Merge branch 'develop' into search_logic

This commit is contained in:
Amit Chauhan
2024-11-20 14:31:48 +05:30
committed by GitHub
35 changed files with 2024 additions and 990 deletions

View File

@@ -1,227 +1,323 @@
import React, { useState, useEffect } from 'react';
import React, { useState, useEffect } from "react";
import { GenericModal } from "../atoms/GenericModal";
import { MenuItem, Typography, CircularProgress, Alert, AlertTitle, Chip } from "@mui/material";
import {
MenuItem,
Typography,
CircularProgress,
Alert,
AlertTitle,
Chip,
} from "@mui/material";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import axios from 'axios';
import { useGlobalInfoStore } from '../../context/globalInfo';
import { getStoredRecording } from '../../api/storage';
import { apiUrl } from '../../apiConfig.js';
import axios from "axios";
import { useGlobalInfoStore } from "../../context/globalInfo";
import { getStoredRecording } from "../../api/storage";
import { apiUrl } from "../../apiConfig.js";
import Cookies from 'js-cookie';
interface IntegrationProps {
isOpen: boolean;
handleStart: (data: IntegrationSettings) => void;
handleClose: () => void;
isOpen: boolean;
handleStart: (data: IntegrationSettings) => void;
handleClose: () => void;
}
export interface IntegrationSettings {
spreadsheetId: string;
spreadsheetName: string;
data: string;
spreadsheetId: string;
spreadsheetName: string;
data: string;
}
export const IntegrationSettingsModal = ({ isOpen, handleStart, handleClose }: IntegrationProps) => {
const [settings, setSettings] = useState<IntegrationSettings>({
spreadsheetId: '',
spreadsheetName: '',
data: '',
});
export const IntegrationSettingsModal = ({
isOpen,
handleStart,
handleClose,
}: IntegrationProps) => {
const [settings, setSettings] = useState<IntegrationSettings>({
spreadsheetId: "",
spreadsheetName: "",
data: "",
});
const [spreadsheets, setSpreadsheets] = useState<{ id: string, name: string }[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [spreadsheets, setSpreadsheets] = useState<
{ id: string; name: string }[]
>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { recordingId, notify } = useGlobalInfoStore();
const [recording, setRecording] = useState<any>(null);
const { recordingId, notify } = useGlobalInfoStore();
const [recording, setRecording] = useState<any>(null);
const authenticateWithGoogle = () => {
window.location.href = `${apiUrl}/auth/google?robotId=${recordingId}`;
};
const authenticateWithGoogle = () => {
window.location.href = `${apiUrl}/auth/google?robotId=${recordingId}`;
};
const handleOAuthCallback = async () => {
try {
const response = await axios.get(`${apiUrl}/auth/google/callback`);
const { google_sheet_email, files } = response.data;
} catch (error) {
setError('Error authenticating with Google');
const handleOAuthCallback = async () => {
try {
const response = await axios.get(`${apiUrl}/auth/google/callback`);
const { google_sheet_email, files } = response.data;
} catch (error) {
setError("Error authenticating with Google");
}
};
const fetchSpreadsheetFiles = async () => {
try {
const response = await axios.get(
`${apiUrl}/auth/gsheets/files?robotId=${recordingId}`,
{
withCredentials: true,
}
};
);
setSpreadsheets(response.data);
} catch (error: any) {
console.error(
"Error fetching spreadsheet files:",
error.response?.data?.message || error.message
);
notify(
"error",
`Error fetching spreadsheet files: ${
error.response?.data?.message || error.message
}`
);
}
};
const fetchSpreadsheetFiles = async () => {
try {
const response = await axios.get(`${apiUrl}/auth/gsheets/files?robotId=${recordingId}`, {
withCredentials: true,
});
setSpreadsheets(response.data);
} catch (error: any) {
console.error('Error fetching spreadsheet files:', error.response?.data?.message || error.message);
notify('error', `Error fetching spreadsheet files: ${error.response?.data?.message || error.message}`);
}
};
const handleSpreadsheetSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedSheet = spreadsheets.find(sheet => sheet.id === e.target.value);
if (selectedSheet) {
setSettings({ ...settings, spreadsheetId: selectedSheet.id, spreadsheetName: selectedSheet.name });
}
};
const updateGoogleSheetId = async () => {
try {
const response = await axios.post(
`${apiUrl}/auth/gsheets/update`,
{ spreadsheetId: settings.spreadsheetId, spreadsheetName: settings.spreadsheetName, robotId: recordingId },
{ withCredentials: true }
);
console.log('Google Sheet ID updated:', response.data);
} catch (error: any) {
console.error('Error updating Google Sheet ID:', error.response?.data?.message || error.message);
}
};
const removeIntegration = async () => {
try {
await axios.post(
`${apiUrl}/auth/gsheets/remove`,
{ robotId: recordingId },
{ withCredentials: true }
);
setRecording(null);
setSpreadsheets([]);
setSettings({ spreadsheetId: '', spreadsheetName: '', data: '' });
} catch (error: any) {
console.error('Error removing Google Sheets integration:', error.response?.data?.message || error.message);
}
};
useEffect(() => {
// Check if we're on the callback URL
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get('code');
if (code) {
handleOAuthCallback();
}
const fetchRecordingInfo = async () => {
if (!recordingId) return;
const recording = await getStoredRecording(recordingId);
if (recording) {
setRecording(recording);
}
};
fetchRecordingInfo();
}, [recordingId]);
return (
<GenericModal isOpen={isOpen} onClose={handleClose}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', marginLeft: '65px' }}>
<Typography variant="h6" sx={{ margin: '15px 0px' }}>Integrate with Google Sheet <Chip label="beta" color="primary" variant="outlined" /></Typography>
{recording && recording.google_sheet_id ? (
<>
<Alert severity="info">
<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}`} target="_blank" rel="noreferrer">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.
</Alert>
<Button
variant="outlined"
color="error"
onClick={removeIntegration}
style={{ marginTop: '15px' }}
>
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>
<Button
variant="contained"
color="primary"
onClick={authenticateWithGoogle}
style={{ marginBottom: '15px' }}
>
Authenticate with Google
</Button>
</>
) : (
<>
{recording.google_sheet_email && (
<Typography sx={{ margin: '20px 0px 30px 0px' }}>
Authenticated as: {recording.google_sheet_email}
</Typography>
)}
{loading ? (
<CircularProgress sx={{ marginBottom: '15px' }} />
) : error ? (
<Typography color="error">{error}</Typography>
) : spreadsheets.length === 0 ? (
<>
<div style={{ display: 'flex', gap: '10px' }}>
<Button
variant="outlined"
color="primary"
onClick={fetchSpreadsheetFiles}
>
Fetch Google Spreadsheets
</Button>
<Button
variant="outlined"
color="error"
onClick={removeIntegration}
>
Remove Integration
</Button>
</div>
</>
) : (
<>
<TextField
sx={{ marginBottom: '15px' }}
select
label="Select Google Sheet"
required
value={settings.spreadsheetId}
onChange={handleSpreadsheetSelect}
fullWidth
>
{spreadsheets.map(sheet => (
<MenuItem key={sheet.id} value={sheet.id}>
{sheet.name}
</MenuItem>
))}
</TextField>
{settings.spreadsheetId && (
<Typography sx={{ marginBottom: '10px' }}>
Selected Sheet: {spreadsheets.find(s => s.id === settings.spreadsheetId)?.name} (ID: {settings.spreadsheetId})
</Typography>
)}
<Button
variant="contained"
color="primary"
onClick={() => {
updateGoogleSheetId();
handleStart(settings);
}}
style={{ marginTop: '10px' }}
disabled={!settings.spreadsheetId || loading}
>
Submit
</Button>
</>
)}
</>
)}
</>
)}
</div>
</GenericModal>
const handleSpreadsheetSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const selectedSheet = spreadsheets.find(
(sheet) => sheet.id === e.target.value
);
if (selectedSheet) {
setSettings({
...settings,
spreadsheetId: selectedSheet.id,
spreadsheetName: selectedSheet.name,
});
}
};
const updateGoogleSheetId = async () => {
try {
const response = await axios.post(
`${apiUrl}/auth/gsheets/update`,
{
spreadsheetId: settings.spreadsheetId,
spreadsheetName: settings.spreadsheetName,
robotId: recordingId,
},
{ withCredentials: true }
);
notify(`success`, `Google Sheet selected successfully`)
console.log("Google Sheet ID updated:", response.data);
} catch (error: any) {
console.error(
"Error updating Google Sheet ID:",
error.response?.data?.message || error.message
);
}
};
const removeIntegration = async () => {
try {
await axios.post(
`${apiUrl}/auth/gsheets/remove`,
{ robotId: recordingId },
{ withCredentials: true }
);
setRecording(null);
setSpreadsheets([]);
setSettings({ spreadsheetId: "", spreadsheetName: "", data: "" });
} catch (error: any) {
console.error(
"Error removing Google Sheets integration:",
error.response?.data?.message || error.message
);
}
};
useEffect(() => {
// Check if there is a success message in cookies
const status = Cookies.get("robot_auth_status");
const message = Cookies.get("robot_auth_message");
if (status === "success" && message) {
notify("success", message);
// Clear the cookies after reading
Cookies.remove("robot_auth_status");
Cookies.remove("robot_auth_message");
}
// Check if we're on the callback URL
const urlParams = new URLSearchParams(window.location.search);
const code = urlParams.get("code");
if (code) {
handleOAuthCallback();
}
const fetchRecordingInfo = async () => {
if (!recordingId) return;
const recording = await getStoredRecording(recordingId);
if (recording) {
setRecording(recording);
}
};
fetchRecordingInfo();
}, [recordingId]);
return (
<GenericModal isOpen={isOpen} onClose={handleClose} modalStyle={modalStyle}>
<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" />
</Typography>
{recording && recording.google_sheet_id ? (
<>
<Alert severity="info">
<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}`}
target="_blank"
rel="noreferrer"
>
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.
</Alert>
<Button
variant="outlined"
color="error"
onClick={removeIntegration}
style={{ marginTop: "15px" }}
>
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>
<Button
variant="contained"
color="primary"
onClick={authenticateWithGoogle}
>
Authenticate with Google
</Button>
</>
) : (
<>
{recording.google_sheet_email && (
<Typography sx={{ margin: "20px 0px 30px 0px" }}>
Authenticated as: {recording.google_sheet_email}
</Typography>
)}
{loading ? (
<CircularProgress sx={{ marginBottom: "15px" }} />
) : error ? (
<Typography color="error">{error}</Typography>
) : spreadsheets.length === 0 ? (
<>
<div style={{ display: "flex", gap: "10px" }}>
<Button
variant="outlined"
color="primary"
onClick={fetchSpreadsheetFiles}
>
Fetch Google Spreadsheets
</Button>
<Button
variant="outlined"
color="error"
onClick={removeIntegration}
>
Remove Integration
</Button>
</div>
</>
) : (
<>
<TextField
sx={{ marginBottom: "15px" }}
select
label="Select Google Sheet"
required
value={settings.spreadsheetId}
onChange={handleSpreadsheetSelect}
fullWidth
>
{spreadsheets.map((sheet) => (
<MenuItem key={sheet.id} value={sheet.id}>
{sheet.name}
</MenuItem>
))}
</TextField>
{settings.spreadsheetId && (
<Typography sx={{ marginBottom: "10px" }}>
Selected Sheet:{" "}
{
spreadsheets.find(
(s) => s.id === settings.spreadsheetId
)?.name
}{" "}
(ID: {settings.spreadsheetId})
</Typography>
)}
<Button
variant="contained"
color="primary"
onClick={() => {
updateGoogleSheetId();
handleStart(settings);
}}
style={{ marginTop: "10px" }}
disabled={!settings.spreadsheetId || loading}
>
Submit
</Button>
</>
)}
</>
)}
</>
)}
</div>
</GenericModal>
);
};
export const modalStyle = {
top: "40%",
left: "50%",
transform: "translate(-50%, -50%)",
width: "50%",
backgroundColor: "background.paper",
p: 4,
height: "fit-content",
display: "block",
padding: "20px",
};

View File

@@ -65,21 +65,21 @@ export const NavBar: React.FC<NavBarProps> = ({ recordingName, isRecording }) =>
{!isRecording ? (
<>
<IconButton
component="a"
href="https://discord.gg/NFhWDCdb"
target="_blank"
rel="noopener noreferrer"
sx={{
display: 'flex',
alignItems: 'center',
borderRadius: '5px',
padding: '8px',
marginRight: '10px',
}}
component="a"
href="https://discord.gg/5GbPjBUkws"
target="_blank"
rel="noopener noreferrer"
sx={{
display: 'flex',
alignItems: 'center',
borderRadius: '5px',
padding: '8px',
marginRight: '30px',
}}
>
<DiscordIcon sx={{ marginRight: '5px' }} />
<DiscordIcon sx={{ marginRight: '5px' }} />
</IconButton>
<iframe src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large" frameBorder="0" scrolling="0" width="170" height="30" title="GitHub"></iframe>
<iframe src="https://ghbtns.com/github-btn.html?user=getmaxun&repo=maxun&type=star&count=true&size=large" frameBorder="0" scrolling="0" width="170" height="30" title="GitHub"></iframe>
<IconButton onClick={handleMenuOpen} sx={{
display: 'flex',
alignItems: 'center',

View File

@@ -9,24 +9,32 @@ import TablePagination from '@mui/material/TablePagination';
import TableRow from '@mui/material/TableRow';
import { useEffect } from "react";
import { WorkflowFile } from "maxun-core";
import { IconButton, Button, Box, Typography, TextField } from "@mui/material";
import { Schedule, DeleteForever, Edit, PlayCircle, Settings, Power } from "@mui/icons-material";
import SearchIcon from '@mui/icons-material/Search';
import { IconButton, Button, Box, Typography, TextField, MenuItem, Menu, ListItemIcon, ListItemText } from "@mui/material";
import { Schedule, DeleteForever, Edit, PlayCircle, Settings, Power, ContentCopy, } from "@mui/icons-material";
import LinkIcon from '@mui/icons-material/Link';
import { useGlobalInfoStore } from "../../context/globalInfo";
import { deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage";
import { checkRunsForRecording, deleteRecordingFromStorage, getStoredRecordings } from "../../api/storage";
import { Add } from "@mui/icons-material";
import { useNavigate } from 'react-router-dom';
import { stopRecording } from "../../api/recording";
import { GenericModal } from '../atoms/GenericModal';
import axios from 'axios';
import { apiUrl } from '../../apiConfig';
import { Menu as MenuIcon } from '@mui/icons-material';
/** TODO:
* 1. allow editing existing robot after persisting browser steps
* 2. show robot settings: id, url, etc.
*/
interface Column {
id: 'interpret' | 'name' | 'delete' | 'schedule' | 'integrate' | 'settings';
id: 'interpret' | 'name' | 'options' | 'schedule' | 'integrate' | 'settings';
label: string;
minWidth?: number;
align?: 'right';
@@ -52,8 +60,8 @@ const columns: readonly Column[] = [
minWidth: 80,
},
{
id: 'delete',
label: 'Delete',
id: 'options',
label: 'Options',
minWidth: 80,
},
];
@@ -73,17 +81,17 @@ interface RecordingsTableProps {
handleScheduleRecording: (id: string, fileName: string, params: string[]) => void;
handleIntegrateRecording: (id: string, fileName: string, params: string[]) => void;
handleSettingsRecording: (id: string, fileName: string, params: string[]) => void;
handleEditRobot: (id: string, name: string, params: string[]) => void;
handleDuplicateRobot: (id: string, name: string, params: string[]) => void;
}
export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording }: RecordingsTableProps) => {
export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot, handleDuplicateRobot }: RecordingsTableProps) => {
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('');
console.log('rows', rows);
const { notify, setRecordings, browserId, setBrowserId, recordingUrl, setRecordingUrl, recordingName, setRecordingName, recordingId, setRecordingId } = useGlobalInfoStore();
const navigate = useNavigate();
@@ -147,11 +155,15 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
}
}, []);
// Filter rows based on search term
const filteredRows = rows.filter((row) =>
row.name.toLowerCase().includes(searchTerm.toLowerCase())
);
return (
<React.Fragment>
<Box display="flex" justifyContent="space-between" alignItems="center">
@@ -242,22 +254,33 @@ export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handl
<IntegrateButton handleIntegrate={() => handleIntegrateRecording(row.id, row.name, row.params || [])} />
</TableCell>
);
case 'delete':
return (
<TableCell key={column.id} align={column.align}>
<IconButton aria-label="add" size="small" onClick={() => {
deleteRecordingFromStorage(row.id).then((result: boolean) => {
if (result) {
setRows([]);
notify('success', 'Recording deleted successfully');
fetchRecordings();
}
})
}}>
<DeleteForever />
</IconButton>
</TableCell>
);
case 'options':
return (
<TableCell key={column.id} align={column.align}>
<OptionsButton
handleEdit={() => handleEditRobot(row.id, row.name, row.params || [])}
handleDelete={() => {
checkRunsForRecording(row.id).then((result: boolean) => {
if (result) {
notify('warning', 'Cannot delete recording as it has active runs');
}
})
deleteRecordingFromStorage(row.id).then((result: boolean) => {
if (result) {
setRows([]);
notify('success', 'Recording deleted successfully');
fetchRecordings();
}
})
}}
handleDuplicate={() => {
handleDuplicateRobot(row.id, row.name, row.params || []);
}}
/>
</TableCell>
);
case 'settings':
return (
<TableCell key={column.id} align={column.align}>
@@ -370,6 +393,60 @@ const SettingsButton = ({ handleSettings }: SettingsButtonProps) => {
)
}
interface OptionsButtonProps {
handleEdit: () => void;
handleDelete: () => void;
handleDuplicate: () => void;
}
const OptionsButton = ({ handleEdit, handleDelete, handleDuplicate }: OptionsButtonProps) => {
const [anchorEl, setAnchorEl] = React.useState<null | HTMLElement>(null);
const handleClick = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleClose = () => {
setAnchorEl(null);
};
return (
<>
<IconButton
aria-label="options"
size="small"
onClick={handleClick}
>
<MenuIcon />
</IconButton>
<Menu
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleClose}
>
<MenuItem onClick={() => { handleEdit(); handleClose(); }}>
<ListItemIcon>
<Edit fontSize="small" />
</ListItemIcon>
<ListItemText>Edit</ListItemText>
</MenuItem>
<MenuItem onClick={() => { handleDelete(); handleClose(); }}>
<ListItemIcon>
<DeleteForever fontSize="small" />
</ListItemIcon>
<ListItemText>Delete</ListItemText>
</MenuItem>
<MenuItem onClick={() => { handleDuplicate(); handleClose(); }}>
<ListItemIcon>
<ContentCopy fontSize="small" />
</ListItemIcon>
<ListItemText>Duplicate</ListItemText>
</MenuItem>
</Menu>
</>
);
};
const modalStyle = {
top: '50%',
left: '50%',

View File

@@ -0,0 +1,164 @@
import React, { useState, useEffect } from 'react';
import { GenericModal } from "../atoms/GenericModal";
import { TextField, Typography, Box, Button, Chip } from "@mui/material";
import { modalStyle } from "./AddWhereCondModal";
import { useGlobalInfoStore } from '../../context/globalInfo';
import { duplicateRecording, getStoredRecording } from '../../api/storage';
import { WhereWhatPair } from 'maxun-core';
import { getUserById } from "../../api/auth";
interface RobotMeta {
name: string;
id: string;
createdAt: string;
pairs: number;
updatedAt: string;
params: any[];
}
interface RobotWorkflow {
workflow: WhereWhatPair[];
}
interface ScheduleConfig {
runEvery: number;
runEveryUnit: 'MINUTES' | 'HOURS' | 'DAYS' | 'WEEKS' | 'MONTHS';
startFrom: 'SUNDAY' | 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY';
atTimeStart?: string;
atTimeEnd?: string;
timezone: string;
lastRunAt?: Date;
nextRunAt?: Date;
cronExpression?: string;
}
export interface RobotSettings {
id: string;
userId?: number;
recording_meta: RobotMeta;
recording: RobotWorkflow;
google_sheet_email?: string | null;
google_sheet_name?: string | null;
google_sheet_id?: string | null;
google_access_token?: string | null;
google_refresh_token?: string | null;
schedule?: ScheduleConfig | null;
}
interface RobotSettingsProps {
isOpen: boolean;
handleStart: (settings: RobotSettings) => void;
handleClose: () => void;
initialSettings?: RobotSettings | null;
}
export const RobotDuplicationModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
const [robot, setRobot] = useState<RobotSettings | null>(null);
const [targetUrl, setTargetUrl] = useState<string | undefined>('');
const { recordingId, notify } = useGlobalInfoStore();
useEffect(() => {
if (isOpen) {
getRobot();
}
}, [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];
setTargetUrl(url);
}
}, [robot]);
const getRobot = async () => {
if (recordingId) {
const robot = await getStoredRecording(recordingId);
setRobot(robot);
} else {
notify('error', 'Could not find robot details. Please try again.');
}
}
// 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.');
return;
}
console.log("handle save");
try {
const success = await duplicateRecording(robot.recording_meta.id, targetUrl);
if (success) {
notify('success', 'Target URL updated successfully.');
handleStart(robot); // Inform parent about the updated robot
handleClose(); // Close the modal
window.location.reload();
} else {
notify('error', 'Failed to update the Target URL. Please try again.');
}
} catch (error) {
notify('error', 'An error occurred while updating the Target URL.');
console.error('Error updating Target URL:', error);
}
};
return (
<GenericModal
isOpen={isOpen}
onClose={handleClose}
modalStyle={modalStyle}
>
<>
<Typography variant="h5" style={{ marginBottom: '20px' }}>Duplicate Robot</Typography>
<Box style={{ display: 'flex', flexDirection: 'column' }}>
{
robot && (
<>
<span>Robot duplication is useful to extract data from pages with the same structure.</span>
<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>
</span>
<TextField
label="Robot Target URL"
key="Robot 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
</Button>
<Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}>
Cancel
</Button>
</Box>
</>
)
}
</Box>
</>
</GenericModal>
);
};

View File

@@ -0,0 +1,179 @@
import React, { useState, useEffect } from 'react';
import { GenericModal } from "../atoms/GenericModal";
import { TextField, Typography, Box, Button } from "@mui/material";
import { modalStyle } from "./AddWhereCondModal";
import { useGlobalInfoStore } from '../../context/globalInfo';
import { getStoredRecording, updateRecording } from '../../api/storage';
import { WhereWhatPair } from 'maxun-core';
interface RobotMeta {
name: string;
id: string;
createdAt: string;
pairs: number;
updatedAt: string;
params: any[];
}
interface RobotWorkflow {
workflow: WhereWhatPair[];
}
interface RobotEditOptions {
name: string;
limit?: number;
}
interface ScheduleConfig {
runEvery: number;
runEveryUnit: 'MINUTES' | 'HOURS' | 'DAYS' | 'WEEKS' | 'MONTHS';
startFrom: 'SUNDAY' | 'MONDAY' | 'TUESDAY' | 'WEDNESDAY' | 'THURSDAY' | 'FRIDAY' | 'SATURDAY';
atTimeStart?: string;
atTimeEnd?: string;
timezone: string;
lastRunAt?: Date;
nextRunAt?: Date;
cronExpression?: string;
}
export interface RobotSettings {
id: string;
userId?: number;
recording_meta: RobotMeta;
recording: RobotWorkflow;
google_sheet_email?: string | null;
google_sheet_name?: string | null;
google_sheet_id?: string | null;
google_access_token?: string | null;
google_refresh_token?: string | null;
schedule?: ScheduleConfig | null;
}
interface RobotSettingsProps {
isOpen: boolean;
handleStart: (settings: RobotSettings) => void;
handleClose: () => void;
initialSettings?: RobotSettings | null;
}
export const RobotEditModal = ({ isOpen, handleStart, handleClose, initialSettings }: RobotSettingsProps) => {
console.log("robot edit");
const [robot, setRobot] = useState<RobotSettings | null>(null);
const { recordingId, notify } = useGlobalInfoStore();
useEffect(() => {
if (isOpen) {
getRobot();
}
}, [isOpen]);
const getRobot = async () => {
if (recordingId) {
const robot = await getStoredRecording(recordingId);
setRobot(robot);
} else {
notify('error', 'Could not find robot details. Please try again.');
}
}
const handleRobotNameChange = (newName: string) => {
setRobot((prev) =>
prev ? { ...prev, recording_meta: { ...prev.recording_meta, name: newName } } : prev
);
};
const handleLimitChange = (newLimit: number) => {
setRobot((prev) => {
if (!prev) return prev;
const updatedWorkflow = [...prev.recording.workflow];
if (
updatedWorkflow.length > 0 &&
updatedWorkflow[0]?.what &&
updatedWorkflow[0].what.length > 0 &&
updatedWorkflow[0].what[0].args &&
updatedWorkflow[0].what[0].args.length > 0 &&
updatedWorkflow[0].what[0].args[0]
) {
updatedWorkflow[0].what[0].args[0].limit = newLimit;
}
return { ...prev, recording: { ...prev.recording, workflow: updatedWorkflow } };
});
};
const handleSave = async () => {
if (!robot) return;
try {
const payload = {
name: robot.recording_meta.name,
limit: robot.recording.workflow[0]?.what[0]?.args?.[0]?.limit,
};
const success = await updateRecording(robot.recording_meta.id, payload);
if (success) {
notify('success', 'Robot updated successfully.');
handleStart(robot); // Inform parent about the updated robot
handleClose(); // Close the modal
window.location.reload();
} else {
notify('error', 'Failed to update the robot. Please try again.');
}
} catch (error) {
notify('error', 'An error occurred while updating the robot.');
console.error('Error updating robot:', error);
}
};
return (
<GenericModal
isOpen={isOpen}
onClose={handleClose}
modalStyle={modalStyle}
>
<>
<Typography variant="h5" style={{ marginBottom: '20px' }}>Edit Robot</Typography>
<Box style={{ display: 'flex', flexDirection: 'column' }}>
{
robot && (
<>
<TextField
label="Change Robot Name"
key="Change Robot Name"
type='text'
value={robot.recording_meta.name}
onChange={(e) => handleRobotNameChange(e.target.value)}
style={{ marginBottom: '20px' }}
/>
{robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== undefined && (
<TextField
label="Robot Limit"
type="number"
value={robot.recording.workflow[0].what[0].args[0].limit || ''}
onChange={(e) =>
handleLimitChange(parseInt(e.target.value, 10) || 0)
}
style={{ marginBottom: '20px' }}
/>
)}
<Box mt={2} display="flex" justifyContent="flex-end" onClick={handleSave}>
<Button variant="contained" color="primary">
Save Changes
</Button>
<Button onClick={handleClose} color="primary" variant="outlined" style={{ marginLeft: '10px' }}>
Cancel
</Button>
</Box>
</>
)
}
</Box>
</>
</GenericModal>
);
};

View File

@@ -104,6 +104,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
<>
<TextField
label="Robot Target URL"
key="Robot Target URL"
value={targetUrl}
InputProps={{
readOnly: true,
@@ -112,6 +113,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
/>
<TextField
label="Robot ID"
key="Robot ID"
value={robot.recording_meta.id}
InputProps={{
readOnly: true,
@@ -120,7 +122,8 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
/>
<TextField
label="Created By User"
value={userEmail}
key="Created By User"
value={userEmail ? userEmail : ''}
InputProps={{
readOnly: true,
}}
@@ -128,6 +131,7 @@ export const RobotSettingsModal = ({ isOpen, handleStart, handleClose, initialSe
/>
<TextField
label="Robot Created At"
key="Robot Created At"
value={robot.recording_meta.createdAt}
InputProps={{
readOnly: true,

View File

@@ -1,14 +1,11 @@
import { Box, Tabs, Typography, Tab, Paper } from "@mui/material";
import { Box, Tabs, Typography, Tab, Paper, Button } from "@mui/material";
import Highlight from "react-highlight";
import Button from "@mui/material/Button";
import * as React from "react";
import { Data } from "./RunsTable";
import { TabPanel, TabContext } from "@mui/lab";
import SettingsIcon from '@mui/icons-material/Settings';
import ImageIcon from '@mui/icons-material/Image';
import ArticleIcon from '@mui/icons-material/Article';
import ImageIcon from '@mui/icons-material/Image';
import { useEffect, useState } from "react";
import AssignmentIcon from '@mui/icons-material/Assignment';
import Table from '@mui/material/Table';
import TableBody from '@mui/material/TableBody';
import TableCell from '@mui/material/TableCell';
@@ -32,7 +29,7 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
useEffect(() => {
setTab(tab);
}, [interpretationInProgress])
}, [interpretationInProgress]);
useEffect(() => {
if (row.serializableOutput && Object.keys(row.serializableOutput).length > 0) {
@@ -47,6 +44,29 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
}
}, [row.serializableOutput]);
// Function to convert table data to CSV format
const convertToCSV = (data: any[], columns: string[]): string => {
const header = columns.join(',');
const rows = data.map(row =>
columns.map(col => JSON.stringify(row[col], null, 2)).join(',')
);
return [header, ...rows].join('\n');
};
// Function to download CSV file when called
const downloadCSV = () => {
const csvContent = convertToCSV(tableData, columns);
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.setAttribute("download", "data.csv");
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
};
return (
<Box sx={{ width: '100%' }}>
<TabContext value={tab}>
@@ -54,7 +74,6 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
<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="Input" value='input' /> */}
</Tabs>
</Box>
<TabPanel value='log'>
@@ -94,16 +113,19 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
<ArticleIcon sx={{ marginRight: '15px' }} />
Captured Data
</Typography>
{Object.keys(row.serializableOutput).map((key) => {
return (
<div key={`number-of-serializable-output-${key}`}>
<Typography sx={{ margin: '20px 0px 20px 0px' }}>
<a style={{ textDecoration: 'none' }} href={`data:application/json;utf8,${JSON.stringify(row.serializableOutput[key], null, 2)}`}
download={key}>Download as JSON</a>
</Typography>
</div>
)
})}
<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
</a>
</Typography>
<Typography
onClick={downloadCSV}
>
<a style={{ textDecoration: 'none', cursor: 'pointer' }}>Download as CSV</a>
</Typography>
</Box>
{tableData.length > 0 ? (
<TableContainer component={Paper} sx={{ maxHeight: 440, marginTop: 2 }}>
<Table stickyHeader aria-label="sticky table">
@@ -139,12 +161,12 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
)}
</div>
}
{row.binaryOutput
&& Object.keys(row.binaryOutput).length !== 0 &&
{row.binaryOutput && Object.keys(row.binaryOutput).length !== 0 &&
<div>
<Typography variant='h6' sx={{ display: 'flex', alignItems: 'center' }}>
<ImageIcon sx={{ marginRight: '15px' }} />
Captured Screenshot</Typography>
Captured Screenshot
</Typography>
{Object.keys(row.binaryOutput).map((key) => {
try {
const imageUrl = row.binaryOutput[key];
@@ -152,10 +174,10 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
<Box key={`number-of-binary-output-${key}`} sx={{
width: 'max-content',
}}>
<Typography key={`binary-output-key-${key}`} sx={{ margin: '20px 0px 20px 0px' }}>
<Typography sx={{ margin: '20px 0px' }}>
<a href={imageUrl} download={key} style={{ textDecoration: 'none' }}>Download Screenshot</a>
</Typography>
<img key={`image-${key}`} src={imageUrl} alt={key} height='auto' width='700px' />
<img src={imageUrl} alt={key} height='auto' width='700px' />
</Box>
)
} catch (e) {
@@ -171,4 +193,4 @@ export const RunContent = ({ row, currentLog, interpretationInProgress, logEndRe
</TabContext>
</Box>
);
}
};

View File

@@ -63,8 +63,10 @@ export const RunsTable = (
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) => {
@@ -110,6 +112,7 @@ export const RunsTable = (
fetchRuns();
};
// Filter rows based on search term
const filteredRows = rows.filter((row) =>
row.name.toLowerCase().includes(searchTerm.toLowerCase())
@@ -117,6 +120,7 @@ export const RunsTable = (
// Group filtered rows by robot meta id
const groupedRows = filteredRows.reduce((acc, row) => {
if (!acc[row.robotMetaId]) {
acc[row.robotMetaId] = [];
}
@@ -145,7 +149,9 @@ 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">