Merge branch 'develop' into iframe

This commit is contained in:
Rohit
2025-01-04 15:51:51 +05:30
committed by GitHub
56 changed files with 3858 additions and 657 deletions

View File

@@ -0,0 +1,74 @@
import React, { useState } from 'react';
import { useSocketStore } from '../../context/socket';
import { Coordinates } from './canvas';
interface DateTimeLocalPickerProps {
coordinates: Coordinates;
selector: string;
onClose: () => void;
}
const DateTimeLocalPicker: React.FC<DateTimeLocalPickerProps> = ({ coordinates, selector, onClose }) => {
const { socket } = useSocketStore();
const [selectedDateTime, setSelectedDateTime] = useState<string>('');
const handleDateTimeChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setSelectedDateTime(e.target.value);
};
const handleConfirm = () => {
if (socket && selectedDateTime) {
socket.emit('input:datetime-local', {
selector,
value: selectedDateTime
});
onClose();
}
};
return (
<div
style={{
position: 'absolute',
left: `${coordinates.x}px`,
top: `${coordinates.y}px`,
zIndex: 1000,
backgroundColor: 'white',
boxShadow: '0 2px 10px rgba(0,0,0,0.1)',
padding: '10px',
borderRadius: '4px'
}}
>
<div className="flex flex-col space-y-2">
<input
type="datetime-local"
onChange={handleDateTimeChange}
value={selectedDateTime}
className="p-2 border rounded"
autoFocus
/>
<div className="flex justify-end space-x-2">
<button
onClick={onClose}
className="px-3 py-1 text-sm text-gray-600 hover:text-gray-800 border rounded"
>
Cancel
</button>
<button
onClick={handleConfirm}
disabled={!selectedDateTime}
className={`px-3 py-1 text-sm rounded ${
selectedDateTime
? 'bg-blue-500 text-white hover:bg-blue-600'
: 'bg-gray-300 text-gray-500 cursor-not-allowed'
}`}
>
Confirm
</button>
</div>
</div>
</div>
);
};
export default DateTimeLocalPicker;

View File

@@ -6,6 +6,7 @@ import { useActionContext } from '../../context/browserActions';
import DatePicker from './DatePicker';
import Dropdown from './Dropdown';
import TimePicker from './TimePicker';
import DateTimeLocalPicker from './DateTimeLocalPicker';
interface CreateRefCallback {
(ref: React.RefObject<HTMLCanvasElement>): void;
@@ -55,6 +56,11 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => {
selector: string;
} | null>(null);
const [dateTimeLocalInfo, setDateTimeLocalInfo] = React.useState<{
coordinates: Coordinates;
selector: string;
} | null>(null);
const notifyLastAction = (action: string) => {
if (lastAction !== action) {
setLastAction(action);
@@ -91,9 +97,15 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => {
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]);
@@ -222,6 +234,13 @@ const Canvas = ({ width, height, onCreateRef }: CanvasProps) => {
onClose={() => setTimePickerInfo(null)}
/>
)}
{dateTimeLocalInfo && (
<DateTimeLocalPicker
coordinates={dateTimeLocalInfo.coordinates}
selector={dateTimeLocalInfo.selector}
onClose={() => setDateTimeLocalInfo(null)}
/>
)}
</div>
);

View File

@@ -3,6 +3,7 @@ import styled from 'styled-components';
import { Typography, FormControlLabel, Checkbox, Box } from '@mui/material';
import { useActionContext } from '../../context/browserActions';
import MaxunLogo from "../../assets/maxunlogo.png";
import { useTranslation } from 'react-i18next';
const CustomBoxContainer = styled.div`
position: relative;
@@ -44,6 +45,7 @@ const Content = styled.div`
`;
const ActionDescriptionBox = () => {
const { t } = useTranslation();
const { getText, getScreenshot, getList, captureStage } = useActionContext() as {
getText: boolean;
getScreenshot: boolean;
@@ -52,36 +54,36 @@ const ActionDescriptionBox = () => {
};
const messages = [
{ stage: 'initial' as const, text: 'Select the list you want to extract along with the texts inside it' },
{ stage: 'pagination' as const, text: 'Select how the robot can capture the rest of the list' },
{ stage: 'limit' as const, text: 'Choose the number of items to extract' },
{ stage: 'complete' as const, text: 'Capture is complete' },
{ stage: 'initial' as const, text: t('action_description.list_stages.initial') },
{ stage: 'pagination' as const, text: t('action_description.list_stages.pagination') },
{ stage: 'limit' as const, text: t('action_description.list_stages.limit') },
{ stage: 'complete' as const, text: t('action_description.list_stages.complete') },
];
const stages = messages.map(({ stage }) => stage); // Create a list of stages
const currentStageIndex = stages.indexOf(captureStage); // Get the index of the current stage
const stages = messages.map(({ stage }) => stage);
const currentStageIndex = stages.indexOf(captureStage);
const renderActionDescription = () => {
if (getText) {
return (
<>
<Typography variant="subtitle2" gutterBottom>Capture Text</Typography>
<Typography variant="body2" gutterBottom>Hover over the texts you want to extract and click to select them</Typography>
<Typography variant="subtitle2" gutterBottom>{t('action_description.text.title')}</Typography>
<Typography variant="body2" gutterBottom>{t('action_description.text.description')}</Typography>
</>
);
} else if (getScreenshot) {
return (
<>
<Typography variant="subtitle2" gutterBottom>Capture Screenshot</Typography>
<Typography variant="body2" gutterBottom>Capture a partial or full page screenshot of the current page.</Typography>
<Typography variant="subtitle2" gutterBottom>{t('action_description.screenshot.title')}</Typography>
<Typography variant="body2" gutterBottom>{t('action_description.screenshot.description')}</Typography>
</>
);
} else if (getList) {
return (
<>
<Typography variant="subtitle2" gutterBottom>Capture List</Typography>
<Typography variant="subtitle2" gutterBottom>{t('action_description.list.title')}</Typography>
<Typography variant="body2" gutterBottom>
Hover over the list you want to extract. Once selected, you can hover over all texts inside the list you selected. Click to select them.
{t('action_description.list.description')}
</Typography>
<Box>
{messages.map(({ stage, text }, index) => (
@@ -89,7 +91,7 @@ const ActionDescriptionBox = () => {
key={stage}
control={
<Checkbox
checked={index < currentStageIndex} // Check the box if we are past this stage
checked={index < currentStageIndex}
disabled
/>
}
@@ -102,8 +104,8 @@ const ActionDescriptionBox = () => {
} else {
return (
<>
<Typography variant="subtitle2" gutterBottom>What data do you want to extract?</Typography>
<Typography variant="body2" gutterBottom>A robot is designed to perform one action at a time. You can choose any of the options below.</Typography>
<Typography variant="subtitle2" gutterBottom>{t('action_description.default.title')}</Typography>
<Typography variant="body2" gutterBottom>{t('action_description.default.description')}</Typography>
</>
);
}
@@ -111,7 +113,7 @@ const ActionDescriptionBox = () => {
return (
<CustomBoxContainer>
<Logo src={MaxunLogo} alt="Maxun Logo" />
<Logo src={MaxunLogo} alt='maxun_logo' />
<Triangle />
<Content>
{renderActionDescription()}
@@ -120,4 +122,4 @@ const ActionDescriptionBox = () => {
);
};
export default ActionDescriptionBox;
export default ActionDescriptionBox;

View File

@@ -5,8 +5,10 @@ import { useGlobalInfoStore } from '../../context/globalInfo';
import { stopRecording } from "../../api/recording";
import { useNavigate } from 'react-router-dom';
import { GenericModal } from "../atoms/GenericModal";
import { useTranslation } from 'react-i18next';
const BrowserRecordingSave = () => {
const { t } = useTranslation();
const [openModal, setOpenModal] = useState<boolean>(false);
const { recordingName, browserId, setBrowserId, notify } = useGlobalInfoStore();
const navigate = useNavigate();
@@ -14,7 +16,7 @@ const BrowserRecordingSave = () => {
const goToMainMenu = async () => {
if (browserId) {
await stopRecording(browserId);
notify('warning', 'Current Recording was terminated');
notify('warning', t('browser_recording.notifications.terminated'));
setBrowserId(null);
}
navigate('/');
@@ -25,30 +27,29 @@ const BrowserRecordingSave = () => {
<Grid item xs={12} md={3} lg={3}>
<div style={{
marginTop: '12px',
// marginLeft: '10px',
color: 'white',
position: 'absolute',
background: '#ff00c3',
border: 'none',
borderRadius: '5px',
padding: '7.5px',
width: 'calc(100% - 20px)', // Ensure it takes full width but with padding
width: 'calc(100% - 20px)',
overflow: 'hidden',
display: 'flex',
justifyContent: 'space-between',
}}>
<Button onClick={() => setOpenModal(true)} variant="outlined" style={{ marginLeft: "25px" }} size="small" color="error">
Discard
{t('right_panel.buttons.discard')}
</Button>
<GenericModal isOpen={openModal} onClose={() => setOpenModal(false)} modalStyle={modalStyle}>
<Box p={2}>
<Typography variant="h6">Are you sure you want to discard the recording?</Typography>
<Typography variant="h6">{t('browser_recording.modal.confirm_discard')}</Typography>
<Box display="flex" justifyContent="space-between" mt={2}>
<Button onClick={goToMainMenu} variant="contained" color="error">
Discard
{t('right_panel.buttons.discard')}
</Button>
<Button onClick={() => setOpenModal(false)} variant="outlined">
Cancel
{t('right_panel.buttons.cancel')}
</Button>
</Box>
</Box>
@@ -60,7 +61,7 @@ const BrowserRecordingSave = () => {
);
}
export default BrowserRecordingSave
export default BrowserRecordingSave;
const modalStyle = {
top: '25%',

View File

@@ -10,6 +10,7 @@ import { RunContent } from "./RunContent";
import { GenericModal } from "../atoms/GenericModal";
import { modalStyle } from "./AddWhereCondModal";
import { getUserById } from "../../api/auth";
import { useTranslation } from "react-i18next";
interface RunTypeChipProps {
runByUserId?: string;
@@ -18,10 +19,12 @@ interface RunTypeChipProps {
}
const RunTypeChip: React.FC<RunTypeChipProps> = ({ runByUserId, runByScheduledId, runByAPI }) => {
if (runByUserId) return <Chip label="Manual Run" color="primary" variant="outlined" />;
if (runByScheduledId) return <Chip label="Scheduled Run" color="primary" variant="outlined" />;
if (runByAPI) return <Chip label="API" color="primary" variant="outlined" />;
return <Chip label="Unknown Run Type" color="primary" variant="outlined" />;
const { t } = useTranslation();
if (runByUserId) return <Chip label={t('runs_table.run_type_chips.manual_run')} color="primary" variant="outlined" />;
if (runByScheduledId) return <Chip label={t('runs_table.run_type_chips.scheduled_run')} color="primary" variant="outlined" />;
if (runByAPI) return <Chip label={t('runs_table.run_type_chips.api')} color="primary" variant="outlined" />;
return <Chip label={t('runs_table.run_type_chips.unknown_run_type')} color="primary" variant="outlined" />;
};
interface CollapsibleRowProps {
@@ -33,6 +36,7 @@ interface CollapsibleRowProps {
runningRecordingName: string;
}
export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRunHandler, runningRecordingName }: CollapsibleRowProps) => {
const { t } = useTranslation();
const [open, setOpen] = useState(isOpen);
const [openSettingsModal, setOpenSettingsModal] = useState(false);
const [userEmail, setUserEmail] = useState<string | null>(null);
@@ -99,12 +103,12 @@ export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRun
} else {
switch (column.id) {
case 'runStatus':
return (
return (
<TableCell key={column.id} align={column.align}>
{row.status === 'success' && <Chip label="Success" color="success" variant="outlined" />}
{row.status === 'running' && <Chip label="Running" color="warning" variant="outlined" />}
{row.status === 'scheduled' && <Chip label="Scheduled" variant="outlined" />}
{row.status === 'failed' && <Chip label="Failed" color="error" variant="outlined" />}
{row.status === 'success' && <Chip label={t('runs_table.run_status_chips.success')} color="success" variant="outlined" />}
{row.status === 'running' && <Chip label={t('runs_table.run_status_chips.running')} color="warning" variant="outlined" />}
{row.status === 'scheduled' && <Chip label={t('runs_table.run_status_chips.scheduled')} variant="outlined" />}
{row.status === 'failed' && <Chip label={t('runs_table.run_status_chips.failed')} color="error" variant="outlined" />}
</TableCell>
)
case 'delete':
@@ -133,21 +137,35 @@ export const CollapsibleRow = ({ row, handleDelete, isOpen, currentLog, abortRun
modalStyle={modalStyle}
>
<>
<Typography variant="h5" style={{ marginBottom: '20px' }}>Run Settings</Typography>
<Typography variant="h5" style={{ marginBottom: '20px' }}>
{t('runs_table.run_settings_modal.title')}
</Typography>
<Box style={{ display: 'flex', flexDirection: 'column', gap: '20px' }}>
<TextField
label="Run ID"
label={t('runs_table.run_settings_modal.labels.run_id')}
value={row.runId}
InputProps={{ readOnly: true }}
/>
<TextField
label={row.runByUserId ? "Run by User" : row.runByScheduleId ? "Run by Schedule ID" : "Run by API"}
label={
row.runByUserId
? t('runs_table.run_settings_modal.labels.run_by_user')
: row.runByScheduleId
? t('runs_table.run_settings_modal.labels.run_by_schedule')
: t('runs_table.run_settings_modal.labels.run_by_api')
}
value={runByLabel}
InputProps={{ readOnly: true }}
/>
<Box style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<Typography variant="body1">Run Type:</Typography>
<RunTypeChip runByUserId={row.runByUserId} runByScheduledId={row.runByScheduleId} runByAPI={row.runByAPI ?? false} />
<Typography variant="body1">
{t('runs_table.run_settings_modal.labels.run_type')}:
</Typography>
<RunTypeChip
runByUserId={row.runByUserId}
runByScheduledId={row.runByScheduleId}
runByAPI={row.runByAPI ?? false}
/>
</Box>
</Box>
</>

View File

@@ -15,6 +15,7 @@ import { useGlobalInfoStore } from "../../context/globalInfo";
import { getStoredRecording } from "../../api/storage";
import { apiUrl } from "../../apiConfig.js";
import Cookies from 'js-cookie';
import { useTranslation } from "react-i18next";
interface IntegrationProps {
isOpen: boolean;
@@ -33,6 +34,7 @@ export const IntegrationSettingsModal = ({
handleStart,
handleClose,
}: IntegrationProps) => {
const { t } = useTranslation();
const [settings, setSettings] = useState<IntegrationSettings>({
spreadsheetId: "",
spreadsheetName: "",
@@ -77,8 +79,9 @@ export const IntegrationSettingsModal = ({
);
notify(
"error",
`Error fetching spreadsheet files: ${error.response?.data?.message || error.message
}`
t('integration_settings.errors.fetch_error', {
message: error.response?.data?.message || error.message
})
);
}
};
@@ -107,7 +110,7 @@ export const IntegrationSettingsModal = ({
},
{ withCredentials: true }
);
notify(`success`, `Google Sheet selected successfully`)
notify(`success`, t('integration_settings.notifications.sheet_selected'));
console.log("Google Sheet ID updated:", response.data);
} catch (error: any) {
console.error(
@@ -168,38 +171,28 @@ export const IntegrationSettingsModal = ({
return (
<GenericModal isOpen={isOpen} onClose={handleClose} modalStyle={modalStyle}>
<div
style={{
<div style={{
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
marginLeft: "65px",
}}
>
}}>
<Typography variant="h6">
Integrate with Google Sheet{" "}
{/* <Chip label="beta" color="primary" variant="outlined" /> */}
{t('integration_settings.title')}
</Typography>
{recording && recording.google_sheet_id ? (
<>
<Alert severity="info" sx={{ marginTop: '10px', border: '1px solid #ff00c3' }}>
<AlertTitle>Google Sheet Integrated Successfully.</AlertTitle>
Every time this robot creates a successful run, its captured data
is appended to your {recording.google_sheet_name} Google Sheet.
You can check the data updates{" "}
<a
href={`https://docs.google.com/spreadsheets/d/${recording.google_sheet_id}`}
<AlertTitle>{t('integration_settings.alerts.success.title')}</AlertTitle>
{t('integration_settings.alerts.success.content', { sheetName: recording.google_sheet_name })}
<a href={`https://docs.google.com/spreadsheets/d/${recording.google_sheet_id}`}
target="_blank"
rel="noreferrer"
>
here
</a>
.
rel="noreferrer">
{t('integration_settings.alerts.success.here')}
</a>.
<br />
<strong>Note:</strong> The data extracted before integrating with
Google Sheets will not be synced in the Google Sheet. Only the
data extracted after the integration will be synced.
<strong>{t('integration_settings.alerts.success.note')}</strong> {t('integration_settings.alerts.success.sync_limitation')}
</Alert>
<Button
variant="outlined"
@@ -207,31 +200,29 @@ export const IntegrationSettingsModal = ({
onClick={removeIntegration}
style={{ marginTop: "15px" }}
>
Remove Integration
{t('integration_settings.buttons.remove_integration')}
</Button>
</>
) : (
<>
{!recording?.google_sheet_email ? (
<>
<p>
If you enable this option, every time this robot runs a task
successfully, its captured data will be appended to your
Google Sheet.
</p>
<p>{t('integration_settings.descriptions.sync_info')}</p>
<Button
variant="contained"
color="primary"
onClick={authenticateWithGoogle}
>
Authenticate with Google
{t('integration_settings.buttons.authenticate')}
</Button>
</>
) : (
<>
{recording.google_sheet_email && (
<Typography sx={{ margin: "20px 0px 30px 0px" }}>
Authenticated as: {recording.google_sheet_email}
{t('integration_settings.descriptions.authenticated_as', {
email: recording.google_sheet_email
})}
</Typography>
)}
@@ -247,14 +238,14 @@ export const IntegrationSettingsModal = ({
color="primary"
onClick={fetchSpreadsheetFiles}
>
Fetch Google Spreadsheets
{t('integration_settings.buttons.fetch_sheets')}
</Button>
<Button
variant="outlined"
color="error"
onClick={removeIntegration}
>
Remove Integration
{t('integration_settings.buttons.remove_integration')}
</Button>
</div>
</>
@@ -263,7 +254,7 @@ export const IntegrationSettingsModal = ({
<TextField
sx={{ marginBottom: "15px" }}
select
label="Select Google Sheet"
label={t('integration_settings.fields.select_sheet')}
required
value={settings.spreadsheetId}
onChange={handleSpreadsheetSelect}
@@ -278,13 +269,10 @@ export const IntegrationSettingsModal = ({
{settings.spreadsheetId && (
<Typography sx={{ marginBottom: "10px" }}>
Selected Sheet:{" "}
{
spreadsheets.find(
(s) => s.id === settings.spreadsheetId
)?.name
}{" "}
(ID: {settings.spreadsheetId})
{t('integration_settings.fields.selected_sheet', {
name: spreadsheets.find((s) => s.id === settings.spreadsheetId)?.name,
id: settings.spreadsheetId
})}
</Typography>
)}
@@ -298,7 +286,7 @@ export const IntegrationSettingsModal = ({
style={{ marginTop: "10px" }}
disabled={!settings.spreadsheetId || loading}
>
Submit
{t('integration_settings.buttons.submit')}
</Button>
</>
)}

View File

@@ -7,6 +7,7 @@ import { useGlobalInfoStore } from "../../context/globalInfo";
import { GenericModal } from "../atoms/GenericModal";
import { WhereWhatPair } from "maxun-core";
import HelpIcon from '@mui/icons-material/Help';
import { useTranslation } from "react-i18next";
interface InterpretationButtonsProps {
enableStepping: (isPaused: boolean) => void;
@@ -23,6 +24,7 @@ const interpretationInfo: InterpretationInfo = {
};
export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsProps) => {
const { t } = useTranslation();
const [info, setInfo] = useState<InterpretationInfo>(interpretationInfo);
const [decisionModal, setDecisionModal] = useState<{
pair: WhereWhatPair | null,
@@ -44,9 +46,9 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP
const breakpointHitHandler = useCallback(() => {
setInfo({ running: false, isPaused: true });
notify('warning', 'Please restart the interpretation after updating the recording');
notify('warning', t('interpretation_buttons.messages.restart_required'));
enableStepping(true);
}, [enableStepping]);
}, [enableStepping, t]);
const decisionHandler = useCallback(
({ pair, actionType, lastData }: { pair: WhereWhatPair | null, actionType: string, lastData: { selector: string, action: string, tagName: string, innerText: string } }) => {
@@ -73,11 +75,12 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP
return (
<>
<Typography>
Do you want to use your previous selection as a condition for performing this action?
{t('interpretation_buttons.modal.use_previous')}
</Typography>
<Box style={{ marginTop: '4px' }}>
<Typography>
Your previous action was: <b>{decisionModal.action}</b>, on an element with text <b>{decisionModal.innerText}</b>
{t('interpretation_buttons.modal.previous_action')} <b>{decisionModal.action}</b>,
{t('interpretation_buttons.modal.element_text')} <b>{decisionModal.innerText}</b>
</Typography>
</Box>
</>
@@ -105,9 +108,9 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP
const finished = await interpretCurrentRecording();
setInfo({ ...info, running: false });
if (finished) {
notify('info', 'Run finished');
notify('info', t('interpretation_buttons.messages.run_finished'));
} else {
notify('error', 'Run failed to start');
notify('error', t('interpretation_buttons.messages.run_failed'));
}
}
};
@@ -139,9 +142,12 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP
disabled={info.running}
sx={{ display: 'grid' }}
>
{info.running ? <Box sx={{ display: 'flex', alignItems: 'center' }}>
<CircularProgress size={22} color="inherit" sx={{ marginRight: '10px' }} /> Extracting data...please wait for 10secs to 1min
</Box> : 'Get Preview of Output Data'}
{info.running ? (
<Box sx={{ display: 'flex', alignItems: 'center' }}>
<CircularProgress size={22} color="inherit" sx={{ marginRight: '10px' }} />
{t('interpretation_buttons.messages.extracting')}
</Box>
) : t('interpretation_buttons.buttons.preview')}
</Button>
<GenericModal
onClose={() => { }}
@@ -166,8 +172,12 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP
<HelpIcon />
{handleDescription()}
<div style={{ float: 'right' }}>
<Button onClick={() => handleDecision(true)} color='success'>Yes</Button>
<Button onClick={() => handleDecision(false)} color='error'>No</Button>
<Button onClick={() => handleDecision(true)} color='success'>
{t('interpretation_buttons.buttons.yes')}
</Button>
<Button onClick={() => handleDecision(false)} color='error'>
{t('interpretation_buttons.buttons.no')}
</Button>
</div>
</div>
</GenericModal>

View File

@@ -17,6 +17,7 @@ import StorageIcon from '@mui/icons-material/Storage';
import ArrowUpwardIcon from '@mui/icons-material/ArrowUpward';
import { SidePanelHeader } from './SidePanelHeader';
import { useGlobalInfoStore } from '../../context/globalInfo';
import { useTranslation } from 'react-i18next';
interface InterpretationLogProps {
isOpen: boolean;
@@ -24,6 +25,7 @@ interface InterpretationLogProps {
}
export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, setIsOpen }) => {
const { t } = useTranslation();
const [log, setLog] = useState<string>('');
const [customValue, setCustomValue] = useState('');
const [tableData, setTableData] = useState<any[]>([]);
@@ -33,7 +35,7 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
const { width } = useBrowserDimensionsStore();
const { socket } = useSocketStore();
const { currentWorkflowActionsState } = useGlobalInfoStore();
const { currentWorkflowActionsState, shouldResetInterpretationLog, notify } = useGlobalInfoStore();
const toggleDrawer = (newOpen: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => {
if (
@@ -63,34 +65,43 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
const handleSerializableCallback = useCallback((data: any) => {
setLog((prevState) =>
prevState + '\n' + '---------- Serializable output data received ----------' + '\n'
+ JSON.stringify(data, null, 2) + '\n' + '--------------------------------------------------');
prevState + '\n' + t('interpretation_log.data_sections.serializable_received') + '\n'
+ JSON.stringify(data, null, 2) + '\n' + t('interpretation_log.data_sections.separator'));
if (Array.isArray(data)) {
setTableData(data);
}
scrollLogToBottom();
}, [log, scrollLogToBottom]);
}, [log, scrollLogToBottom, t]);
const handleBinaryCallback = useCallback(({ data, mimetype }: any) => {
const base64String = Buffer.from(data).toString('base64');
const imageSrc = `data:${mimetype};base64,${base64String}`;
setLog((prevState) =>
prevState + '\n' + '---------- Binary output data received ----------' + '\n'
+ `mimetype: ${mimetype}` + '\n' + 'Image is rendered below:' + '\n'
+ '------------------------------------------------');
prevState + '\n' + t('interpretation_log.data_sections.binary_received') + '\n'
+ t('interpretation_log.data_sections.mimetype') + mimetype + '\n'
+ t('interpretation_log.data_sections.image_below') + '\n'
+ t('interpretation_log.data_sections.separator'));
setBinaryData(imageSrc);
scrollLogToBottom();
}, [log, scrollLogToBottom]);
}, [log, scrollLogToBottom, t]);
const handleCustomValueChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setCustomValue(event.target.value);
};
useEffect(() => {
if (shouldResetInterpretationLog) {
setLog('');
setTableData([]);
setBinaryData(null);
}
}, [shouldResetInterpretationLog]);
useEffect(() => {
socket?.on('log', handleLog);
socket?.on('serializableCallback', handleSerializableCallback);
@@ -136,7 +147,8 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
},
}}
>
<ArrowUpwardIcon fontSize="inherit" sx={{ marginRight: '10px'}} /> Output Data Preview
<ArrowUpwardIcon fontSize="inherit" sx={{ marginRight: '10px'}} />
{t('interpretation_log.titles.output_preview')}
</Button>
<SwipeableDrawer
anchor="bottom"
@@ -155,9 +167,10 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
},
}}
>
<Typography variant="h6" gutterBottom style={{ display: 'flex', alignItems: 'center' }}>
<StorageIcon style={{ marginRight: '8px' }} /> Output Data Preview
</Typography>
<Typography variant="h6" gutterBottom style={{ display: 'flex', alignItems: 'center' }}>
<StorageIcon style={{ marginRight: '8px' }} />
{t('interpretation_log.titles.output_preview')}
</Typography>
<div
style={{
height: '50vh',
@@ -168,8 +181,10 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
{
binaryData ? (
<div style={{ marginBottom: '20px' }}>
<Typography variant="body1" gutterBottom>Screenshot</Typography>
<img src={binaryData} alt="Binary Output" style={{ maxWidth: '100%' }} />
<Typography variant="body1" gutterBottom>
{t('interpretation_log.titles.screenshot')}
</Typography>
<img src={binaryData} alt={t('interpretation_log.titles.screenshot')} style={{ maxWidth: '100%' }} />
</div>
) : tableData.length > 0 ? (
<>
@@ -193,7 +208,9 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
</TableBody>
</Table>
</TableContainer>
<span style={{ marginLeft: '15px', marginTop: '10px', fontSize: '12px' }}>Additional rows of data will be extracted once you finish recording. </span>
<span style={{ marginLeft: '15px', marginTop: '10px', fontSize: '12px' }}>
{t('interpretation_log.messages.additional_rows')}
</span>
</>
) : (
<Grid container justifyContent="center" alignItems="center" style={{ height: '100%' }}>
@@ -201,13 +218,13 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
{hasScrapeListAction || hasScrapeSchemaAction || hasScreenshotAction ? (
<>
<Typography variant="h6" gutterBottom align="left">
You've successfully trained the robot to perform actions! Click on the button below to get a preview of the data your robot will extract.
{t('interpretation_log.messages.successful_training')}
</Typography>
<SidePanelHeader />
</>
) : (
<Typography variant="h6" gutterBottom align="left">
It looks like you have not selected anything for extraction yet. Once you do, the robot will show a preview of your selections here.
{t('interpretation_log.messages.no_selection')}
</Typography>
)}
</Grid>
@@ -219,4 +236,4 @@ export const InterpretationLog: React.FC<InterpretationLogProps> = ({ isOpen, se
</Grid>
</Grid>
);
}
};

View File

@@ -1,10 +1,11 @@
import { useTranslation } from "react-i18next";
import React, { useState, useContext, useEffect } from 'react';
import axios from 'axios';
import styled from "styled-components";
import { stopRecording } from "../../api/recording";
import { useGlobalInfoStore } from "../../context/globalInfo";
import { IconButton, Menu, MenuItem, Typography, Chip, Button, Modal, Tabs, Tab, Box, Snackbar } from "@mui/material";
import { AccountCircle, Logout, Clear, YouTube, X, Update, Close } from "@mui/icons-material";
import { AccountCircle, Logout, Clear, YouTube, X, Update, Close, Language } from "@mui/icons-material";
import { useNavigate } from 'react-router-dom';
import { AuthContext } from '../../context/auth';
import { SaveRecording } from '../molecules/SaveRecording';
@@ -13,18 +14,26 @@ import { apiUrl } from '../../apiConfig';
import MaxunLogo from "../../assets/maxunlogo.png";
import packageJson from "../../../package.json"
interface NavBarProps {
recordingName: string;
isRecording: boolean;
}
export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) => {
const { notify, browserId, setBrowserId, recordingUrl } = useGlobalInfoStore();
export const NavBar: React.FC<NavBarProps> = ({
recordingName,
isRecording,
}) => {
const { notify, browserId, setBrowserId } = useGlobalInfoStore();
const { state, dispatch } = useContext(AuthContext);
const { user } = state;
const navigate = useNavigate();
const { t, i18n } = useTranslation();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [langAnchorEl, setLangAnchorEl] = useState<null | HTMLElement>(null);
const currentVersion = packageJson.version;
const [open, setOpen] = useState(false);
@@ -40,7 +49,7 @@ export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) =>
return version;
} catch (error) {
console.error("Failed to fetch latest version:", error);
return null; // Handle errors gracefully
return null;
}
};
@@ -62,33 +71,43 @@ export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) =>
setAnchorEl(event.currentTarget);
};
const handleLangMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setLangAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setAnchorEl(null);
setLangAnchorEl(null);
};
const logout = async () => {
dispatch({ type: 'LOGOUT' });
window.localStorage.removeItem('user');
dispatch({ type: "LOGOUT" });
window.localStorage.removeItem("user");
const { data } = await axios.get(`${apiUrl}/auth/logout`);
notify('success', data.message);
navigate('/login');
notify("success", data.message);
navigate("/login");
};
const goToMainMenu = async () => {
if (browserId) {
await stopRecording(browserId);
notify('warning', 'Current Recording was terminated');
notify("warning", t('browser_recording.notifications.terminated'));
setBrowserId(null);
}
navigate('/');
navigate("/");
};
const changeLanguage = (lang: string) => {
i18n.changeLanguage(lang);
localStorage.setItem("language", lang);
};
useEffect(() => {
const checkForUpdates = async () => {
const latestVersion = await fetchLatestVersion();
setLatestVersion(latestVersion); // Set the latest version state
setLatestVersion(latestVersion);
if (latestVersion && latestVersion !== currentVersion) {
setIsUpdateAvailable(true); // Show a notification or highlight the "Upgrade" button
setIsUpdateAvailable(true);
}
};
checkForUpdates();
@@ -101,7 +120,7 @@ export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) =>
open={isUpdateAvailable}
onClose={() => setIsUpdateAvailable(false)}
message={
`New version ${latestVersion} available! Click "Upgrade" to update.`
`${t('navbar.upgrade.modal.new_version_available', { version: latestVersion })} ${t('navbar.upgrade.modal.view_updates')}`
}
action={
<>
@@ -118,7 +137,7 @@ export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) =>
borderRadius: '5px',
}}
>
Upgrade
{t('navbar.upgrade.button')}
</Button>
<IconButton
size="small"
@@ -138,7 +157,6 @@ export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) =>
}
}}
/>
)}
<NavBarWrapper>
<div style={{
@@ -146,7 +164,7 @@ export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) =>
justifyContent: 'flex-start',
}}>
<img src={MaxunLogo} width={45} height={40} style={{ borderRadius: '5px', margin: '5px 0px 5px 15px' }} />
<div style={{ padding: '11px' }}><ProjectName>Maxun</ProjectName></div>
<div style={{ padding: '11px' }}><ProjectName>{t('navbar.project_name')}</ProjectName></div>
<Chip
label={`${currentVersion}`}
color="primary"
@@ -165,7 +183,7 @@ export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) =>
border: "#00000099 1px solid",
'&:hover': { color: '#ff00c3', border: '#ff00c3 1px solid' }
}}>
<Update sx={{ marginRight: '5px' }} /> Upgrade Maxun
<Update sx={{ marginRight: '5px' }} /> {t('navbar.upgrade.button')} Maxun
</Button>
<Modal open={open} onClose={handleUpdateClose}>
<Box
@@ -185,14 +203,14 @@ export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) =>
<Typography>Checking for updates...</Typography>
) : currentVersion === latestVersion ? (
<Typography variant="h6" textAlign="center">
🎉 You're up to date!
{t('navbar.upgrade.modal.up_to_date')}
</Typography>
) : (
<>
<Typography variant="body1" textAlign="left" sx={{ marginLeft: '30px' }}>
A new version is available: {latestVersion}. Upgrade to the latest version for bug fixes, enhancements and new features!
{t('navbar.upgrade.modal.new_version_available', { version: latestVersion })}
<br />
View all the new updates
{t('navbar.upgrade.modal.view_updates')}
<a href="https://github.com/getmaxun/maxun/releases/" target="_blank" style={{ textDecoration: 'none' }}>{' '}here.</a>
</Typography>
<Tabs
@@ -201,8 +219,8 @@ export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) =>
sx={{ marginTop: 2, marginBottom: 2 }}
centered
>
<Tab label="Manual Setup Upgrade" />
<Tab label="Docker Compose Setup Upgrade" />
<Tab label={t('navbar.upgrade.modal.tabs.manual_setup')} />
<Tab label={t('navbar.upgrade.modal.tabs.docker_setup')} />
</Tabs>
{tab === 0 && (
<Box sx={{ marginLeft: '30px', background: '#cfd0d1', padding: 1, borderRadius: 3 }}>
@@ -285,7 +303,7 @@ export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) =>
PaperProps={{ sx: { width: '180px' } }}
>
<MenuItem onClick={() => { handleMenuClose(); logout(); }}>
<Logout sx={{ marginRight: '5px' }} /> Logout
<Logout sx={{ marginRight: '5px' }} /> {t('navbar.menu_items.logout')}
</MenuItem>
<MenuItem onClick={() => {
window.open('https://discord.gg/5GbPjBUkws', '_blank');
@@ -300,8 +318,65 @@ export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) =>
<MenuItem onClick={() => {
window.open('https://x.com/maxun_io?ref=app', '_blank');
}}>
<X sx={{ marginRight: '5px' }} /> Twiiter (X)
<X sx={{ marginRight: '5px' }} /> Twitter (X)
</MenuItem>
<MenuItem onClick={handleLangMenuOpen}>
<Language sx={{ marginRight: '5px' }} /> {t('navbar.menu_items.language')}
</MenuItem>
<Menu
anchorEl={langAnchorEl}
open={Boolean(langAnchorEl)}
onClose={handleMenuClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<MenuItem
onClick={() => {
changeLanguage("en");
handleMenuClose();
}}
>
English
</MenuItem>
<MenuItem
onClick={() => {
changeLanguage("es");
handleMenuClose();
}}
>
Español
</MenuItem>
<MenuItem
onClick={() => {
changeLanguage("ja");
handleMenuClose();
}}
>
</MenuItem>
<MenuItem
onClick={() => {
changeLanguage("zh");
handleMenuClose();
}}
>
</MenuItem>
<MenuItem
onClick={() => {
changeLanguage("de");
handleMenuClose();
}}
>
Deutsch
</MenuItem>
</Menu>
</Menu>
</>
) : (
@@ -315,14 +390,80 @@ export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) =>
'&:hover': { color: 'white', backgroundColor: 'red' }
}}>
<Clear sx={{ marginRight: '5px' }} />
Discard
{t('navbar.recording.discard')}
</IconButton>
<SaveRecording fileName={recordingName} />
</>
)}
</div>
) : ""
}
) : (
<><IconButton
onClick={handleLangMenuOpen}
sx={{
display: "flex",
alignItems: "center",
borderRadius: "5px",
padding: "8px",
marginRight: "10px",
}}
>
<Language sx={{ marginRight: '5px' }} /><Typography variant="body1">{t("Language")}</Typography>
</IconButton>
<Menu
anchorEl={langAnchorEl}
open={Boolean(langAnchorEl)}
onClose={handleMenuClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "right",
}}
transformOrigin={{
vertical: "top",
horizontal: "right",
}}
>
<MenuItem
onClick={() => {
changeLanguage("en");
handleMenuClose();
}}
>
English
</MenuItem>
<MenuItem
onClick={() => {
changeLanguage("es");
handleMenuClose();
}}
>
Español
</MenuItem>
<MenuItem
onClick={() => {
changeLanguage("ja");
handleMenuClose();
}}
>
</MenuItem>
<MenuItem
onClick={() => {
changeLanguage("zh");
handleMenuClose();
}}
>
</MenuItem>
<MenuItem
onClick={() => {
changeLanguage("de");
handleMenuClose();
}}
>
Deutsch
</MenuItem>
</Menu></>
)}
</NavBarWrapper>
</>
);
@@ -331,7 +472,7 @@ export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) =>
const NavBarWrapper = styled.div`
grid-area: navbar;
background-color: white;
padding:5px;
padding: 5px;
display: flex;
justify-content: space-between;
border-bottom: 1px solid #e0e0e0;

View File

@@ -1,4 +1,5 @@
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import Paper from '@mui/material/Paper';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
@@ -19,6 +20,7 @@ import { useNavigate } from 'react-router-dom';
import { stopRecording } from "../../api/recording";
import { GenericModal } from '../atoms/GenericModal';
/** TODO:
* 1. allow editing existing robot after persisting browser steps
*/
@@ -31,31 +33,6 @@ interface Column {
format?: (value: string) => string;
}
const columns: readonly Column[] = [
{ id: 'interpret', label: 'Run', minWidth: 80 },
{ id: 'name', label: 'Name', minWidth: 80 },
{
id: 'schedule',
label: 'Schedule',
minWidth: 80,
},
{
id: 'integrate',
label: 'Integrate',
minWidth: 80,
},
{
id: 'settings',
label: 'Settings',
minWidth: 80,
},
{
id: 'options',
label: 'Options',
minWidth: 80,
},
];
interface Data {
id: string;
name: string;
@@ -76,12 +53,38 @@ interface RecordingsTableProps {
}
export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot }: RecordingsTableProps) => {
const {t} = useTranslation();
const [page, setPage] = React.useState(0);
const [rowsPerPage, setRowsPerPage] = React.useState(10);
const [rows, setRows] = React.useState<Data[]>([]);
const [isModalOpen, setModalOpen] = React.useState(false);
const [searchTerm, setSearchTerm] = React.useState('');
const columns: readonly Column[] = [
{ id: 'interpret', label: t('recordingtable.run'), minWidth: 80 },
{ id: 'name', label: t('recordingtable.name'), minWidth: 80 },
{
id: 'schedule',
label: t('recordingtable.schedule'),
minWidth: 80,
},
{
id: 'integrate',
label: t('recordingtable.integrate'),
minWidth: 80,
},
{
id: 'settings',
label: t('recordingtable.settings'),
minWidth: 80,
},
{
id: 'options',
label: t('recordingtable.options'),
minWidth: 80,
},
];
const { notify, setRecordings, browserId, setBrowserId, recordingUrl, setRecordingUrl, recordingName, setRecordingName, recordingId, setRecordingId } = useGlobalInfoStore();
const navigate = useNavigate();
@@ -151,16 +154,17 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
row.name.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<React.Fragment>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Typography variant="h6" gutterBottom>
My Robots
{t('recordingtable.heading')}
</Typography>
<Box display="flex" alignItems="center" gap={2}>
<TextField
size="small"
placeholder="Search robots..."
placeholder={t('recordingtable.search')}
value={searchTerm}
onChange={handleSearchChange}
InputProps={{
@@ -187,7 +191,7 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
'&:hover': { color: 'white', backgroundColor: '#ff00c3' }
}}
>
<Add sx={{ marginRight: '5px' }} /> Create Robot
<Add sx={{ marginRight: '5px' }} /> {t('recordingtable.new')}
</IconButton>
</Box>
</Box>
@@ -253,14 +257,14 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
checkRunsForRecording(row.id).then((result: boolean) => {
if (result) {
notify('warning', 'Cannot delete robot as it has associated runs');
notify('warning', t('recordingtable.notifications.delete_warning'));
}
})
deleteRecordingFromStorage(row.id).then((result: boolean) => {
if (result) {
setRows([]);
notify('success', 'Robot deleted successfully');
notify('success', t('recordingtable.notifications.delete_success'));
fetchRecordings();
}
})
@@ -297,9 +301,9 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
/>
<GenericModal isOpen={isModalOpen} onClose={() => setModalOpen(false)} modalStyle={modalStyle}>
<div style={{ padding: '20px' }}>
<Typography variant="h6" gutterBottom>Enter URL To Extract Data</Typography>
<Typography variant="h6" gutterBottom>{t('recordingtable.modal.title')}</Typography>
<TextField
label="URL"
label={t('recordingtable.modal.label')}
variant="outlined"
fullWidth
value={recordingUrl}
@@ -312,7 +316,7 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
onClick={startRecording}
disabled={!recordingUrl}
>
Start Training Robot
{t('recordingtable.modal.button')}
</Button>
</div>
</GenericModal>
@@ -397,6 +401,8 @@ const OptionsButton = ({ handleEdit, handleDelete, handleDuplicate }: OptionsBut
setAnchorEl(null);
};
const {t} = useTranslation();
return (
<>
<IconButton
@@ -415,19 +421,21 @@ const OptionsButton = ({ handleEdit, handleDelete, handleDuplicate }: OptionsBut
<ListItemIcon>
<Edit fontSize="small" />
</ListItemIcon>
<ListItemText>Edit</ListItemText>
</MenuItem>
<MenuItem onClick={() => { handleDuplicate(); handleClose(); }}>
<ListItemIcon>
<ContentCopy fontSize="small" />
</ListItemIcon>
<ListItemText>Duplicate</ListItemText>
<ListItemText>{t('recordingtable.edit')}</ListItemText>
</MenuItem>
<MenuItem onClick={() => { handleDelete(); handleClose(); }}>
<ListItemIcon>
<DeleteForever fontSize="small" />
</ListItemIcon>
<ListItemText>Delete</ListItemText>
<ListItemText>{t('recordingtable.delete')}</ListItemText>
</MenuItem>
<MenuItem onClick={() => { handleDuplicate(); handleClose(); }}>
<ListItemIcon>
<ContentCopy fontSize="small" />
</ListItemIcon>
<ListItemText>{t('recordingtable.duplicate')}</ListItemText>
</MenuItem>
</Menu>
</>

View File

@@ -6,6 +6,7 @@ import { useGlobalInfoStore } from '../../context/globalInfo';
import { duplicateRecording, getStoredRecording } from '../../api/storage';
import { WhereWhatPair } from 'maxun-core';
import { getUserById } from "../../api/auth";
import { useTranslation } from 'react-i18next';
interface RobotMeta {
name: string;
@@ -54,6 +55,7 @@ interface RobotSettingsProps {
}
export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
const { t } = useTranslation();
const [robot, setRobot] = useState<RobotSettings | null>(null);
const [targetUrl, setTargetUrl] = useState<string | undefined>('');
const { recordingId, notify } = useGlobalInfoStore();
@@ -65,7 +67,6 @@ export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initia
}, [isOpen]);
useEffect(() => {
// Update the targetUrl when the robot data is loaded
if (robot) {
const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1];
const url = lastPair?.what.find(action => action.action === "goto")?.args?.[0];
@@ -78,43 +79,36 @@ export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initia
const robot = await getStoredRecording(recordingId);
setRobot(robot);
} else {
notify('error', 'Could not find robot details. Please try again.');
notify('error', t('robot_duplication.notifications.robot_not_found'));
}
}
// const lastPair = robot?.recording.workflow[robot?.recording.workflow.length - 1];
// // Find the `goto` action in `what` and retrieve its arguments
// const targetUrl = lastPair?.what.find(action => action.action === "goto")?.args?.[0];
const handleTargetUrlChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setTargetUrl(e.target.value);
};
const handleSave = async () => {
if (!robot || !targetUrl) {
notify('error', 'Target URL is required.');
notify('error', t('robot_duplication.notifications.url_required'));
return;
}
console.log("handle save");
try {
const success = await duplicateRecording(robot.recording_meta.id, targetUrl);
if (success) {
notify('success', 'Robot duplicated successfully.');
handleStart(robot); // Inform parent about the updated robot
notify('success', t('robot_duplication.notifications.duplicate_success'));
handleStart(robot);
handleClose();
setTimeout(() => {
window.location.reload();
}, 1000);
} else {
notify('error', 'Failed to update the Target URL. Please try again.');
notify('error', t('robot_duplication.notifications.duplicate_error'));
}
} catch (error) {
notify('error', 'An error occurred while updating the Target URL.');
notify('error', t('robot_duplication.notifications.unknown_error'));
console.error('Error updating Target URL:', error);
}
};
@@ -126,34 +120,40 @@ export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initia
modalStyle={modalStyle}
>
<>
<Typography variant="h5" style={{ marginBottom: '20px' }}>Duplicate Robot</Typography>
<Typography variant="h5" style={{ marginBottom: '20px' }}>
{t('robot_duplication.title')}
</Typography>
<Box style={{ display: 'flex', flexDirection: 'column' }}>
{
robot && (
<>
<span>Robot duplication is useful to extract data from pages with the same structure.</span>
<span>
{t('robot_duplication.descriptions.purpose')}
</span>
<br />
<span dangerouslySetInnerHTML={{
__html: t('robot_duplication.descriptions.example', {
url1: '<code>producthunt.com/topics/api</code>',
url2: '<code>producthunt.com/topics/database</code>'
})
}}/>
<br />
<span>
Example: If you've created a robot for <code>producthunt.com/topics/api</code>, you can duplicate it to scrape similar pages
like <code>producthunt.com/topics/database</code> without training a robot from scratch.
</span>
<br />
<span>
<b>⚠️ Ensure the new page has the same structure as the original page.</b>
<b>{t('robot_duplication.descriptions.warning')}</b>
</span>
<TextField
label="Robot Target URL"
key="Robot Target URL"
label={t('robot_duplication.fields.target_url')}
key={t('robot_duplication.fields.target_url')}
value={targetUrl}
onChange={handleTargetUrlChange}
style={{ marginBottom: '20px', marginTop: '30px' }}
/>
<Box mt={2} display="flex" justifyContent="flex-end" onClick={handleSave}>
<Button variant="contained" color="primary">
Duplicate Robot
<Box mt={2} display="flex" justifyContent="flex-end">
<Button variant="contained" color="primary" onClick={handleSave}>
{t('robot_duplication.buttons.duplicate')}
</Button>
<Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}>
Cancel
{t('robot_duplication.buttons.cancel')}
</Button>
</Box>
</>

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { GenericModal } from "../atoms/GenericModal";
import { TextField, Typography, Box, Button } from "@mui/material";
import { modalStyle } from "./AddWhereCondModal";
@@ -54,10 +55,10 @@ interface RobotSettingsProps {
handleStart: (settings: RobotSettings) => void;
handleClose: () => void;
initialSettings?: RobotSettings | null;
}
export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
const { t } = useTranslation();
const [robot, setRobot] = useState<RobotSettings | null>(null);
const { recordingId, notify } = useGlobalInfoStore();
@@ -72,7 +73,7 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
const robot = await getStoredRecording(recordingId);
setRobot(robot);
} else {
notify('error', 'Could not find robot details. Please try again.');
notify('error', t('robot_edit.notifications.update_failed'));
}
}
@@ -102,6 +103,7 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
return { ...prev, recording: { ...prev.recording, workflow: updatedWorkflow } };
});
};
const handleSave = async () => {
if (!robot) return;
@@ -114,7 +116,7 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
const success = await updateRecording(robot.recording_meta.id, payload);
if (success) {
notify('success', 'Robot updated successfully.');
notify('success', t('robot_edit.notifications.update_success'));
handleStart(robot); // Inform parent about the updated robot
handleClose();
@@ -122,10 +124,10 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
window.location.reload();
}, 1000);
} else {
notify('error', 'Failed to update the robot. Please try again.');
notify('error', t('robot_edit.notifications.update_failed'));
}
} catch (error) {
notify('error', 'An error occurred while updating the robot.');
notify('error', t('robot_edit.notifications.update_error'));
console.error('Error updating robot:', error);
}
};
@@ -137,14 +139,16 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
modalStyle={modalStyle}
>
<>
<Typography variant="h5" style={{ marginBottom: '20px' }}>Edit Robot</Typography>
<Typography variant="h5" style={{ marginBottom: '20px' }}>
{t('robot_edit.title')}
</Typography>
<Box style={{ display: 'flex', flexDirection: 'column' }}>
{
robot && (
<>
<TextField
label="Change Robot Name"
key="Change Robot Name"
label={t('robot_edit.change_name')}
key="Robot Name"
type='text'
value={robot.recording_meta.name}
onChange={(e) => handleRobotNameChange(e.target.value)}
@@ -152,7 +156,7 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
/>
{robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== undefined && (
<TextField
label="Robot Limit"
label={t('robot_edit.robot_limit')}
type="number"
value={robot.recording.workflow[0].what[0].args[0].limit || ''}
onChange={(e) =>{
@@ -166,12 +170,17 @@ export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettin
/>
)}
<Box mt={2} display="flex" justifyContent="flex-end" onClick={handleSave}>
<Button variant="contained" color="primary">
Save Changes
<Box mt={2} display="flex" justifyContent="flex-end">
<Button variant="contained" color="primary" onClick={handleSave}>
{t('robot_edit.save')}
</Button>
<Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}>
Cancel
<Button
onClick={handleClose}
color="primary"
variant="outlined"
style={{ marginLeft: '10px' }}
>
{t('robot_edit.cancel')}
</Button>
</Box>
</>

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { GenericModal } from "../atoms/GenericModal";
import { TextField, Typography, Box } from "@mui/material";
import { modalStyle } from "./AddWhereCondModal";
@@ -50,10 +51,10 @@ interface RobotSettingsProps {
handleStart: (settings: RobotSettings) => void;
handleClose: () => void;
initialSettings?: RobotSettings | null;
}
export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
const { t } = useTranslation();
const [robot, setRobot] = useState<RobotSettings | null>(null);
const [userEmail, setUserEmail] = useState<string | null>(null);
const { recordingId, notify } = useGlobalInfoStore();
@@ -69,7 +70,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
const robot = await getStoredRecording(recordingId);
setRobot(robot);
} else {
notify('error', 'Could not find robot details. Please try again.');
notify('error', t('robot_settings.errors.robot_not_found'));
}
}
@@ -97,13 +98,15 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
modalStyle={modalStyle}
>
<>
<Typography variant="h5" style={{ marginBottom: '20px' }}>Robot Settings</Typography>
<Typography variant="h5" style={{ marginBottom: '20px' }}>
{t('robot_settings.title')}
</Typography>
<Box style={{ display: 'flex', flexDirection: 'column' }}>
{
robot && (
<>
<TextField
label="Robot Target URL"
label={t('robot_settings.target_url')}
key="Robot Target URL"
value={targetUrl}
InputProps={{
@@ -112,7 +115,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
style={{ marginBottom: '20px' }}
/>
<TextField
label="Robot ID"
label={t('robot_settings.robot_id')}
key="Robot ID"
value={robot.recording_meta.id}
InputProps={{
@@ -122,17 +125,17 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
/>
{robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== undefined && (
<TextField
label="Robot Limit"
label={t('robot_settings.robot_limit')}
type="number"
value={robot.recording.workflow[0].what[0].args[0].limit || ''}
InputProps={{
readOnly: true,
}}
readOnly: true,
}}
style={{ marginBottom: '20px' }}
/>
)}
<TextField
label="Created By User"
label={t('robot_settings.created_by_user')}
key="Created By User"
value={userEmail ? userEmail : ''}
InputProps={{
@@ -141,7 +144,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
style={{ marginBottom: '20px' }}
/>
<TextField
label="Robot Created At"
label={t('robot_settings.created_at')}
key="Robot Created At"
value={robot.recording_meta.createdAt}
InputProps={{
@@ -156,4 +159,4 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
</>
</GenericModal>
);
};
};

View File

@@ -13,6 +13,7 @@ import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TableRow from '@mui/material/TableRow';
import 'highlight.js/styles/github.css';
import { useTranslation } from "react-i18next";
interface RunContentProps {
row: Data,
@@ -23,6 +24,7 @@ interface RunContentProps {
}
export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRef, abortRunHandler }: RunContentProps) => {
const { t } = useTranslation();
const [tab, setTab] = React.useState<string>('log');
const [tableData, setTableData] = useState<any[]>([]);
const [columns, setColumns] = useState<string[]>([]);
@@ -76,8 +78,8 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
<TabContext value={tab}>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={tab} onChange={(e, newTab) => setTab(newTab)} aria-label="run-content-tabs">
<Tab label="Output Data" value='output' />
<Tab label="Log" value='log' />
<Tab label={t('run_content.tabs.output_data')} value='output' />
<Tab label={t('run_content.tabs.log')} value='log' />
</Tabs>
</Box>
<TabPanel value='log'>
@@ -102,32 +104,32 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
color="error"
onClick={abortRunHandler}
>
Stop
{t('run_content.buttons.stop')}
</Button> : null}
</TabPanel>
<TabPanel value='output' sx={{ width: '700px' }}>
{!row || !row.serializableOutput || !row.binaryOutput
|| (Object.keys(row.serializableOutput).length === 0 && Object.keys(row.binaryOutput).length === 0)
? <Typography>The output is empty.</Typography> : null}
? <Typography>{t('run_content.empty_output')}</Typography> : null}
{row.serializableOutput &&
Object.keys(row.serializableOutput).length !== 0 &&
<div>
<Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }}>
<ArticleIcon sx={{ marginRight: '15px' }} />
Captured Data
{t('run_content.captured_data.title')}
</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 2, mt: 2 }}>
<Typography>
<a style={{ textDecoration: 'none' }} href={`data:application/json;utf8,${JSON.stringify(row.serializableOutput, null, 2)}`}
download="data.json">
Download as JSON
{t('run_content.captured_data.download_json')}
</a>
</Typography>
<Typography
onClick={downloadCSV}
>
<a style={{ textDecoration: 'none', cursor: 'pointer' }}>Download as CSV</a>
<a style={{ textDecoration: 'none', cursor: 'pointer' }}>{t('run_content.captured_data.download_csv')}</a>
</Typography>
</Box>
{tableData.length > 0 ? (
@@ -171,7 +173,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
<div>
<Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }}>
<ImageIcon sx={{ marginRight: '15px' }} />
Captured Screenshot
{t('run_content.captured_screenshot.title')}
</Typography>
{Object.keys(row.binaryOutput).map((key) => {
try {
@@ -181,7 +183,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
width: 'max-content',
}}>
<Typography sx={{ margin: '20px 0px' }}>
<a href={imageUrl} download={key} style={{ textDecoration: 'none' }}>Download Screenshot</a>
<a href={imageUrl} download={key} style={{ textDecoration: 'none' }}>{t('run_content.captured_screenshot.download')}</a>
</Typography>
<img src={imageUrl} alt={key} height='auto' width='700px' />
</Box>
@@ -189,7 +191,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
} catch (e) {
console.log(e)
return <Typography key={`number-of-binary-output-${key}`}>
{key}: The image failed to render
{key}: {t('run_content.captured_screenshot.render_failed')}
</Typography>
}
})}

View File

@@ -1,4 +1,6 @@
import * as React from 'react';
import { useEffect, useState } from "react";
import { useTranslation } from 'react-i18next';
import Paper from '@mui/material/Paper';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
@@ -7,14 +9,24 @@ import TableContainer from '@mui/material/TableContainer';
import TableHead from '@mui/material/TableHead';
import TablePagination from '@mui/material/TablePagination';
import TableRow from '@mui/material/TableRow';
import { useEffect, useState } from "react";
import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import SearchIcon from '@mui/icons-material/Search';
import { useGlobalInfoStore } from "../../context/globalInfo";
import { getStoredRuns } from "../../api/storage";
import { RunSettings } from "./RunSettings";
import { CollapsibleRow } from "./ColapsibleRow";
import { Accordion, AccordionSummary, AccordionDetails, Typography, Box, TextField } from '@mui/material';
import ExpandMoreIcon from '@mui/icons-material/ExpandMore';
import SearchIcon from '@mui/icons-material/Search';
// Export columns before the component
export const columns: readonly Column[] = [
{ id: 'runStatus', label: 'Status', minWidth: 80 },
{ id: 'name', label: 'Name', minWidth: 80 },
{ id: 'startedAt', label: 'Started At', minWidth: 80 },
{ id: 'finishedAt', label: 'Finished At', minWidth: 80 },
{ id: 'settings', label: 'Settings', minWidth: 80 },
{ id: 'delete', label: 'Delete', minWidth: 80 },
];
interface Column {
id: 'runStatus' | 'name' | 'startedAt' | 'finishedAt' | 'delete' | 'settings';
@@ -24,15 +36,6 @@ interface Column {
format?: (value: string) => string;
}
export const columns: readonly Column[] = [
{ id: 'runStatus', label: 'Status', minWidth: 80 },
{ id: 'name', label: 'Robot Name', minWidth: 80 },
{ id: 'startedAt', label: 'Started at', minWidth: 80 },
{ id: 'finishedAt', label: 'Finished at', minWidth: 80 },
{ id: 'settings', label: 'Settings', minWidth: 80 },
{ id: 'delete', label: 'Delete', minWidth: 80 },
];
export interface Data {
id: number;
status: string;
@@ -58,15 +61,25 @@ interface RunsTableProps {
runningRecordingName: string;
}
export const RunsTable = (
{ currentInterpretationLog, abortRunHandler, runId, runningRecordingName }: RunsTableProps) => {
export const RunsTable: React.FC<RunsTableProps> = ({
currentInterpretationLog,
abortRunHandler,
runId,
runningRecordingName
}) => {
const { t } = useTranslation();
// Update column labels using translation if needed
const translatedColumns = columns.map(column => ({
...column,
label: t(`runstable.${column.id}`, column.label)
}));
const [page, setPage] = useState(0);
const [rowsPerPage, setRowsPerPage] = useState(10);
const [rows, setRows] = useState<Data[]>([]);
const [searchTerm, setSearchTerm] = useState('');
const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore();
const handleChangePage = (event: unknown, newPage: number) => {
@@ -86,16 +99,13 @@ export const RunsTable = (
const fetchRuns = async () => {
const runs = await getStoredRuns();
if (runs) {
const parsedRows: Data[] = [];
runs.map((run: any, index) => {
parsedRows.push({
id: index,
...run,
});
});
const parsedRows: Data[] = runs.map((run: any, index: number) => ({
id: index,
...run,
}));
setRows(parsedRows);
} else {
notify('error', 'No runs found. Please try again.')
notify('error', t('runstable.notifications.no_runs'));
}
};
@@ -104,15 +114,14 @@ export const RunsTable = (
fetchRuns();
setRerenderRuns(false);
}
}, [rerenderRuns]);
}, [rerenderRuns, rows.length, setRerenderRuns]);
const handleDelete = () => {
setRows([]);
notify('success', 'Run deleted successfully');
notify('success', t('runstable.notifications.delete_success'));
fetchRuns();
};
// Filter rows based on search term
const filteredRows = rows.filter((row) =>
row.name.toLowerCase().includes(searchTerm.toLowerCase())
@@ -120,7 +129,6 @@ export const RunsTable = (
// Group filtered rows by robot meta id
const groupedRows = filteredRows.reduce((acc, row) => {
if (!acc[row.robotMetaId]) {
acc[row.robotMetaId] = [];
}
@@ -132,11 +140,11 @@ export const RunsTable = (
<React.Fragment>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={2}>
<Typography variant="h6" gutterBottom>
All Runs
{t('runstable.runs', 'Runs')}
</Typography>
<TextField
size="small"
placeholder="Search runs..."
placeholder={t('runstable.search', 'Search runs...')}
value={searchTerm}
onChange={handleSearchChange}
InputProps={{
@@ -149,16 +157,14 @@ export const RunsTable = (
{Object.entries(groupedRows).map(([id, data]) => (
<Accordion key={id}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">{data[data.length - 1].name}</Typography>
</AccordionSummary>
<AccordionDetails>
<Table stickyHeader aria-label="sticky table">
<TableHead>
<TableRow>
<TableCell />
{columns.map((column) => (
{translatedColumns.map((column) => (
<TableCell
key={column.id}
align={column.align}
@@ -200,4 +206,4 @@ export const RunsTable = (
/>
</React.Fragment>
);
};
};

View File

@@ -9,13 +9,14 @@ import { TextField, Typography } from "@mui/material";
import { WarningText } from "../atoms/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);
@@ -46,7 +47,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
};
const exitRecording = useCallback(async () => {
notify('success', 'Robot saved successfully');
notify('success', t('save_recording.notifications.save_success'));
if (browserId) {
await stopRecording(browserId);
}
@@ -63,7 +64,7 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
setWaitingForSave(true);
console.log(`Saving the recording as ${recordingName} for userId ${user.id}`);
} else {
console.error('User not logged in. Cannot save recording.');
console.error(t('save_recording.notifications.user_not_logged'));
}
};
@@ -77,34 +78,38 @@ export const SaveRecording = ({ fileName }: SaveRecordingProps) => {
return (
<div>
<Button onClick={() => setOpenModal(true)} variant="outlined" sx={{ marginRight: '20px' }} size="small" color="success">
Finish
{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">Save Robot</Typography>
<Typography variant="h6">{t('save_recording.title')}</Typography>
<TextField
required
sx={{ width: '300px', margin: '15px 0px' }}
onChange={handleChangeOfTitle}
id="title"
label="Robot Name"
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' }}>Confirm</Button>
<Button color="error" variant="contained" onClick={saveRecording} sx={{ marginTop: '10px' }}>
{t('save_recording.buttons.confirm')}
</Button>
<WarningText>
<NotificationImportantIcon color="warning" />
Robot with this name already exists, please confirm the Robot's overwrite.
{t('save_recording.errors.exists_warning')}
</WarningText>
</React.Fragment>)
: <Button type="submit" variant="contained" sx={{ marginTop: '10px' }}>Save</Button>
: <Button type="submit" variant="contained" sx={{ marginTop: '10px' }}>
{t('save_recording.buttons.save')}
</Button>
}
{waitingForSave &&
<Tooltip title='Optimizing and saving the workflow' placement={"bottom"}>
<Tooltip title={t('save_recording.tooltips.optimizing')} placement={"bottom"}>
<Box sx={{ width: '100%', marginTop: '10px' }}>
<LinearProgress />
</Box>

View File

@@ -1,4 +1,5 @@
import React, { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { GenericModal } from "../atoms/GenericModal";
import { MenuItem, TextField, Typography, Box } from "@mui/material";
import { Dropdown } from "../atoms/DropdownMui";
@@ -25,6 +26,7 @@ export interface ScheduleSettings {
}
export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initialSettings }: ScheduleSettingsProps) => {
const { t } = useTranslation();
const [schedule, setSchedule] = useState<ScheduleSettings | null>(null);
const [settings, setSettings] = useState<ScheduleSettings>({
runEvery: 1,
@@ -77,12 +79,13 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
'SUNDAY'
];
const { recordingId } = useGlobalInfoStore();
const { recordingId, notify } = useGlobalInfoStore();
const deleteRobotSchedule = () => {
if (recordingId) {
deleteSchedule(recordingId);
setSchedule(null);
notify('success', t('Schedule deleted successfully'));
} else {
console.error('No recording id provided');
}
@@ -116,6 +119,25 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
}
}, [isOpen]);
const getDayOrdinal = (day: string | undefined) => {
if (!day) return '';
const lastDigit = day.slice(-1);
const lastTwoDigits = day.slice(-2);
// Special cases for 11, 12, 13
if (['11', '12', '13'].includes(lastTwoDigits)) {
return t('schedule_settings.labels.on_day.th');
}
// Other cases
switch (lastDigit) {
case '1': return t('schedule_settings.labels.on_day.st');
case '2': return t('schedule_settings.labels.on_day.nd');
case '3': return t('schedule_settings.labels.on_day.rd');
default: return t('schedule_settings.labels.on_day.th');
}
};
return (
<GenericModal
isOpen={isOpen}
@@ -129,30 +151,30 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
padding: '20px',
'& > *': { marginBottom: '20px' },
}}>
<Typography variant="h6" sx={{ marginBottom: '20px' }}>Schedule Settings</Typography>
<Typography variant="h6" sx={{ marginBottom: '20px' }}>{t('schedule_settings.title')}</Typography>
<>
{schedule !== null ? (
<>
<Typography>Run every: {schedule.runEvery} {schedule.runEveryUnit.toLowerCase()}</Typography>
<Typography>{['MONTHS', 'WEEKS'].includes(settings.runEveryUnit) ? "Start From" : "On"} {schedule.startFrom.charAt(0).toUpperCase() + schedule.startFrom.slice(1).toLowerCase()}</Typography>
<Typography>{t('schedule_settings.run_every')}: {schedule.runEvery} {schedule.runEveryUnit.toLowerCase()}</Typography>
<Typography>{['MONTHS', 'WEEKS'].includes(settings.runEveryUnit) ? t('schedule_settings.start_from') : t('schedule_settings.start_from')}: {schedule.startFrom.charAt(0).toUpperCase() + schedule.startFrom.slice(1).toLowerCase()}</Typography>
{schedule.runEveryUnit === 'MONTHS' && (
<Typography>On day: {schedule.dayOfMonth}{['1', '21', '31'].includes(schedule.dayOfMonth || '') ? 'st' : ['2', '22'].includes(schedule.dayOfMonth || '') ? 'nd' : ['3', '23'].includes(schedule.dayOfMonth || '') ? 'rd' : 'th'} of the month</Typography>
<Typography>{t('schedule_settings.on_day')}: {schedule.dayOfMonth}{getDayOrdinal(schedule.dayOfMonth)} of the month</Typography>
)}
<Typography>At around: {schedule.atTimeStart}, {schedule.timezone} Timezone</Typography>
<Typography>{t('schedule_settings.at_around')}: {schedule.atTimeStart}, {schedule.timezone} {t('schedule_settings.timezone')}</Typography>
<Box mt={2} display="flex" justifyContent="space-between">
<Button
onClick={deleteRobotSchedule}
variant="outlined"
color="error"
>
Delete Schedule
{t('schedule_settings.buttons.delete_schedule')}
</Button>
</Box>
</>
) : (
<>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Typography sx={{ marginRight: '10px' }}>Run once every</Typography>
<Typography sx={{ marginRight: '10px' }}>{t('schedule_settings.labels.run_once_every')}</Typography>
<TextField
type="number"
value={settings.runEvery}
@@ -174,7 +196,9 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
</Box>
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Typography sx={{ marginBottom: '5px', marginRight: '25px' }}>{['MONTHS', 'WEEKS'].includes(settings.runEveryUnit) ? "Start From" : "On"}</Typography>
<Typography sx={{ marginBottom: '5px', marginRight: '25px' }}>
{['MONTHS', 'WEEKS'].includes(settings.runEveryUnit) ? t('schedule_settings.labels.start_from_label') : t('schedule_settings.labels.start_from_label')}
</Typography>
<Dropdown
label=""
id="startFrom"
@@ -190,7 +214,7 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
{settings.runEveryUnit === 'MONTHS' && (
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Typography sx={{ marginBottom: '5px', marginRight: '25px' }}>On Day of the Month</Typography>
<Typography sx={{ marginBottom: '5px', marginRight: '25px' }}>{t('schedule_settings.labels.on_day_of_month')}</Typography>
<TextField
type="number"
value={settings.dayOfMonth}
@@ -204,7 +228,7 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
{['MINUTES', 'HOURS'].includes(settings.runEveryUnit) ? (
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Box sx={{ marginRight: '20px' }}>
<Typography sx={{ marginBottom: '5px' }}>In Between</Typography>
<Typography sx={{ marginBottom: '5px' }}>{t('schedule_settings.labels.in_between')}</Typography>
<TextField
type="time"
value={settings.atTimeStart}
@@ -221,7 +245,7 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
</Box>
) : (
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Typography sx={{ marginBottom: '5px', marginRight: '10px' }}>At Around</Typography>
<Typography sx={{ marginBottom: '5px', marginRight: '10px' }}>{t('schedule_settings.at_around')}</Typography>
<TextField
type="time"
value={settings.atTimeStart}
@@ -232,7 +256,7 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
)}
<Box sx={{ display: 'flex', alignItems: 'center', width: '100%' }}>
<Typography sx={{ marginRight: '10px' }}>Timezone</Typography>
<Typography sx={{ marginRight: '10px' }}>{t('schedule_settings.timezone')}</Typography>
<Dropdown
label=""
id="timezone"
@@ -247,10 +271,10 @@ export const ScheduleSettingsModal = ({ isOpen, handleStart, handleClose, initia
</Box>
<Box mt={2} display="flex" justifyContent="flex-end">
<Button onClick={() => handleStart(settings)} variant="contained" color="primary">
Save Schedule
{t('schedule_settings.buttons.save_schedule')}
</Button>
<Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}>
Cancel
{t('schedule_settings.buttons.cancel')}
</Button>
</Box>
</>
@@ -271,4 +295,4 @@ const modalStyle = {
height: 'fit-content',
display: 'block',
padding: '20px',
};
};

View File

@@ -19,6 +19,7 @@ import styled from 'styled-components';
import axios from 'axios';
import { useGlobalInfoStore } from '../../context/globalInfo';
import { apiUrl } from '../../apiConfig';
import { useTranslation } from 'react-i18next';
const Container = styled(Box)`
display: flex;
@@ -29,24 +30,21 @@ const Container = styled(Box)`
`;
const ApiKeyManager = () => {
const { t } = useTranslation();
const [apiKey, setApiKey] = useState<string | null>(null);
const [apiKeyName, setApiKeyName] = useState<string>('Maxun API Key');
const [apiKeyName, setApiKeyName] = useState<string>(t('apikey.default_name'));
const [loading, setLoading] = useState<boolean>(true);
const [showKey, setShowKey] = useState<boolean>(false);
const [copySuccess, setCopySuccess] = useState<boolean>(false);
const { notify } = useGlobalInfoStore();
useEffect(() => {
const fetchApiKey = async () => {
try {
const { data } = await axios.get(`${apiUrl}/auth/api-key`);
setApiKey(data.api_key);
} catch (error: any) {
notify('error', `Failed to fetch API Key - ${error.message}`);
notify('error', t('apikey.notifications.fetch_error', { error: error.message }));
} finally {
setLoading(false);
}
@@ -62,9 +60,9 @@ const ApiKeyManager = () => {
const { data } = await axios.post(`${apiUrl}/auth/generate-api-key`);
setApiKey(data.api_key);
notify('success', `Generated API Key successfully`);
notify('success', t('apikey.notifications.generate_success'));
} catch (error: any) {
notify('error', `Failed to generate API Key - ${error.message}`);
notify('error', t('apikey.notifications.generate_error', { error: error.message }));
} finally {
setLoading(false);
}
@@ -75,9 +73,9 @@ const ApiKeyManager = () => {
try {
await axios.delete(`${apiUrl}/auth/delete-api-key`);
setApiKey(null);
notify('success', 'API Key deleted successfully');
notify('success', t('apikey.notifications.delete_success'));
} catch (error: any) {
notify('error', `Failed to delete API Key - ${error.message}`);
notify('error', t('apikey.notifications.delete_error', { error: error.message }));
} finally {
setLoading(false);
}
@@ -88,7 +86,7 @@ const ApiKeyManager = () => {
navigator.clipboard.writeText(apiKey);
setCopySuccess(true);
setTimeout(() => setCopySuccess(false), 2000);
notify('info', 'Copied API Key successfully');
notify('info', t('apikey.notifications.copy_success'));
}
};
@@ -111,34 +109,38 @@ const ApiKeyManager = () => {
return (
<Container sx={{ alignSelf: 'flex-start' }}>
<Typography variant="h6" gutterBottom component="div" style={{ marginBottom: '20px' }}>
Manage Your API Key
{t('apikey.title')}
</Typography>
{apiKey ? (
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden' }}>
<Table>
<TableHead>
<TableRow>
<TableCell>API Key Name</TableCell>
<TableCell>API Key</TableCell>
<TableCell>Actions</TableCell>
<TableCell>{t('apikey.table.name')}</TableCell>
<TableCell>{t('apikey.table.key')}</TableCell>
<TableCell>{t('apikey.table.actions')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
<TableRow>
<TableCell>{apiKeyName}</TableCell>
<TableCell>{showKey ? `${apiKey?.substring(0, 10)}...` : '***************'}</TableCell>
<TableCell>
<Tooltip title="Copy">
<Box sx={{ fontFamily: 'monospace', width: '10ch' }}>
{showKey ? `${apiKey?.substring(0, 10)}...` : '**********'}
</Box>
</TableCell>
<TableCell>
<Tooltip title={t('apikey.actions.copy')}>
<IconButton onClick={copyToClipboard}>
<ContentCopy />
</IconButton>
</Tooltip>
<Tooltip title={showKey ? 'Hide' : 'Show'}>
<Tooltip title={showKey ? t('apikey.actions.hide') : t('apikey.actions.show')}>
<IconButton onClick={() => setShowKey(!showKey)}>
<Visibility />
</IconButton>
</Tooltip>
<Tooltip title="Delete">
<Tooltip title={t('apikey.actions.delete')}>
<IconButton onClick={deleteApiKey} color="error">
<Delete />
</IconButton>
@@ -150,9 +152,9 @@ const ApiKeyManager = () => {
</TableContainer>
) : (
<>
<Typography>You haven't generated an API key yet.</Typography>
<Typography>{t('apikey.no_key_message')}</Typography>
<Button onClick={generateApiKey} variant="contained" color="primary" sx={{ marginTop: '15px' }}>
Generate API Key
{t('apikey.generate_button')}
</Button>
</>
)}

View File

@@ -7,6 +7,7 @@ import { GenericModal } from '../atoms/GenericModal';
import { useActionContext } from '../../context/browserActions';
import { useBrowserSteps, TextStep } from '../../context/browserSteps';
import { useGlobalInfoStore } from '../../context/globalInfo';
import { useTranslation } from 'react-i18next';
interface ElementInfo {
@@ -53,6 +54,7 @@ const getAttributeOptions = (tagName: string, elementInfo: ElementInfo | null):
};
export const BrowserWindow = () => {
const { t } = useTranslation();
const [canvasRef, setCanvasReference] = useState<React.RefObject<HTMLCanvasElement> | undefined>(undefined);
const [screenShot, setScreenShot] = useState<string>("");
const [highlighterData, setHighlighterData] = useState<{ rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[] } | null>(null);
@@ -67,7 +69,7 @@ export const BrowserWindow = () => {
const { socket } = useSocketStore();
const { notify } = useGlobalInfoStore();
const { getText, getList, paginationMode, paginationType, limitMode } = useActionContext();
const { getText, getList, paginationMode, paginationType, limitMode, captureStage } = useActionContext();
const { addTextStep, addListStep } = useBrowserSteps();
const onMouseMove = (e: MouseEvent) => {
@@ -169,7 +171,7 @@ export const BrowserWindow = () => {
// For non-list steps
setHighlighterData(data);
}
}, [highlighterData, getList, socket, listSelector, paginationMode, paginationType]);
}, [highlighterData, getList, socket, listSelector, paginationMode, paginationType, captureStage]);
useEffect(() => {
@@ -183,6 +185,13 @@ export const BrowserWindow = () => {
};
}, [socket, onMouseMove]);
useEffect(() => {
if (captureStage === 'initial' && listSelector) {
socket?.emit('setGetList', { getList: true });
socket?.emit('listSelector', { selector: listSelector });
}
}, [captureStage, listSelector, socket]);
const handleClick = (e: React.MouseEvent<HTMLDivElement>) => {
if (highlighterData && canvasRef?.current) {
const canvasRect = canvasRef.current.getBoundingClientRect();
@@ -227,7 +236,7 @@ export const BrowserWindow = () => {
// Only allow selection in pagination mode if type is not empty, 'scrollDown', or 'scrollUp'
if (paginationType !== '' && paginationType !== 'scrollDown' && paginationType !== 'scrollUp' && paginationType !== 'none') {
setPaginationSelector(highlighterData.selector);
notify(`info`, `Pagination element selected successfully.`);
notify(`info`, t('browser_window.attribute_modal.notifications.pagination_select_success'));
addListStep(listSelector!, fields, currentListId || 0, { type: paginationType, selector: highlighterData.selector });
}
return;
@@ -235,7 +244,7 @@ export const BrowserWindow = () => {
if (getList === true && !listSelector) {
setListSelector(highlighterData.selector);
notify(`info`, `List selected succesfully. Select the text data for extraction.`)
notify(`info`, t('browser_window.attribute_modal.notifications.list_select_success'));
setCurrentListId(Date.now());
setFields({});
} else if (getList === true && listSelector && currentListId) {

View File

@@ -5,6 +5,9 @@ import Box from '@mui/material/Box';
import { Paper, Button } from "@mui/material";
import { AutoAwesome, FormatListBulleted, VpnKey, Usb, CloudQueue, Code } from "@mui/icons-material";
import { apiUrl } from "../../apiConfig";
import { useTranslation } from 'react-i18next';
import i18n from '../../i18n';
interface MainMenuProps {
value: string;
@@ -12,6 +15,7 @@ interface MainMenuProps {
}
export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenuProps) => {
const {t} = useTranslation();
const handleChange = (event: React.SyntheticEvent, newValue: string) => {
handleChangeContent(newValue);
@@ -47,7 +51,7 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu
fontSize: 'medium',
}}
value="recordings"
label="Robots"
label={t('mainmenu.recordings')}
icon={<AutoAwesome />}
iconPosition="start"
/>
@@ -58,7 +62,7 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu
fontSize: 'medium',
}}
value="runs"
label="Runs"
label={t('mainmenu.runs')}
icon={<FormatListBulleted />}
iconPosition="start"
/>
@@ -69,7 +73,7 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu
fontSize: 'medium',
}}
value="proxy"
label="Proxy"
label={t('mainmenu.proxy')}
icon={<Usb />}
iconPosition="start"
/>
@@ -80,7 +84,7 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu
fontSize: 'medium',
}}
value="apikey"
label="API Key"
label={t('mainmenu.apikey')}
icon={<VpnKey />}
iconPosition="start"
/>
@@ -88,10 +92,10 @@ export const MainMenu = ({ value = 'recordings', handleChangeContent }: MainMenu
<hr />
<Box sx={{ display: 'flex', flexDirection: 'column', gap: '1rem', textAlign: 'left' }}>
<Button href={`${apiUrl}/api-docs/`} target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<Code />}>
Website To API
{t('mainmenu.apidocs')}
</Button>
<Button href="https://forms.gle/hXjgqDvkEhPcaBW76" target="_blank" rel="noopener noreferrer" sx={buttonStyles} startIcon={<CloudQueue />}>
Join Maxun Cloud
{t('mainmenu.feedback')}
</Button>
</Box>
</Box>

View File

@@ -3,6 +3,7 @@ import { styled } from '@mui/system';
import { Alert, AlertTitle, TextField, Button, Switch, FormControlLabel, Box, Typography, Tabs, Tab, Table, TableContainer, TableHead, TableRow, TableBody, TableCell, Paper } from '@mui/material';
import { sendProxyConfig, getProxyConfig, testProxyConfig, deleteProxyConfig } from '../../api/proxy';
import { useGlobalInfoStore } from '../../context/globalInfo';
import { useTranslation } from 'react-i18next';
const FormContainer = styled(Box)({
display: 'flex',
@@ -16,6 +17,7 @@ const FormControl = styled(Box)({
});
const ProxyForm: React.FC = () => {
const { t } = useTranslation();
const [proxyConfigForm, setProxyConfigForm] = useState({
server_url: '',
username: '',
@@ -79,13 +81,13 @@ const ProxyForm: React.FC = () => {
try {
const response = await sendProxyConfig(proxyConfigForm);
if (response) {
notify('success', 'Proxy configuration submitted successfully');
notify('success', t('proxy.notifications.config_success'));
} else {
notify('error', `Failed to submit proxy configuration. Try again. ${response}`);
console.log(`Failed to submit proxy configuration. Try again. ${response}`)
notify('error', t('proxy.notifications.config_error'));
console.log(`${t('proxy.notifications.config_error')} ${response}`)
}
} catch (error: any) {
notify('error', `${error} : Failed to submit proxy configuration`);
notify('error', `${error} : ${t('proxy.notifications.config_error')}`);
}
};
@@ -96,9 +98,9 @@ const ProxyForm: React.FC = () => {
const testProxy = async () => {
await testProxyConfig().then((response) => {
if (response.success) {
notify('success', 'Proxy configuration is working');
notify('success', t('proxy.notifications.test_success'));
} else {
notify('error', 'Failed to test proxy configuration. Try again.');
notify('error', t('proxy.notifications.test_error'));
}
});
};
@@ -109,7 +111,7 @@ const ProxyForm: React.FC = () => {
if (response.proxy_url) {
setIsProxyConfigured(true);
setProxy(response);
notify('success', 'Proxy configuration fetched successfully');
notify('success', t('proxy.notifications.fetch_success'));
}
} catch (error: any) {
notify('error', error);
@@ -119,11 +121,11 @@ const ProxyForm: React.FC = () => {
const removeProxy = async () => {
await deleteProxyConfig().then((response) => {
if (response) {
notify('success', 'Proxy configuration removed successfully');
notify('success', t('proxy.notifications.remove_success'));
setIsProxyConfigured(false);
setProxy({ proxy_url: '', auth: false });
} else {
notify('error', 'Failed to remove proxy configuration. Try again.');
notify('error', t('proxy.notifications.remove_error'));
}
});
}
@@ -136,11 +138,11 @@ const ProxyForm: React.FC = () => {
<>
<FormContainer>
<Typography variant="h6" gutterBottom component="div" style={{ marginTop: '20px' }}>
Proxy Configuration
{t('proxy.title')}
</Typography>
<Tabs value={tabIndex} onChange={handleTabChange}>
<Tab label="Standard Proxy" />
<Tab label="Automatic Proxy Rotation" />
<Tab label={t('proxy.tab_standard')} />
<Tab label={t('proxy.tab_rotation')} />
</Tabs>
{tabIndex === 0 && (
isProxyConfigured ? (
@@ -149,8 +151,8 @@ const ProxyForm: React.FC = () => {
<Table>
<TableHead>
<TableRow>
<TableCell><strong>Proxy URL</strong></TableCell>
<TableCell><strong>Requires Authentication</strong></TableCell>
<TableCell><strong>{t('proxy.table.proxy_url')}</strong></TableCell>
<TableCell><strong>{t('proxy.table.requires_auth')}</strong></TableCell>
</TableRow>
</TableHead>
<TableBody>
@@ -162,39 +164,37 @@ const ProxyForm: React.FC = () => {
</Table>
</TableContainer>
<Button variant="outlined" color="primary" onClick={testProxy}>
Test Proxy
{t('proxy.test_proxy')}
</Button>
<Button variant="outlined" color="error" onClick={removeProxy} sx={{ marginLeft: '10px' }}>
Remove Proxy
{t('proxy.remove_proxy')}
</Button>
</Box>
) : (
<Box component="form" onSubmit={handleSubmit} sx={{ maxWidth: 400, width: '100%' }}>
<FormControl>
<TextField
label="Proxy Server URL"
label={t('proxy.server_url')}
name="server_url"
value={proxyConfigForm.server_url}
onChange={handleChange}
fullWidth
required
error={!!errors.server_url}
helperText={errors.server_url || `Proxy to be used for all robots. HTTP and SOCKS proxies are supported.
Example http://myproxy.com:3128 or socks5://myproxy.com:3128.
Short form myproxy.com:3128 is considered an HTTP proxy.`}
helperText={errors.server_url || t('proxy.server_url_helper')}
/>
</FormControl>
<FormControl>
<FormControlLabel
control={<Switch checked={requiresAuth} onChange={handleAuthToggle} />}
label="Requires Authentication?"
label={t('proxy.requires_auth')}
/>
</FormControl>
{requiresAuth && (
<>
<FormControl>
<TextField
label="Username"
label={t('proxy.username')}
name="username"
value={proxyConfigForm.username}
onChange={handleChange}
@@ -206,7 +206,7 @@ const ProxyForm: React.FC = () => {
</FormControl>
<FormControl>
<TextField
label="Password"
label={t('proxy.password')}
name="password"
value={proxyConfigForm.password}
onChange={handleChange}
@@ -226,7 +226,7 @@ const ProxyForm: React.FC = () => {
fullWidth
disabled={!proxyConfigForm.server_url || (requiresAuth && (!proxyConfigForm.username || !proxyConfigForm.password))}
>
Add Proxy
{t('proxy.add_proxy')}
</Button>
</Box>
))}
@@ -234,33 +234,33 @@ const ProxyForm: React.FC = () => {
<Box sx={{ maxWidth: 400, width: '100%', textAlign: 'center', marginTop: '20px' }}>
<>
<Typography variant="body1" gutterBottom component="div">
Coming Soon - In Open Source (Basic Rotation) & Cloud (Advanced Rotation). If you don't want to manage the infrastructure, join our cloud waitlist to get early access.
{t('proxy.coming_soon')}
</Typography>
<Button variant="contained" color="primary" sx={{ marginTop: '20px' }}>
<a style={{ color: 'white', textDecoration: 'none' }} href="https://forms.gle/hXjgqDvkEhPcaBW76">Join Maxun Cloud Waitlist</a>
<a style={{ color: 'white', textDecoration: 'none' }} href="https://forms.gle/hXjgqDvkEhPcaBW76">{t('proxy.join_waitlist')}</a>
</Button>
</>
</Box>
)}
</FormContainer>
<Alert severity="info" sx={{ marginTop: '80px', marginLeft: '50px', height: '230px', width: '450px', border: '1px solid #ff00c3' }}>
<AlertTitle>If your proxy requires a username and password, always provide them separately from the proxy URL. </AlertTitle>
<AlertTitle>{t('proxy.alert.title')}</AlertTitle>
<br />
<b>The right way</b>
<b>{t('proxy.alert.right_way')}</b>
<br />
Proxy URL: http://proxy.com:1337
{t('proxy.alert.proxy_url')} http://proxy.com:1337
<br />
Username: myusername
{t('proxy.alert.username')} myusername
<br />
Password: mypassword
{t('proxy.alert.password')} mypassword
<br />
<br />
<b>The wrong way</b>
<b>{t('proxy.alert.wrong_way')}</b>
<br />
Proxy URL: http://myusername:mypassword@proxy.com:1337
{t('proxy.alert.proxy_url')} http://myusername:mypassword@proxy.com:1337
</Alert>
</>
);
};
export default ProxyForm;
export default ProxyForm;

View File

@@ -22,6 +22,7 @@ import { emptyWorkflow } from "../../shared/constants";
import { getActiveWorkflow } from "../../api/workflow";
import DeleteIcon from '@mui/icons-material/Delete';
import ActionDescriptionBox from '../molecules/ActionDescriptionBox';
import { useTranslation } from 'react-i18next';
const fetchWorkflow = (id: string, callback: (response: WorkflowFile) => void) => {
getActiveWorkflow(id).then(
@@ -55,11 +56,14 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
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 } = useGlobalInfoStore();
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);
@@ -128,6 +132,16 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
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
@@ -139,7 +153,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
setTextLabels(prevLabels => ({ ...prevLabels, [id]: label }));
}
if (!label.trim()) {
setErrors(prevErrors => ({ ...prevErrors, [id]: 'Label cannot be empty' }));
setErrors(prevErrors => ({ ...prevErrors, [id]: t('right_panel.errors.label_required') }));
} else {
setErrors(prevErrors => ({ ...prevErrors, [id]: '' }));
}
@@ -151,7 +165,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
updateBrowserTextStepLabel(id, label);
setConfirmedTextSteps(prev => ({ ...prev, [id]: true }));
} else {
setErrors(prevErrors => ({ ...prevErrors, [id]: 'Label cannot be empty' }));
setErrors(prevErrors => ({ ...prevErrors, [id]: t('right_panel.errors.label_required') }));
}
};
@@ -167,6 +181,22 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
});
};
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,
@@ -193,6 +223,22 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
});
};
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 => {
@@ -213,18 +259,19 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
const stopCaptureAndEmitGetTextSettings = useCallback(() => {
const hasUnconfirmedTextSteps = browserSteps.some(step => step.type === 'text' && !confirmedTextSteps[step.id]);
if (hasUnconfirmedTextSteps) {
notify('error', 'Please confirm all text fields');
notify('error', t('right_panel.errors.confirm_text_fields'));
return;
}
stopGetText();
const settings = getTextSettingsObject();
console.log("SETTINGS", settings);
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]);
}, [stopGetText, getTextSettingsObject, socket, browserSteps, confirmedTextSteps, resetInterpretationLog]);
const getListSettingsObject = useCallback(() => {
let settings: {
@@ -278,7 +325,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
if (settings) {
socket?.emit('action', { action: 'scrapeList', settings });
} else {
notify('error', 'Unable to create list settings. Make sure you have defined a field for the list.');
notify('error', t('right_panel.errors.unable_create_settings'));
}
handleStopGetList();
onFinishCapture();
@@ -296,13 +343,13 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
case 'pagination':
if (!paginationType) {
notify('error', 'Please select a pagination type.');
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', 'Please select the pagination element first.');
notify('error', t('right_panel.errors.select_pagination_element'));
return;
}
stopPaginationMode();
@@ -314,16 +361,17 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
case 'limit':
if (!limitType || (limitType === 'custom' && !customLimit)) {
notify('error', 'Please select a limit or enter a custom limit.');
notify('error', t('right_panel.errors.select_limit'));
return;
}
const limit = limitType === 'custom' ? parseInt(customLimit) : parseInt(limitType);
if (isNaN(limit) || limit <= 0) {
notify('error', 'Please enter a valid limit.');
notify('error', t('right_panel.errors.invalid_limit'));
return;
}
stopLimitMode();
setShowLimitOptions(false);
setIsCaptureListConfirmed(true);
stopCaptureAndEmitGetListSettings();
setCaptureStage('complete');
break;
@@ -334,6 +382,23 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
}
}, [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);
};
@@ -348,7 +413,8 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
setTextLabels({});
setErrors({});
setConfirmedTextSteps({});
notify('error', 'Capture Text Discarded');
setIsCaptureTextConfirmed(false);
notify('error', t('right_panel.errors.capture_text_discarded'));
}, [browserSteps, stopGetText, deleteBrowserStep]);
const discardGetList = useCallback(() => {
@@ -363,7 +429,8 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
setShowLimitOptions(false);
setCaptureStage('initial');
setConfirmedListTextFields({});
notify('error', 'Capture List Discarded');
setIsCaptureListConfirmed(false);
notify('error', t('right_panel.errors.capture_list_discarded'));
}, [browserSteps, stopGetList, deleteBrowserStep, resetListState]);
@@ -402,37 +469,48 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
</SimpleBox> */}
<ActionDescriptionBox />
<Box display="flex" flexDirection="column" gap={2} style={{ margin: '13px' }}>
{!getText && !getScreenshot && !getList && showCaptureList && <Button variant="contained" onClick={startGetList}>Capture List</Button>}
{!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}
>
{t('right_panel.buttons.back')}
</Button>
)}
<Button
variant="outlined"
onClick={handleConfirmListCapture}
disabled={captureStage === 'initial' ? isConfirmCaptureDisabled : hasUnconfirmedListTextFields}
>
{captureStage === 'initial' ? 'Confirm Capture' :
captureStage === 'pagination' ? 'Confirm Pagination' :
captureStage === 'limit' ? 'Confirm Limit' : 'Finish Capture'}
{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}>
{t('right_panel.buttons.discard')}
</Button>
<Button variant="outlined" color="error" onClick={discardGetList}>Discard</Button>
</Box>
</>
)}
{showPaginationOptions && (
<Box display="flex" flexDirection="column" gap={2} style={{ margin: '13px' }}>
<Typography>How can we find the next list item on the page?</Typography>
<Button variant={paginationType === 'clickNext' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('clickNext')}>Click on next to navigate to the next page</Button>
<Button variant={paginationType === 'clickLoadMore' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('clickLoadMore')}>Click on load more to load more items</Button>
<Button variant={paginationType === 'scrollDown' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('scrollDown')}>Scroll down to load more items</Button>
<Button variant={paginationType === 'scrollUp' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('scrollUp')}>Scroll up to load more items</Button>
<Button variant={paginationType === 'none' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('none')}>No more items to load</Button>
<Typography>{t('right_panel.pagination.title')}</Typography>
<Button variant={paginationType === 'clickNext' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('clickNext')}>{t('right_panel.pagination.click_next')}</Button>
<Button variant={paginationType === 'clickLoadMore' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('clickLoadMore')}>{t('right_panel.pagination.click_load_more')}</Button>
<Button variant={paginationType === 'scrollDown' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('scrollDown')}>{t('right_panel.pagination.scroll_down')}</Button>
<Button variant={paginationType === 'scrollUp' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('scrollUp')}>{t('right_panel.pagination.scroll_up')}</Button>
<Button variant={paginationType === 'none' ? "contained" : "outlined"} onClick={() => handlePaginationSettingSelect('none')}>{t('right_panel.pagination.none')}</Button>
</Box>
)}
{showLimitOptions && (
<FormControl>
<FormLabel>
<h4>What is the maximum number of rows you want to extract?</h4>
<h4>{t('right_panel.limit.title')}</h4>
</FormLabel>
<RadioGroup
value={limitType}
@@ -446,13 +524,28 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
<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="Custom" />
<FormControlLabel value="custom" control={<Radio />} label={t('right_panel.limit.custom')} />
{limitType === 'custom' && (
<TextField
type="number"
value={customLimit}
onChange={(e) => updateCustomLimit(e.target.value)}
placeholder="Enter number"
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': {
@@ -467,21 +560,21 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
</RadioGroup>
</FormControl>
)}
{!getText && !getScreenshot && !getList && showCaptureText && <Button variant="contained" onClick={startGetText}>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} >Confirm</Button>
<Button variant="outlined" color="error" onClick={discardGetText} >Discard</Button>
<Button variant="outlined" onClick={stopCaptureAndEmitGetTextSettings} >{t('right_panel.buttons.confirm')}</Button>
<Button variant="outlined" color="error" onClick={discardGetText} >{t('right_panel.buttons.discard')}</Button>
</Box>
</>
}
{!getText && !getScreenshot && !getList && showCaptureScreenshot && <Button variant="contained" onClick={startGetScreenshot}>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)}>Capture Fullpage</Button>
<Button variant="contained" onClick={() => captureScreenshot(false)}>Capture Visible Part</Button>
<Button variant="outlined" color="error" onClick={stopGetScreenshot}>Discard</Button>
<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}>{t('right_panel.buttons.discard')}</Button>
</Box>
)}
</Box>
@@ -492,7 +585,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
step.type === 'text' && (
<>
<TextField
label="Label"
label={t('right_panel.fields.label')}
value={textLabels[step.id] || step.label || ''}
onChange={(e) => handleTextLabelChange(step.id, e.target.value)}
fullWidth
@@ -510,7 +603,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
}}
/>
<TextField
label="Data"
label={t('right_panel.fields.data')}
value={step.data}
fullWidth
margin="normal"
@@ -523,10 +616,20 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
)
}}
/>
{!confirmedTextSteps[step.id] && (
{!confirmedTextSteps[step.id] ? (
<Box display="flex" justifyContent="space-between" gap={2}>
<Button variant="contained" onClick={() => handleTextStepConfirm(step.id)} disabled={!textLabels[step.id]?.trim()}>Confirm</Button>
<Button variant="contained" color="error" onClick={() => handleTextStepDiscard(step.id)}>Discard</Button>
<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>
)}
</>
@@ -535,17 +638,19 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
<Box display="flex" alignItems="center">
<DocumentScannerIcon sx={{ mr: 1 }} />
<Typography>
{`Take ${step.fullPage ? 'Fullpage' : 'Visible Part'} Screenshot`}
{step.fullPage ?
t('right_panel.screenshot.display_fullpage') :
t('right_panel.screenshot.display_visible')}
</Typography>
</Box>
)}
{step.type === 'list' && (
<>
<Typography>List Selected Successfully</Typography>
<Typography>{t('right_panel.messages.list_selected')}</Typography>
{Object.entries(step.fields).map(([key, field]) => (
<Box key={key}>
<TextField
label="Field Label"
label={t('right_panel.fields.field_label')}
value={field.label || ''}
onChange={(e) => handleTextLabelChange(field.id, e.target.value, step.id, key)}
fullWidth
@@ -560,7 +665,7 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
}}
/>
<TextField
label="Field Data"
label={t('right_panel.fields.field_data')}
value={field.data || ''}
fullWidth
margin="normal"
@@ -573,21 +678,31 @@ export const RightSidePanel: React.FC<RightSidePanelProps> = ({ onFinishCapture
)
}}
/>
{!confirmedListTextFields[step.id]?.[key] && (
{!confirmedListTextFields[step.id]?.[key] ? (
<Box display="flex" justifyContent="space-between" gap={2}>
<Button
variant="contained"
onClick={() => handleListTextFieldConfirm(step.id, key)}
disabled={!field.label?.trim()}
>
Confirm
{t('right_panel.buttons.confirm')}
</Button>
<Button
variant="contained"
color="error"
onClick={() => handleListTextFieldDiscard(step.id, key)}
>
Discard
{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>
)}