Merge pull request #179 from getmaxun/edit-robot

feat: edit robot
This commit is contained in:
Karishma Shukla
2024-11-18 06:40:44 +05:30
committed by GitHub
6 changed files with 414 additions and 43 deletions

View File

@@ -57,6 +57,95 @@ router.get('/recordings/:id', requireSignIn, async (req, res) => {
}
})
/**
* PUT endpoint to update the name and limit of a robot.
*/
router.put('/recordings/:id', requireSignIn, async (req: AuthenticatedRequest, res) => {
try {
const { id } = req.params;
const { name, limit } = req.body;
// Validate input
if (!name && limit === undefined) {
return res.status(400).json({ error: 'Either "name" or "limit" must be provided.' });
}
// Fetch the robot by ID
const robot = await Robot.findOne({ where: { 'recording_meta.id': id } });
if (!robot) {
return res.status(404).json({ error: 'Robot not found.' });
}
// Update fields if provided
if (name) {
robot.set('recording_meta', { ...robot.recording_meta, name });
}
// Update the limit
if (limit !== undefined) {
const workflow = [...robot.recording.workflow]; // Create a copy of the workflow
// Ensure the workflow structure is valid before updating
if (
workflow.length > 0 &&
workflow[0]?.what?.[0]
) {
// Create a new workflow object with the updated limit
const updatedWorkflow = workflow.map((step, index) => {
if (index === 0) { // Assuming you want to update the first step
return {
...step,
what: step.what.map((action, actionIndex) => {
if (actionIndex === 0) { // Assuming the first action needs updating
return {
...action,
args: (action.args ?? []).map((arg, argIndex) => {
if (argIndex === 0) { // Assuming the first argument needs updating
return { ...arg, limit };
}
return arg;
}),
};
}
return action;
}),
};
}
return step;
});
// Replace the workflow in the recording object
robot.set('recording', { ...robot.recording, workflow: updatedWorkflow });
} else {
return res.status(400).json({ error: 'Invalid workflow structure for updating limit.' });
}
}
await robot.save();
const updatedRobot = await Robot.findOne({ where: { 'recording_meta.id': id } });
console.log('After save:', updatedRobot);
// Log the update
logger.log('info', `Robot with ID ${id} was updated successfully.`);
return res.status(200).json({ message: 'Robot updated successfully', robot });
} catch (error) {
// Safely handle the error type
if (error instanceof Error) {
logger.log('error', `Error updating robot with ID ${req.params.id}: ${error.message}`);
return res.status(500).json({ error: error.message });
} else {
logger.log('error', `Unknown error updating robot with ID ${req.params.id}`);
return res.status(500).json({ error: 'An unknown error occurred.' });
}
}
});
/**
* DELETE endpoint for deleting a recording from the storage.
*/

View File

@@ -19,6 +19,20 @@ export const getStoredRecordings = async (): Promise<string[] | null> => {
}
};
export const updateRecording = async (id: string, data: { name?: string; limit?: number }): Promise<boolean> => {
try {
const response = await axios.put(`${apiUrl}/storage/recordings/${id}`, data);
if (response.status === 200) {
return true;
} else {
throw new Error(`Couldn't update recording with id ${id}`);
}
} catch (error: any) {
console.error(`Error updating recording: ${error.message}`);
return false;
}
};
export const getStoredRuns = async (): Promise<string[] | null> => {
try {
const response = await axios.get(`${apiUrl}/storage/runs`);

View File

@@ -9,8 +9,8 @@ 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 { 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";
@@ -18,7 +18,7 @@ import { Add } from "@mui/icons-material";
import { useNavigate } from 'react-router-dom';
import { stopRecording } from "../../api/recording";
import { GenericModal } from '../atoms/GenericModal';
import { Menu as MenuIcon } from '@mui/icons-material';
/** TODO:
* 1. allow editing existing robot after persisting browser steps
@@ -26,7 +26,7 @@ import { GenericModal } from '../atoms/GenericModal';
*/
interface Column {
id: 'interpret' | 'name' | 'delete' | 'schedule' | 'integrate' | 'settings';
id: 'interpret' | 'name' | 'options' | 'schedule' | 'integrate' | 'settings';
label: string;
minWidth?: number;
align?: 'right';
@@ -69,8 +69,8 @@ const columns: readonly Column[] = [
minWidth: 80,
},
{
id: 'delete',
label: 'Delete',
id: 'options',
label: 'Options',
minWidth: 80,
},
];
@@ -90,9 +90,10 @@ 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;
}
export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording }: RecordingsTableProps) => {
export const RecordingsTable = ({ handleEditRecording, handleRunRecording, handleScheduleRecording, handleIntegrateRecording, handleSettingsRecording, handleEditRobot }: RecordingsTableProps) => {
const [page, setPage] = React.useState(0);
const [rowsPerPage, setRowsPerPage] = React.useState(10);
const [rows, setRows] = React.useState<Data[]>([]);
@@ -248,22 +249,27 @@ 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={() => {
deleteRecordingFromStorage(row.id).then((result: boolean) => {
if (result) {
setRows([]);
notify('success', 'Recording deleted successfully');
fetchRecordings();
}
})
}}
handleDuplicate={() => {
notify('info', 'Duplicating recording...');
// Implement duplication logic here
}}
/>
</TableCell>
);
case 'settings':
return (
<TableCell key={column.id} align={column.align}>
@@ -377,6 +383,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,178 @@
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) => {
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

@@ -45,6 +45,8 @@ export interface Data {
// task: string;
log: string;
runId: string;
robotId: string;
robotMetaId: string;
interpreterSettings: RunSettings;
serializableOutput: any;
binaryOutput: any;
@@ -63,8 +65,6 @@ export const RunsTable = (
const [rowsPerPage, setRowsPerPage] = useState(10);
const [rows, setRows] = useState<Data[]>([]);
console.log(`rows runs: ${JSON.stringify(rows)}`);
const { notify, rerenderRuns, setRerenderRuns } = useGlobalInfoStore();
const handleChangePage = (event: unknown, newPage: number) => {
@@ -105,12 +105,12 @@ export const RunsTable = (
fetchRuns();
};
// Group runs by recording name
// Group runs by robot meta id
const groupedRows = rows.reduce((acc, row) => {
if (!acc[row.name]) {
acc[row.name] = [];
if (!acc[row.robotMetaId]) {
acc[row.robotMetaId] = [];
}
acc[row.name].push(row);
acc[row.robotMetaId].push(row);
return acc;
}, {} as Record<string, Data[]>);
@@ -120,10 +120,10 @@ export const RunsTable = (
All Runs
</Typography>
<TableContainer component={Paper} sx={{ width: '100%', overflow: 'hidden' }}>
{Object.entries(groupedRows).map(([name, group]) => (
<Accordion key={name}>
{Object.entries(groupedRows).map(([id, data]) => (
<Accordion key={id}>
<AccordionSummary expandIcon={<ExpandMoreIcon />}>
<Typography variant="h6">{name}</Typography>
<Typography variant="h6">{data[data.length - 1].name}</Typography>
</AccordionSummary>
<AccordionDetails>
<Table stickyHeader aria-label="sticky table">
@@ -142,17 +142,19 @@ export const RunsTable = (
</TableRow>
</TableHead>
<TableBody>
{group.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage).map((row) => (
<CollapsibleRow
row={row}
handleDelete={handleDelete}
key={`row-${row.id}`}
isOpen={runId === row.runId && runningRecordingName === row.name}
currentLog={currentInterpretationLog}
abortRunHandler={abortRunHandler}
runningRecordingName={runningRecordingName}
/>
))}
{data
.slice(page * rowsPerPage, page * rowsPerPage + rowsPerPage)
.map((row) => (
<CollapsibleRow
row={row}
handleDelete={handleDelete}
key={`row-${row.id}`}
isOpen={runId === row.runId && runningRecordingName === row.name}
currentLog={currentInterpretationLog}
abortRunHandler={abortRunHandler}
runningRecordingName={runningRecordingName}
/>
))}
</TableBody>
</Table>
</AccordionDetails>

View File

@@ -5,6 +5,7 @@ import { RunSettings, RunSettingsModal } from "../molecules/RunSettings";
import { ScheduleSettings, ScheduleSettingsModal } from "../molecules/ScheduleSettings";
import { IntegrationSettings, IntegrationSettingsModal } from "../molecules/IntegrationSettings";
import { RobotSettings, RobotSettingsModal } from "../molecules/RobotSettings";
import { RobotEditModal } from '../molecules/RobotEdit';
interface RecordingsProps {
handleEditRecording: (id: string, fileName: string) => void;
@@ -18,10 +19,12 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
const [scheduleSettingsAreOpen, setScheduleSettingsAreOpen] = useState(false);
const [integrateSettingsAreOpen, setIntegrateSettingsAreOpen] = useState(false);
const [robotSettingsAreOpen, setRobotSettingsAreOpen] = useState(false);
const [robotEditAreOpen, setRobotEditAreOpen] = useState(false);
const [params, setParams] = useState<string[]>([]);
const [selectedRecordingId, setSelectedRecordingId] = useState<string>('');
const handleIntegrateRecording = (id: string, settings: IntegrationSettings) => {};
const handleSettingsRecording = (id: string, settings: RobotSettings) => {};
const handleEditRobot = (id: string, settings: RobotSettings) => {};
const handleSettingsAndIntegrate = (id: string, name: string, params: string[]) => {
if (params.length === 0) {
@@ -75,6 +78,19 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
}
}
const handleEditRobotOption = (id: string, name: string, params: string[]) => {
if (params.length === 0) {
setRobotEditAreOpen(true);
setRecordingInfo(id, name);
setSelectedRecordingId(id);
} else {
setParams(params);
setRobotEditAreOpen(true);
setRecordingInfo(id, name);
setSelectedRecordingId(id);
}
}
const handleClose = () => {
setParams([]);
setRunSettingsAreOpen(false);
@@ -103,6 +119,13 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
setSelectedRecordingId('');
}
const handleRobotEditClose = () => {
setParams([]);
setRobotEditAreOpen(false);
setRecordingInfo('', '');
setSelectedRecordingId('');
}
return (
<React.Fragment>
<RunSettingsModal isOpen={runSettingsAreOpen}
@@ -123,6 +146,10 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
handleClose={handleRobotSettingsClose}
handleStart={(settings) => handleSettingsRecording(selectedRecordingId, settings)}
/>
<RobotEditModal isOpen={robotEditAreOpen}
handleClose={handleRobotEditClose}
handleStart={(settings) => handleEditRobot(selectedRecordingId,settings)}
/>
<Grid container direction="column" sx={{ padding: '30px' }}>
<Grid item xs>
<RecordingsTable
@@ -131,6 +158,7 @@ export const Recordings = ({ handleEditRecording, handleRunRecording, setRecordi
handleScheduleRecording={handleSettingsAndSchedule}
handleIntegrateRecording={handleSettingsAndIntegrate}
handleSettingsRecording={handleRobotSettings}
handleEditRobot={handleEditRobotOption}
/>
</Grid>
</Grid>