diff --git a/public/locales/de.json b/public/locales/de.json index 19b37c7b..58d90cff 100644 --- a/public/locales/de.json +++ b/public/locales/de.json @@ -442,6 +442,9 @@ "created_at": "Erstellungsdatum des Roboters", "errors": { "robot_not_found": "Roboterdetails konnten nicht gefunden werden. Bitte versuchen Sie es erneut." + }, + "buttons": { + "close": "Schließen" } }, "robot_edit": { diff --git a/public/locales/en.json b/public/locales/en.json index 51aada6b..f900089f 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -442,6 +442,9 @@ "created_at": "Robot Created At", "errors": { "robot_not_found": "Could not find robot details. Please try again." + }, + "buttons": { + "close": "Close" } }, "robot_edit": { diff --git a/public/locales/es.json b/public/locales/es.json index 92bf7fe3..880f3be9 100644 --- a/public/locales/es.json +++ b/public/locales/es.json @@ -460,6 +460,9 @@ "created_at": "Fecha de Creación del Robot", "errors": { "robot_not_found": "No se pudieron encontrar los detalles del robot. Inténtelo de nuevo." + }, + "buttons": { + "close": "Cerrar" } }, "robot_edit": { diff --git a/public/locales/ja.json b/public/locales/ja.json index 421bdecf..05071e8a 100644 --- a/public/locales/ja.json +++ b/public/locales/ja.json @@ -442,6 +442,9 @@ "created_at": "作成日時", "errors": { "robot_not_found": "ロボットの詳細が見つかりませんでした。もう一度試してください。" + }, + "buttons": { + "close": "閉じる" } }, "robot_edit": { diff --git a/public/locales/tr.json b/public/locales/tr.json index b4118ed0..db2f9ffd 100644 --- a/public/locales/tr.json +++ b/public/locales/tr.json @@ -442,6 +442,9 @@ "created_at": "Oluşturulma", "errors": { "robot_not_found": "Robot bulunamadı. Tekrar deneyin." + }, + "buttons": { + "close": "Kapat" } }, "robot_edit": { diff --git a/public/locales/zh.json b/public/locales/zh.json index c1e32760..abd5de9f 100644 --- a/public/locales/zh.json +++ b/public/locales/zh.json @@ -442,6 +442,9 @@ "created_at": "机器人创建时间", "errors": { "robot_not_found": "无法找到机器人详细信息。请重试。" + }, + "buttons": { + "close": "关闭" } }, "robot_edit": { diff --git a/src/components/robot/Recordings.tsx b/src/components/robot/Recordings.tsx index a3db4f7a..6f463ba4 100644 --- a/src/components/robot/Recordings.tsx +++ b/src/components/robot/Recordings.tsx @@ -2,11 +2,14 @@ import React, { useEffect, useState } from "react"; import { RecordingsTable } from "./RecordingsTable"; import { Grid } from "@mui/material"; import { RunSettings, RunSettingsModal } from "../run/RunSettings"; -import { ScheduleSettings, ScheduleSettingsModal } from "./ScheduleSettings"; -import { IntegrationSettingsModal } from "../integration/IntegrationSettings"; -import { RobotSettingsModal } from "./RobotSettings"; -import { RobotEditModal } from "./RobotEdit"; -import { RobotDuplicationModal } from "./RobotDuplicate"; +import { + ScheduleSettings, + ScheduleSettingsPage, +} from "./pages/ScheduleSettingsPage"; +import { RobotIntegrationPage } from "./pages/RobotIntegrationPage"; +import { RobotSettingsPage } from "./pages/RobotSettingsPage"; +import { RobotEditPage } from "./pages/RobotEditPage"; +import { RobotDuplicatePage } from "./pages/RobotDuplicatePage"; import { useNavigate, useLocation, useParams } from "react-router-dom"; import { useGlobalInfoStore } from "../../context/globalInfo"; import { useTranslation } from "react-i18next"; @@ -26,12 +29,16 @@ export const Recordings = ({ }: RecordingsProps) => { const navigate = useNavigate(); const location = useLocation(); - const { selectedRecordingId } = useParams(); const [params, setParams] = useState([]); const { notify } = useGlobalInfoStore(); const { t } = useTranslation(); - const handleNavigate = (path: string, id: string, name: string, params: string[]) => { + const handleNavigate = ( + path: string, + id: string, + name: string, + params: string[] + ) => { setParams(params); setRecordingInfo(id, name); navigate(path); @@ -47,35 +54,36 @@ export const Recordings = ({ // Helper function to get and clear a cookie const getAndClearCookie = (name: string) => { const value = document.cookie - .split('; ') - .find(row => row.startsWith(`${name}=`)) - ?.split('=')[1]; - + .split("; ") + .find((row) => row.startsWith(`${name}=`)) + ?.split("=")[1]; + if (value) { document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/`; } - + return value; }; - const authStatus = getAndClearCookie('robot_auth_status'); - const airtableAuthStatus = getAndClearCookie('airtable_auth_status'); - const robotId = getAndClearCookie('robot_auth_robotId'); + const authStatus = getAndClearCookie("robot_auth_status"); + const airtableAuthStatus = getAndClearCookie("airtable_auth_status"); + const robotId = getAndClearCookie("robot_auth_robotId"); - if (airtableAuthStatus === 'success' && robotId) { + if (airtableAuthStatus === "success" && robotId) { console.log("Airtable Auth Status:", airtableAuthStatus); - notify(airtableAuthStatus, t("recordingtable.notifications.auth_success")); + notify( + airtableAuthStatus, + t("recordingtable.notifications.auth_success") + ); handleNavigate(`/robots/${robotId}/integrate/airtable`, robotId, "", []); - } - else if (authStatus === 'success' && robotId) { + } else if (authStatus === "success" && robotId) { console.log("Google Auth Status:", authStatus); notify(authStatus, t("recordingtable.notifications.auth_success")); handleNavigate(`/robots/${robotId}/integrate/google`, robotId, "", []); } }, []); - // Determine which modal to open based on the current route - const getCurrentModal = () => { + const getCurrentPageComponent = () => { const currentPath = location.pathname; if (currentPath.endsWith("/run")) { @@ -89,79 +97,36 @@ export const Recordings = ({ /> ); } else if (currentPath.endsWith("/schedule")) { + return ; + } else if (currentPath.includes("/integrate")) { return ( - - ); - } else if (currentPath.endsWith("/integrate/google")) { - return ( - {}} - preSelectedIntegrationType="googleSheets" - /> - ); - } else if (currentPath.endsWith("/integrate/airtable")) { - return ( - {}} - preSelectedIntegrationType="airtable" - /> - ); - } else if (currentPath.endsWith("/integrate/webhook")) { - return ( - {}} - preSelectedIntegrationType="webhook" - /> - ); - } else if (currentPath.endsWith("/integrate")) { - return ( - {}} - /> + {}} robotPath={"robots"} /> ); } else if (currentPath.endsWith("/settings")) { - return ( - {}} - /> - ); + return {}} />; } else if (currentPath.endsWith("/edit")) { - return ( - {}} - /> - ); + return {}} />; } else if (currentPath.endsWith("/duplicate")) { - return ( - {}} - /> - ); + return {}} />; } return null; }; + const currentPath = location.pathname; + const isConfigPage = + currentPath.includes("/schedule") || + currentPath.includes("/integrate") || + currentPath.includes("/settings") || + currentPath.includes("/edit") || + currentPath.includes("/duplicate") || + currentPath.includes("/run"); + + if (isConfigPage) { + return getCurrentPageComponent(); + } + return ( - {getCurrentModal()} void; + onCancel?: () => void; + saveButtonText?: string; + cancelButtonText?: string; + showSaveButton?: boolean; + showCancelButton?: boolean; + isLoading?: boolean; + icon?: React.ReactNode; + onBackToSelection?: () => void; + backToSelectionText?: string; +} + +export const RobotConfigPage: React.FC = ({ + title, + children, + onSave, + onCancel, + saveButtonText = "Save", + cancelButtonText = "Cancel", + showSaveButton = true, + showCancelButton = true, + isLoading = false, + icon, + onBackToSelection, + backToSelectionText = "← Back" +}) => { + const theme = useTheme(); + + const handleBack = () => { + if (onCancel) { + onCancel(); + } + }; + + return ( + + + + + + {icon && ( + + {icon} + + )} + + {title} + + + + + + {children} + + + {(showSaveButton || showCancelButton || onBackToSelection) && ( + + {onBackToSelection && ( + + )} + + + {showCancelButton && ( + + )} + {showSaveButton && onSave && ( + + )} + + + )} + + ); +}; \ No newline at end of file diff --git a/src/components/robot/pages/RobotDuplicatePage.tsx b/src/components/robot/pages/RobotDuplicatePage.tsx new file mode 100644 index 00000000..e88be19d --- /dev/null +++ b/src/components/robot/pages/RobotDuplicatePage.tsx @@ -0,0 +1,191 @@ +import React, { useState, useEffect } from "react"; +import { + TextField, + Typography, + Box, + Button, +} from "@mui/material"; +import { useGlobalInfoStore } from "../../../context/globalInfo"; +import { + duplicateRecording, + getStoredRecording, + getStoredRecordings, +} from "../../../api/storage"; +import { WhereWhatPair } from "maxun-core"; +import { useTranslation } from "react-i18next"; +import { RobotConfigPage } from "./RobotConfigPage"; +import { useNavigate, useLocation } from "react-router-dom"; + +interface RobotMeta { + name: string; + id: string; + createdAt: string; + pairs: number; + updatedAt: string; + params: any[]; + url?: string; +} + +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 { + handleStart: (settings: RobotSettings) => void; +} + +export const RobotDuplicatePage = ({ handleStart }: RobotSettingsProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const [targetUrl, setTargetUrl] = useState(""); + const [robot, setRobot] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const { recordingId, notify, setRerenderRobots } = + useGlobalInfoStore(); + + useEffect(() => { + getRobot(); + }, []); + + useEffect(() => { + if (robot) { + let url = robot.recording_meta.url; + + if (!url) { + const lastPair = + robot?.recording.workflow[robot?.recording.workflow.length - 1]; + url = lastPair?.what.find((action) => action.action === "goto") + ?.args?.[0]; + } + + setTargetUrl(url); + } + }, [robot]); + + const getRobot = async () => { + if (recordingId) { + try { + const robot = await getStoredRecording(recordingId); + setRobot(robot); + } catch (error) { + notify("error", t("robot_duplication.notifications.robot_not_found")); + } + } else { + notify("error", t("robot_duplication.notifications.robot_not_found")); + } + }; + + const handleTargetUrlChange = (e: React.ChangeEvent) => { + setTargetUrl(e.target.value); + }; + + const handleSave = async () => { + if (!robot || !targetUrl) { + notify("error", t("robot_duplication.notifications.url_required")); + return; + } + + setIsLoading(true); + try { + const success = await duplicateRecording( + robot.recording_meta.id, + targetUrl + ); + + if (success) { + setRerenderRobots(true); + notify( + "success", + t("robot_duplication.notifications.duplicate_success") + ); + handleStart(robot); + navigate("/robots"); + } else { + notify("error", t("robot_duplication.notifications.duplicate_error")); + } + } catch (error) { + notify("error", t("robot_duplication.notifications.unknown_error")); + console.error("Error updating Target URL:", error); + } finally { + setIsLoading(false); + } + }; + + const handleCancel = () => { + navigate("/robots"); + }; + + return ( + + <> + + {robot && ( + <> + {t("robot_duplication.descriptions.purpose")} +
+ producthunt.com/topics/api", + url2: "producthunt.com/topics/database", + }), + }} + /> +
+ + {t("robot_duplication.descriptions.warning")} + + + + )} +
+ +
+ ); +}; diff --git a/src/components/robot/pages/RobotEditPage.tsx b/src/components/robot/pages/RobotEditPage.tsx new file mode 100644 index 00000000..deaf3d76 --- /dev/null +++ b/src/components/robot/pages/RobotEditPage.tsx @@ -0,0 +1,643 @@ +import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { + TextField, + Typography, + Box, + Button, + IconButton, + InputAdornment, +} from "@mui/material"; +import { Visibility, VisibilityOff } from "@mui/icons-material"; +import { useGlobalInfoStore } from "../../../context/globalInfo"; +import { getStoredRecording, updateRecording } from "../../../api/storage"; +import { WhereWhatPair } from "maxun-core"; +import { RobotConfigPage } from "./RobotConfigPage"; +import { useNavigate, useLocation } from "react-router-dom"; + +interface RobotMeta { + name: string; + id: string; + createdAt: string; + pairs: number; + updatedAt: string; + params: any[]; + url?: string; +} + +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 { + handleStart: (settings: RobotSettings) => void; +} + +interface CredentialInfo { + value: string; + type: string; +} + +interface Credentials { + [key: string]: CredentialInfo; +} + +interface CredentialVisibility { + [key: string]: boolean; +} + +interface GroupedCredentials { + passwords: string[]; + emails: string[]; + usernames: string[]; + others: string[]; +} + +interface ScrapeListLimit { + pairIndex: number; + actionIndex: number; + argIndex: number; + currentLimit: number; +} + +export const RobotEditPage = ({ handleStart }: RobotSettingsProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const [credentials, setCredentials] = useState({}); + const { recordingId, notify, setRerenderRobots } = useGlobalInfoStore(); + const [robot, setRobot] = useState(null); + const [credentialGroups, setCredentialGroups] = useState({ + passwords: [], + emails: [], + usernames: [], + others: [], + }); + const [showPasswords, setShowPasswords] = useState({}); + const [scrapeListLimits, setScrapeListLimits] = useState( + [] + ); + const [isLoading, setIsLoading] = useState(false); + + const isEmailPattern = (value: string): boolean => { + return value.includes("@"); + }; + + const isUsernameSelector = (selector: string): boolean => { + return ( + selector.toLowerCase().includes("username") || + selector.toLowerCase().includes("user") || + selector.toLowerCase().includes("email") + ); + }; + + const determineCredentialType = ( + selector: string, + info: CredentialInfo + ): "password" | "email" | "username" | "other" => { + if ( + info.type === "password" || + selector.toLowerCase().includes("password") + ) { + return "password"; + } + if ( + isEmailPattern(info.value) || + selector.toLowerCase().includes("email") + ) { + return "email"; + } + if (isUsernameSelector(selector)) { + return "username"; + } + return "other"; + }; + + useEffect(() => { + getRobot(); + }, []); + + useEffect(() => { + if (robot?.recording?.workflow) { + const extractedCredentials = extractInitialCredentials( + robot.recording.workflow + ); + setCredentials(extractedCredentials); + setCredentialGroups(groupCredentialsByType(extractedCredentials)); + + findScrapeListLimits(robot.recording.workflow); + } + }, [robot]); + + const findScrapeListLimits = (workflow: WhereWhatPair[]) => { + const limits: ScrapeListLimit[] = []; + + workflow.forEach((pair, pairIndex) => { + if (!pair.what) return; + + pair.what.forEach((action, actionIndex) => { + if ( + action.action === "scrapeList" && + action.args && + action.args.length > 0 + ) { + const arg = action.args[0]; + if (arg && typeof arg === "object" && "limit" in arg) { + limits.push({ + pairIndex, + actionIndex, + argIndex: 0, + currentLimit: arg.limit, + }); + } + } + }); + }); + + setScrapeListLimits(limits); + }; + + function extractInitialCredentials(workflow: any[]): Credentials { + const credentials: Credentials = {}; + + const isPrintableCharacter = (char: string): boolean => { + return char.length === 1 && !!char.match(/^[\x20-\x7E]$/); + }; + + workflow.forEach((step) => { + if (!step.what) return; + + let currentSelector = ""; + let currentValue = ""; + let currentType = ""; + let i = 0; + + while (i < step.what.length) { + const action = step.what[i]; + + if (!action.action || !action.args?.[0]) { + i++; + continue; + } + + const selector = action.args[0]; + + if ( + action.action === "type" && + action.args?.length >= 2 && + typeof action.args[1] === "string" && + action.args[1].length > 1 + ) { + if (!credentials[selector]) { + credentials[selector] = { + value: action.args[1], + type: action.args[2] || "text", + }; + } + i++; + continue; + } + + if ( + (action.action === "type" || action.action === "press") && + action.args?.length >= 2 && + typeof action.args[1] === "string" + ) { + if (selector !== currentSelector) { + if (currentSelector && currentValue) { + credentials[currentSelector] = { + value: currentValue, + type: currentType || "text", + }; + } + currentSelector = selector; + currentValue = credentials[selector]?.value || ""; + currentType = + action.args[2] || credentials[selector]?.type || "text"; + } + + const character = action.args[1]; + + if (isPrintableCharacter(character)) { + currentValue += character; + } else if (character === "Backspace") { + currentValue = currentValue.slice(0, -1); + } + + if (!currentType && action.args[2]?.toLowerCase() === "password") { + currentType = "password"; + } + + let j = i + 1; + while (j < step.what.length) { + const nextAction = step.what[j]; + if ( + !nextAction.action || + !nextAction.args?.[0] || + nextAction.args[0] !== selector || + (nextAction.action !== "type" && nextAction.action !== "press") + ) { + break; + } + if (nextAction.args[1] === "Backspace") { + currentValue = currentValue.slice(0, -1); + } else if (isPrintableCharacter(nextAction.args[1])) { + currentValue += nextAction.args[1]; + } + j++; + } + + credentials[currentSelector] = { + value: currentValue, + type: currentType, + }; + + i = j; + } else { + i++; + } + } + + if (currentSelector && currentValue) { + credentials[currentSelector] = { + value: currentValue, + type: currentType || "text", + }; + } + }); + + return credentials; + } + + const groupCredentialsByType = ( + credentials: Credentials + ): GroupedCredentials => { + return Object.entries(credentials).reduce( + (acc: GroupedCredentials, [selector, info]) => { + const credentialType = determineCredentialType(selector, info); + + switch (credentialType) { + case "password": + acc.passwords.push(selector); + break; + case "email": + acc.emails.push(selector); + break; + case "username": + acc.usernames.push(selector); + break; + default: + acc.others.push(selector); + } + + return acc; + }, + { passwords: [], emails: [], usernames: [], others: [] } + ); + }; + + const getRobot = async () => { + if (recordingId) { + try { + const robot = await getStoredRecording(recordingId); + setRobot(robot); + } catch (error) { + notify("error", t("robot_edit.notifications.update_failed")); + } + } else { + notify("error", t("robot_edit.notifications.update_failed")); + } + }; + + const handleClickShowPassword = (selector: string) => { + setShowPasswords((prev) => ({ + ...prev, + [selector]: !prev[selector], + })); + }; + + const handleRobotNameChange = (newName: string) => { + setRobot((prev) => + prev + ? { ...prev, recording_meta: { ...prev.recording_meta, name: newName } } + : prev + ); + }; + + const handleCredentialChange = (selector: string, value: string) => { + setCredentials((prev) => ({ + ...prev, + [selector]: { + ...prev[selector], + value, + }, + })); + }; + + const handleLimitChange = ( + pairIndex: number, + actionIndex: number, + argIndex: number, + newLimit: number + ) => { + setRobot((prev) => { + if (!prev) return prev; + + const updatedWorkflow = [...prev.recording.workflow]; + if ( + updatedWorkflow.length > pairIndex && + updatedWorkflow[pairIndex]?.what && + updatedWorkflow[pairIndex].what.length > actionIndex && + updatedWorkflow[pairIndex].what[actionIndex].args && + updatedWorkflow[pairIndex].what[actionIndex].args.length > argIndex + ) { + updatedWorkflow[pairIndex].what[actionIndex].args[argIndex].limit = + newLimit; + + setScrapeListLimits((prev) => { + return prev.map((item) => { + if ( + item.pairIndex === pairIndex && + item.actionIndex === actionIndex && + item.argIndex === argIndex + ) { + return { ...item, currentLimit: newLimit }; + } + return item; + }); + }); + } + + return { + ...prev, + recording: { ...prev.recording, workflow: updatedWorkflow }, + }; + }); + }; + + const handleTargetUrlChange = (newUrl: string) => { + setRobot((prev) => { + if (!prev) return prev; + + const updatedWorkflow = [...prev.recording.workflow]; + const lastPairIndex = updatedWorkflow.length - 1; + + if (lastPairIndex >= 0) { + const gotoAction = updatedWorkflow[lastPairIndex]?.what?.find( + (action) => action.action === "goto" + ); + if (gotoAction && gotoAction.args && gotoAction.args.length > 0) { + gotoAction.args[0] = newUrl; + } + } + + return { + ...prev, + recording_meta: { ...prev.recording_meta, url: newUrl }, + recording: { ...prev.recording, workflow: updatedWorkflow }, + }; + }); + }; + + const renderAllCredentialFields = () => { + return ( + <> + {renderCredentialFields( + credentialGroups.usernames, + t("Username"), + "text" + )} + + {renderCredentialFields(credentialGroups.emails, t("Email"), "text")} + + {renderCredentialFields( + credentialGroups.passwords, + t("Password"), + "password" + )} + + {renderCredentialFields(credentialGroups.others, t("Other"), "text")} + + ); + }; + + const renderScrapeListLimitFields = () => { + if (scrapeListLimits.length === 0) return null; + + return ( + <> + + {t("List Limits")} + + + {scrapeListLimits.map((limitInfo, index) => ( + { + const value = parseInt(e.target.value, 10); + if (value >= 1) { + handleLimitChange( + limitInfo.pairIndex, + limitInfo.actionIndex, + limitInfo.argIndex, + value + ); + } + }} + inputProps={{ min: 1 }} + style={{ marginBottom: "20px" }} + /> + ))} + + ); + }; + + const renderCredentialFields = ( + selectors: string[], + headerText: string, + defaultType: "text" | "password" = "text" + ) => { + if (selectors.length === 0) return null; + + return ( + <> + {selectors.map((selector, index) => { + const isVisible = showPasswords[selector]; + + return ( + handleCredentialChange(selector, e.target.value)} + fullWidth + style={{ marginBottom: "20px" }} + InputProps={{ + endAdornment: ( + + handleClickShowPassword(selector)} + edge="end" + disabled={!credentials[selector]?.value} + > + {isVisible ? : } + + + ), + }} + /> + ); + })} + + ); + }; + + const getTargetUrl = () => { + let url = robot?.recording_meta.url; + + if (!url) { + const lastPair = + robot?.recording.workflow[robot?.recording.workflow.length - 1]; + url = lastPair?.what.find((action) => action.action === "goto") + ?.args?.[0]; + } + + return url; + }; + + const handleSave = async () => { + if (!robot) return; + + setIsLoading(true); + try { + const credentialsForPayload = Object.entries(credentials).reduce( + (acc, [selector, info]) => { + const enforceType = info.type === "password" ? "password" : "text"; + + acc[selector] = { + value: info.value, + type: enforceType, + }; + return acc; + }, + {} as Record + ); + + const targetUrl = getTargetUrl(); + + const payload = { + name: robot.recording_meta.name, + limits: scrapeListLimits.map((limit) => ({ + pairIndex: limit.pairIndex, + actionIndex: limit.actionIndex, + argIndex: limit.argIndex, + limit: limit.currentLimit, + })), + credentials: credentialsForPayload, + targetUrl: targetUrl, + }; + + const success = await updateRecording(robot.recording_meta.id, payload); + + if (success) { + setRerenderRobots(true); + notify("success", t("robot_edit.notifications.update_success")); + handleStart(robot); + navigate("/robots"); + } else { + notify("error", t("robot_edit.notifications.update_failed")); + } + } catch (error) { + notify("error", t("robot_edit.notifications.update_error")); + console.error("Error updating robot:", error); + } finally { + setIsLoading(false); + } + }; + + const handleCancel = () => { + navigate("/robots"); + }; + + const lastPair = + robot?.recording.workflow[robot?.recording.workflow.length - 1]; + const targetUrl = lastPair?.what.find((action) => action.action === "goto") + ?.args?.[0]; + + return ( + + <> + + {robot && ( + <> + handleRobotNameChange(e.target.value)} + style={{ marginBottom: "20px" }} + /> + + handleTargetUrlChange(e.target.value)} + style={{ marginBottom: "20px" }} + /> + + {renderScrapeListLimitFields()} + + )} + + + + ); +}; diff --git a/src/components/robot/pages/RobotIntegrationPage.tsx b/src/components/robot/pages/RobotIntegrationPage.tsx new file mode 100644 index 00000000..bffe8da8 --- /dev/null +++ b/src/components/robot/pages/RobotIntegrationPage.tsx @@ -0,0 +1,821 @@ +import React, { useState, useEffect } from "react"; +import { + MenuItem, + Typography, + CircularProgress, + Alert, + AlertTitle, + Button, + TextField, + IconButton, + Box, + Chip, + Card, + CardContent, + CardActions, + Switch, + FormControlLabel, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Paper, +} from "@mui/material"; +import { + Add as AddIcon, + Delete as DeleteIcon, + Edit as EditIcon, + Science as ScienceIcon, +} from "@mui/icons-material"; +import axios from "axios"; +import { useGlobalInfoStore } from "../../../context/globalInfo"; +import { getStoredRecording } from "../../../api/storage"; +import { apiUrl } from "../../../apiConfig.js"; +import { v4 as uuid } from "uuid"; +import { useTranslation } from "react-i18next"; +import { useNavigate, useParams, useLocation } from "react-router-dom"; +import { + addWebhook, + updateWebhook, + removeWebhook, + getWebhooks, + testWebhook, + WebhookConfig, +} from "../../../api/webhook"; +import { RobotConfigPage } from "./RobotConfigPage"; + +interface IntegrationProps { + handleStart: (data: IntegrationSettings) => void; + robotPath?: string; + preSelectedIntegrationType?: "googleSheets" | "airtable" | "webhook" | null; +} + +export interface IntegrationSettings { + spreadsheetId?: string; + spreadsheetName?: string; + airtableBaseId?: string; + airtableBaseName?: string; + airtableTableName?: string; + airtableTableId?: string; + webhooks?: WebhookConfig[]; + data: string; + integrationType: "googleSheets" | "airtable" | "webhook"; +} + +export const RobotIntegrationPage = ({ + handleStart, + robotPath = "robots", + preSelectedIntegrationType = null, +}: IntegrationProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + + const pathSegments = location.pathname.split('/'); + const robotsIndex = pathSegments.findIndex(segment => segment === 'robots' || segment === 'prebuilt-robots'); + const integrateIndex = pathSegments.findIndex(segment => segment === 'integrate'); + + const robotIdFromUrl = robotsIndex !== -1 && robotsIndex + 1 < pathSegments.length + ? pathSegments[robotsIndex + 1] + : null; + + const integrationType = integrateIndex !== -1 && integrateIndex + 1 < pathSegments.length + ? pathSegments[integrateIndex + 1] as "googleSheets" | "airtable" | "webhook" + : preSelectedIntegrationType || null; + + const [settings, setSettings] = useState({ + spreadsheetId: "", + spreadsheetName: "", + airtableBaseId: "", + airtableBaseName: "", + airtableTableName: "", + airtableTableId: "", + webhooks: [], + data: "", + integrationType: integrationType || "airtable", + }); + + const [spreadsheets, setSpreadsheets] = useState<{ id: string; name: string }[]>([]); + const [airtableBases, setAirtableBases] = useState<{ id: string; name: string }[]>([]); + const [airtableTables, setAirtableTables] = useState<{ id: string; name: string }[]>([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const [showWebhookForm, setShowWebhookForm] = useState(false); + const [editingWebhook, setEditingWebhook] = useState(null); + const [newWebhook, setNewWebhook] = useState({ + id: "", + url: "", + events: ["run_completed"], + active: true, + }); + const [urlError, setUrlError] = useState(null); + + const { recordingId: recordingIdFromStore, notify, setRerenderRobots, setRecordingId } = useGlobalInfoStore(); + + const recordingId = robotIdFromUrl || recordingIdFromStore; + + useEffect(() => { + if (robotIdFromUrl && robotIdFromUrl !== recordingIdFromStore) { + setRecordingId(robotIdFromUrl); + } + }, [robotIdFromUrl, recordingIdFromStore, setRecordingId]); + + const [recording, setRecording] = useState(null); + const [selectedIntegrationType, setSelectedIntegrationType] = useState< + "googleSheets" | "airtable" | "webhook" | null + >(integrationType); + + const authenticateWithGoogle = () => { + if (!recordingId) { + console.error("Cannot authenticate: recordingId is null"); + return; + } + const basePath = robotPath === "prebuilt-robots" ? "/prebuilt-robots" : "/robots"; + const redirectUrl = `${window.location.origin}${basePath}/${recordingId}/integrate/googleSheets`; + window.location.href = `${apiUrl}/auth/google?robotId=${recordingId}&redirectUrl=${encodeURIComponent(redirectUrl)}`; + }; + + const authenticateWithAirtable = () => { + if (!recordingId) { + console.error("Cannot authenticate: recordingId is null"); + return; + } + const basePath = robotPath === "prebuilt-robots" ? "/prebuilt-robots" : "/robots"; + const redirectUrl = `${window.location.origin}${basePath}/${recordingId}/integrate/airtable`; + window.location.href = `${apiUrl}/auth/airtable?robotId=${recordingId}&redirectUrl=${encodeURIComponent(redirectUrl)}`; + }; + + const validateWebhookData = ( + url: string, + events: string[], + excludeId?: string + ) => { + if (!url) { + setUrlError("Please provide webhook URL"); + return false; + } + try { + new URL(url); + } catch { + setUrlError("Please provide a valid URL"); + return false; + } + const existingWebhook = settings.webhooks?.find( + (webhook) => webhook.url === url && webhook.id !== excludeId + ); + if (existingWebhook) { + setUrlError("This webhook URL is already in use"); + return false; + } + if (!events || events.length === 0) { + setUrlError("Please select at least one event"); + return false; + } + setUrlError(null); + return true; + }; + + const fetchWebhooks = async () => { + try { + setLoading(true); + if (!recordingId) return; + const response = await getWebhooks(recordingId); + if (response.ok && response.webhooks) { + setSettings((prev) => ({ ...prev, webhooks: response.webhooks })); + } + setLoading(false); + } catch (error: any) { + setLoading(false); + console.error("Error fetching webhooks:", error); + } + }; + + const addWebhookSetting = async () => { + if (!validateWebhookData(newWebhook.url, newWebhook.events)) { + if (!newWebhook.url) notify("error", "Please provide webhook URL"); + else if (!newWebhook.events || newWebhook.events.length === 0) notify("error", "Please select at least one event"); + return; + } + if (!recordingId) return; + try { + setLoading(true); + const webhookWithId = { ...newWebhook, id: uuid() }; + const response = await addWebhook(webhookWithId, recordingId); + if (response.ok) { + setSettings((prev) => ({ ...prev, webhooks: [...(prev.webhooks || []), webhookWithId] })); + setNewWebhook({ id: "", url: "", events: ["run_completed"], active: true }); + setShowWebhookForm(false); + notify("success", "Webhook added successfully"); + } else { + notify("error", response.message || "Failed to add webhook"); + } + setLoading(false); + } catch (error: any) { + setLoading(false); + notify("error", "Failed to add webhook"); + console.error("Error adding webhook:", error); + } + }; + + const updateWebhookSetting = async () => { + if (!validateWebhookData(newWebhook.url, newWebhook.events, editingWebhook || undefined)) return; + if (!recordingId || !editingWebhook) return; + try { + setLoading(true); + const response = await updateWebhook({ ...newWebhook, id: editingWebhook }, recordingId); + if (response.ok) { + setSettings((prev) => ({ + ...prev, + webhooks: (prev.webhooks || []).map((webhook) => + webhook.id === editingWebhook ? { ...newWebhook, id: editingWebhook } : webhook + ), + })); + setNewWebhook({ id: "", url: "", events: ["run_completed"], active: true }); + setEditingWebhook(null); + setShowWebhookForm(false); + notify("success", "Webhook updated successfully"); + } else { + notify("error", response.message || "Failed to update webhook"); + } + setLoading(false); + } catch (error: any) { + setLoading(false); + notify("error", "Failed to update webhook"); + console.error("Error updating webhook:", error); + } + }; + + const deleteWebhookSetting = async (webhookId: string) => { + if (!recordingId) return; + try { + setLoading(true); + const response = await removeWebhook(webhookId, recordingId); + if (response.ok) { + setSettings((prev) => ({ ...prev, webhooks: (prev.webhooks || []).filter((webhook) => webhook.id !== webhookId) })); + if (recordingId) { + const updatedRecording = await getStoredRecording(recordingId); + setRecording(updatedRecording); + } + setRerenderRobots(true); + notify("success", "Webhook removed successfully"); + } else { + notify("error", response.error || "Failed to remove webhook"); + } + setLoading(false); + } catch (error: any) { + setLoading(false); + notify("error", "Failed to remove webhook"); + console.error("Error removing webhook:", error); + } + }; + + const testWebhookSetting = async (webhookId: string) => { + if (!recordingId) return; + const webhook = settings.webhooks?.find(w => w.id === webhookId); + if (!webhook) return; + try { + setLoading(true); + const response = await testWebhook(webhook, recordingId); + if (response.ok) { + notify("success", "Test webhook sent successfully"); + } else { + notify("error", response.message || "Failed to test webhook"); + } + setLoading(false); + } catch (error: any) { + setLoading(false); + notify("error", "Failed to test webhook"); + console.error("Error testing webhook:", error); + } + }; + + useEffect(() => { + setSelectedIntegrationType(integrationType); + setSettings(prev => ({ ...prev, integrationType: integrationType || "airtable" })); + }, [integrationType]); + + useEffect(() => { + const fetchRecording = async () => { + if (recordingId) { + try { + const recordingData = await getStoredRecording(recordingId); + setRecording(recordingData); + } catch (error) { + console.error("Failed to fetch recording:", error); + } + } + }; + fetchRecording(); + if (selectedIntegrationType === "webhook") { + fetchWebhooks(); + } + }, [recordingId, selectedIntegrationType]); + + const handleCancel = () => { + const basePath = robotPath === "prebuilt-robots" ? "/prebuilt-robots" : "/robots"; + navigate(basePath); + }; + + const fetchSpreadsheetFiles = async () => { + try { + setLoading(true); + const response = await axios.get(`${apiUrl}/auth/gsheets/files?robotId=${recordingId}`, { withCredentials: true }); + setSpreadsheets(response.data); + setLoading(false); + } catch (error: any) { + setLoading(false); + console.error("Error fetching spreadsheet files:", error); + notify("error", t("integration_settings.google.errors.fetch_error", { + message: error.response?.data?.message || error.message, + })); + } + }; + + const handleSpreadsheetSelect = (e: React.ChangeEvent) => { + const selectedSheet = spreadsheets.find((sheet) => sheet.id === e.target.value); + if (selectedSheet) { + setSettings({ ...settings, spreadsheetId: selectedSheet.id, spreadsheetName: selectedSheet.name }); + } + }; + + const updateGoogleSheetId = async () => { + try { + setLoading(true); + await axios.post(`${apiUrl}/auth/gsheets/update`, { + spreadsheetId: settings.spreadsheetId, + spreadsheetName: settings.spreadsheetName, + robotId: recordingId, + }, { withCredentials: true }); + if (recordingId) { + const updatedRecording = await getStoredRecording(recordingId); + setRecording(updatedRecording); + } + setRerenderRobots(true); + notify("success", t("integration_settings.google.notifications.sheet_selected")); + setLoading(false); + } catch (error: any) { + setLoading(false); + console.error("Error updating Google Sheet ID:", error); + notify("error", t("integration_settings.google.errors.update_error", { + message: error.response?.data?.message || error.message, + })); + } + }; + + const removeGoogleSheetsIntegration = async () => { + try { + setLoading(true); + await axios.post(`${apiUrl}/auth/gsheets/remove`, { robotId: recordingId }, { withCredentials: true }); + setSpreadsheets([]); + setSettings({ ...settings, spreadsheetId: "", spreadsheetName: "" }); + if (recordingId) { + const updatedRecording = await getStoredRecording(recordingId); + setRecording(updatedRecording); + } + setRerenderRobots(true); + notify("success", t("integration_settings.google.notifications.integration_removed")); + setLoading(false); + } catch (error: any) { + setLoading(false); + console.error("Error removing Google Sheets integration:", error); + notify("error", t("integration_settings.google.errors.remove_error", { + message: error.response?.data?.message || error.message, + })); + } + }; + + const fetchAirtableBases = async () => { + try { + setLoading(true); + const response = await axios.get(`${apiUrl}/auth/airtable/bases?robotId=${recordingId}`, { withCredentials: true }); + setAirtableBases(response.data); + setLoading(false); + } catch (error: any) { + setLoading(false); + console.error("Error fetching Airtable bases:", error); + notify("error", t("integration_settings.airtable.errors.fetch_error", { + message: error.response?.data?.message || error.message, + })); + } + }; + + const fetchAirtableTables = async (baseId: string, recordingId: string) => { + try { + setLoading(true); + const response = await axios.get(`${apiUrl}/auth/airtable/tables?robotId=${recordingId}&baseId=${baseId}`, { withCredentials: true }); + setAirtableTables(response.data); + setLoading(false); + } catch (error: any) { + setLoading(false); + console.error("Error fetching Airtable tables:", error); + notify("error", t("integration_settings.airtable.errors.fetch_tables_error", { + message: error.response?.data?.message || error.message, + })); + } + }; + + const handleAirtableBaseSelect = async (e: React.ChangeEvent) => { + const selectedBase = airtableBases.find((base) => base.id === e.target.value); + if (selectedBase) { + setSettings((prevSettings) => ({ ...prevSettings, airtableBaseId: selectedBase.id, airtableBaseName: selectedBase.name })); + if (recordingId) await fetchAirtableTables(selectedBase.id, recordingId); + } + }; + + const handleAirtabletableSelect = (e: React.ChangeEvent) => { + const selectedTable = airtableTables.find((table) => table.id === e.target.value); + if (selectedTable) { + setSettings((prevSettings) => ({ ...prevSettings, airtableTableId: e.target.value, airtableTableName: selectedTable?.name || "" })); + } + }; + + const updateAirtableBase = async () => { + try { + setLoading(true); + await axios.post(`${apiUrl}/auth/airtable/update`, { + baseId: settings.airtableBaseId, + baseName: settings.airtableBaseName, + robotId: recordingId, + tableName: settings.airtableTableName, + tableId: settings.airtableTableId, + }, { withCredentials: true }); + if (recordingId) { + const updatedRecording = await getStoredRecording(recordingId); + setRecording(updatedRecording); + } + setRerenderRobots(true); + notify("success", t("integration_settings.airtable.notifications.base_selected")); + setLoading(false); + } catch (error: any) { + setLoading(false); + console.error("Error updating Airtable base:", error); + notify("error", t("integration_settings.airtable.errors.update_error", { + message: error.response?.data?.message || error.message, + })); + } + }; + + const removeAirtableIntegration = async () => { + try { + setLoading(true); + await axios.post(`${apiUrl}/auth/airtable/remove`, { robotId: recordingId }, { withCredentials: true }); + setAirtableBases([]); + setAirtableTables([]); + setSettings({ ...settings, airtableBaseId: "", airtableBaseName: "", airtableTableName: "", airtableTableId: "" }); + if (recordingId) { + const updatedRecording = await getStoredRecording(recordingId); + setRecording(updatedRecording); + } + setRerenderRobots(true); + notify("success", t("integration_settings.airtable.notifications.integration_removed")); + setLoading(false); + } catch (error: any) { + setLoading(false); + console.error("Error removing Airtable integration:", error); + notify("error", t("integration_settings.airtable.errors.remove_error", { + message: error.response?.data?.message || error.message, + })); + } + }; + + const renderGoogleSheetsIntegration = () => ( + <> + + {t("integration_settings.google.title")} + + {recording?.google_sheet_id ? ( + <> + + {t("integration_settings.google.alerts.success.title")} + {t("integration_settings.google.alerts.success.content", { sheetName: recording.google_sheet_name })} + + {t("integration_settings.google.alerts.success.here")} + + + + + ) : ( + <> + {!recording?.google_sheet_email ? ( + <> +

{t("integration_settings.google.descriptions.sync_info")}

+ + + ) : ( + <> + + {t("integration_settings.google.descriptions.authenticated_as", { email: recording.google_sheet_email })} + + {loading ? ( + + ) : error ? ( + {error} + ) : spreadsheets.length === 0 ? ( + + ) : ( + <> + + {spreadsheets.map((sheet) => ({sheet.name}))} + + + + )} + + )} + + )} + + ); + + const renderAirtableIntegration = () => ( + <> + + {t("integration_settings.airtable.title")} + + {recording?.airtable_base_id ? ( + <> + + {t("integration_settings.airtable.alerts.success.title")} + {t("integration_settings.airtable.alerts.success.content", { baseName: recording.airtable_base_name, tableName: recording.airtable_table_name })} + + {t("integration_settings.airtable.alerts.success.here")} + + + + + ) : ( + <> + {!recording?.airtable_access_token ? ( + <> +

{t("integration_settings.airtable.descriptions.sync_info")}

+ + + ) : ( + <> + + {t("integration_settings.airtable.descriptions.authenticated_as")} + + {loading ? ( + + ) : error ? ( + {error} + ) : airtableBases.length === 0 ? ( + + + + + ) : ( + <> + + {airtableBases.map((base) => ({base.name}))} + + + {airtableTables.map((table) => ({table.name}))} + + + + )} + + )} + + )} + + ); + + const getIntegrationTitle = () => { + switch (selectedIntegrationType) { + case "googleSheets": + return "Google Sheets Integration"; + case "airtable": + return "Airtable Integration"; + case "webhook": + return "Webhook Integration"; + default: + return "Integration Settings"; + } + }; + + const editWebhookSetting = (webhook: WebhookConfig) => { + setNewWebhook(webhook); + setEditingWebhook(webhook.id); + setShowWebhookForm(true); + }; + + const resetWebhookForm = () => { + setNewWebhook({ id: "", url: "", events: ["run_completed"], active: true }); + setShowWebhookForm(false); + setEditingWebhook(null); + setUrlError(null); + }; + + const toggleWebhookStatusSetting = async (webhookId: string) => { + if (!recordingId) return; + try { + const webhook = settings.webhooks?.find((w) => w.id === webhookId); + if (!webhook) return; + const updatedWebhook = { ...webhook, active: !webhook.active }; + const response = await updateWebhook(updatedWebhook, recordingId); + if (response.ok) { + const updatedWebhooks = (settings.webhooks || []).map((w) => w.id === webhookId ? updatedWebhook : w); + setSettings({ ...settings, webhooks: updatedWebhooks }); + if (recordingId) { + const updatedRecording = await getStoredRecording(recordingId); + setRecording(updatedRecording); + } + setRerenderRobots(true); + notify("success", `Webhook ${updatedWebhook.active ? "enabled" : "disabled"}`); + } else { + notify("error", response.message || "Failed to update webhook"); + } + } catch (error: any) { + console.error("Error toggling webhook status:", error); + notify("error", "Failed to update webhook"); + } + }; + + const formatEventName = (event: string) => { + switch (event) { + case "run_completed": return "Run finished"; + case "run_failed": return "Run failed"; + default: return event; + } + }; + + const formatLastCalled = (lastCalledAt?: string | null) => { + if (!lastCalledAt) return "Not called yet"; + const date = new Date(lastCalledAt); + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24)); + const diffHours = Math.floor(diffMs / (1000 * 60 * 60)); + const diffMinutes = Math.floor(diffMs / (1000 * 60)); + if (diffMinutes < 1) return "Just now"; + else if (diffMinutes < 60) return `${diffMinutes} minute${diffMinutes === 1 ? "" : "s"} ago`; + else if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`; + else if (diffDays < 7) return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`; + else return date.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" }); + }; + + // --- MAIN RENDER --- + if (!selectedIntegrationType && !integrationType) { + return ( + navigate(`/${robotPath}/${recordingId}/integrate`)}> +
+
+
+ + + + +
+
+
+
+ ); + } + + const handleBack = () => { + if (!recordingId) return; + setSelectedIntegrationType(null); + const basePath = robotPath === "prebuilt-robots" ? "/prebuilt-robots" : "/robots"; + navigate(`${basePath}/${recordingId}/integrate`); + }; + + return ( + +
+
+ {(selectedIntegrationType === "googleSheets" || integrationType === "googleSheets") && ( + <>{renderGoogleSheetsIntegration()} + )} + + {(selectedIntegrationType === "airtable" || integrationType === "airtable") && ( + <>{renderAirtableIntegration()} + )} + + {(selectedIntegrationType === "webhook" || integrationType === "webhook") && ( + <> + Integrate using Webhooks + {settings.webhooks && settings.webhooks.length > 0 && ( + + + + + Webhook URL + Call when + Last called + Status + Actions + + + + {settings.webhooks.map((webhook) => ( + + {webhook.url} + {webhook.events.map((event) => ())} + {formatLastCalled(webhook.lastCalledAt)} + toggleWebhookStatusSetting(webhook.id)} size="small"/> + + testWebhookSetting(webhook.id)} disabled={loading || !webhook.active} title="Test"> + editWebhookSetting(webhook)} disabled={loading} title="Edit"> + deleteWebhookSetting(webhook.id)} disabled={loading} title="Delete"> + + + ))} + +
+
+ )} + {!showWebhookForm && ( + + + { setNewWebhook({ ...newWebhook, url: e.target.value }); if (urlError) setUrlError(null); }} error={!!urlError} helperText={urlError} required aria-describedby="webhook-url-help"/> + setNewWebhook({ ...newWebhook, events: [e.target.value] })} sx={{ minWidth: "200px" }} required> + Run finished + Run failed + + + + Refer to the API documentation for examples and details. + + + + )} + {showWebhookForm && ( + + + {editingWebhook ? "Edit Webhook" : "Add New Webhook"} + { setNewWebhook({ ...newWebhook, url: e.target.value }); if (urlError) setUrlError(null); }} sx={{ marginBottom: "15px" }} placeholder="https://your-api.com/webhook/endpoint" required error={!!urlError} helperText={urlError}/> + setNewWebhook({ ...newWebhook, events: typeof e.target.value === "string" ? [e.target.value] : e.target.value })} + SelectProps={{ multiple: true, renderValue: (selected) => ({(selected as string[]).map((value) => ())}),}} sx={{ marginBottom: "20px" }} required> + Run finished + Run failed + + setNewWebhook({ ...newWebhook, active: e.target.checked })}/>} label="Active" sx={{ marginBottom: "10px" }}/> + + + + + + + )} + + )} +
+
+
+ ); +}; diff --git a/src/components/robot/pages/RobotSettingsPage.tsx b/src/components/robot/pages/RobotSettingsPage.tsx new file mode 100644 index 00000000..b5355082 --- /dev/null +++ b/src/components/robot/pages/RobotSettingsPage.tsx @@ -0,0 +1,192 @@ +import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { TextField, Typography, Box, Card, CardContent } from "@mui/material"; +import { Settings, Info } from "@mui/icons-material"; +import { useGlobalInfoStore } from "../../../context/globalInfo"; +import { getStoredRecording } from "../../../api/storage"; +import { WhereWhatPair } from "maxun-core"; +import { getUserById } from "../../../api/auth"; +import { RobotConfigPage } from "./RobotConfigPage"; +import { useNavigate, useLocation } from "react-router-dom"; + +interface RobotMeta { + name: string; + id: string; + createdAt: string; + pairs: number; + updatedAt: string; + params: any[]; + url?: string; +} + +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 { + handleStart: (settings: RobotSettings) => void; +} + +export const RobotSettingsPage = ({ handleStart }: RobotSettingsProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const [userEmail, setUserEmail] = useState(null); + const [robot, setRobot] = useState(null); + const { recordingId, notify } = useGlobalInfoStore(); + + useEffect(() => { + getRobot(); + }, []); + + const getRobot = async () => { + if (recordingId) { + try { + const robot = await getStoredRecording(recordingId); + setRobot(robot); + } catch (error) { + notify("error", t("robot_settings.errors.robot_not_found")); + } + } else { + notify("error", t("robot_settings.errors.robot_not_found")); + } + }; + + const getTargetUrl = () => { + let url = robot?.recording_meta.url; + + if (!url) { + const lastPair = + robot?.recording.workflow[robot?.recording.workflow.length - 1]; + url = lastPair?.what.find((action) => action.action === "goto") + ?.args?.[0]; + } + + return url; + }; + + useEffect(() => { + const fetchUserEmail = async () => { + if (robot && robot.userId) { + try { + const userData = await getUserById(robot.userId.toString()); + if (userData && userData.user) { + setUserEmail(userData.user.email); + } + } catch (error) { + console.error("Failed to fetch user email:", error); + } + } + }; + fetchUserEmail(); + }, [robot?.userId]); + + const handleCancel = () => { + const basePath = location.pathname.includes("/prebuilt-robots") + ? "/prebuilt-robots" + : "/robots"; + navigate(basePath); + }; + + const targetUrl = getTargetUrl(); + + return ( + + <> + + {robot && ( + <> + + + {robot.recording.workflow?.[0]?.what?.[0]?.args?.[0]?.limit !== + undefined && ( + + )} + + + + )} + + + + ); +}; diff --git a/src/components/robot/pages/ScheduleSettingsPage.tsx b/src/components/robot/pages/ScheduleSettingsPage.tsx new file mode 100644 index 00000000..5108d485 --- /dev/null +++ b/src/components/robot/pages/ScheduleSettingsPage.tsx @@ -0,0 +1,373 @@ +import React, { useState, useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import { + MenuItem, + TextField, + Typography, + Box, + Button, +} from "@mui/material"; +import { Schedule } from "@mui/icons-material"; +import { Dropdown } from "../../ui/DropdownMui"; +import { validMomentTimezones } from "../../../constants/const"; +import { useGlobalInfoStore } from "../../../context/globalInfo"; +import { getSchedule, deleteSchedule } from "../../../api/storage"; +import { RobotConfigPage } from "./RobotConfigPage"; +import { useNavigate, useLocation } from "react-router-dom"; + +interface ScheduleSettingsProps { + handleStart: (settings: ScheduleSettings) => void; +} + +export interface ScheduleSettings { + runEvery: number; + runEveryUnit: string; + startFrom: string; + dayOfMonth?: string; + atTimeStart?: string; + atTimeEnd?: string; + timezone: string; +} + +export const ScheduleSettingsPage = ({ + handleStart, +}: ScheduleSettingsProps) => { + const { t } = useTranslation(); + const navigate = useNavigate(); + const location = useLocation(); + const [schedule, setSchedule] = useState(null); + const [settings, setSettings] = useState({ + runEvery: 1, + runEveryUnit: "HOURS", + startFrom: "MONDAY", + dayOfMonth: "1", + atTimeStart: "00:00", + atTimeEnd: "01:00", + timezone: "UTC", + }); + const [isLoading, setIsLoading] = useState(false); + + const handleChange = ( + field: keyof ScheduleSettings, + value: string | number | boolean + ) => { + setSettings((prev) => ({ ...prev, [field]: value })); + }; + + const units = ["MINUTES", "HOURS", "DAYS", "WEEKS", "MONTHS"]; + + const days = [ + "MONDAY", + "TUESDAY", + "WEDNESDAY", + "THURSDAY", + "FRIDAY", + "SATURDAY", + "SUNDAY", + ]; + + const { recordingId, notify } = useGlobalInfoStore(); + + const deleteRobotSchedule = async () => { + if (recordingId) { + setIsLoading(true); + try { + await deleteSchedule(recordingId); + setSchedule(null); + notify("success", t("Schedule deleted successfully")); + + setSettings({ + runEvery: 1, + runEveryUnit: "HOURS", + startFrom: "MONDAY", + dayOfMonth: "", + atTimeStart: "00:00", + atTimeEnd: "01:00", + timezone: "UTC", + }); + } catch (error) { + notify("error", "Failed to delete schedule"); + } finally { + setIsLoading(false); + } + } else { + console.error("No recording id provided"); + } + }; + + const getRobotSchedule = async () => { + if (recordingId) { + try { + const scheduleData = await getSchedule(recordingId); + setSchedule(scheduleData); + } catch (error) { + console.error("Failed to fetch robot schedule:", error); + } + } else { + console.error("No recording id provided"); + } + }; + + useEffect(() => { + const fetchSchedule = async () => { + await getRobotSchedule(); + }; + fetchSchedule(); + }, []); + + const handleSave = async () => { + setIsLoading(true); + try { + await handleStart(settings); + const basePath = location.pathname.includes("/prebuilt-robots") + ? "/prebuilt-robots" + : "/robots"; + navigate(basePath); + } catch (error) { + notify("error", "Failed to save schedule"); + } finally { + setIsLoading(false); + } + }; + + const handleCancel = () => { + const basePath = location.pathname.includes("/prebuilt-robots") + ? "/prebuilt-robots" + : "/robots"; + navigate(basePath); + }; + + 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"); + } + }; + + const textStyle = { + width: "150px", + height: "52px", + marginRight: "10px", + }; + + const dropDownStyle = { + marginTop: "2px", + width: "150px", + height: "59px", + marginRight: "10px", + }; + + return ( + + *": { marginBottom: "20px" }, + }} + > + <> + {schedule !== null ? ( + <> + + {t("schedule_settings.run_every")}: {schedule.runEvery}{" "} + {schedule.runEveryUnit.toLowerCase()} + + + {["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()} + + {schedule.runEveryUnit === "MONTHS" && ( + + {t("schedule_settings.on_day")}: {schedule.dayOfMonth} + {getDayOrdinal(schedule.dayOfMonth)} of the month + + )} + + {t("schedule_settings.at_around")}: {schedule.atTimeStart},{" "} + {schedule.timezone} {t("schedule_settings.timezone")} + + + + + + ) : ( + <> + + + {t("schedule_settings.labels.run_once_every")} + + + handleChange("runEvery", parseInt(e.target.value)) + } + sx={textStyle} + inputProps={{ min: 1 }} + /> + + handleChange("runEveryUnit", e.target.value) + } + sx={dropDownStyle} + > + {units.map((unit) => ( + + {" "} + {unit.charAt(0).toUpperCase() + + unit.slice(1).toLowerCase()} + + ))} + + + + + + {["MONTHS", "WEEKS"].includes(settings.runEveryUnit) + ? t("schedule_settings.labels.start_from_label") + : t("schedule_settings.labels.start_from_label")} + + + handleChange("startFrom", e.target.value) + } + sx={dropDownStyle} + > + {days.map((day) => ( + + {day.charAt(0).toUpperCase() + + day.slice(1).toLowerCase()} + + ))} + + + + {settings.runEveryUnit === "MONTHS" && ( + + + {t("schedule_settings.labels.on_day_of_month")} + + + handleChange("dayOfMonth", e.target.value) + } + sx={textStyle} + inputProps={{ min: 1, max: 31 }} + /> + + )} + + {["MINUTES", "HOURS"].includes(settings.runEveryUnit) ? ( + + + + {t("schedule_settings.labels.in_between")} + + + handleChange("atTimeStart", e.target.value) + } + sx={textStyle} + /> + + handleChange("atTimeEnd", e.target.value) + } + sx={textStyle} + /> + + + ) : ( + + + {t("schedule_settings.at_around")} + + + handleChange("atTimeStart", e.target.value) + } + sx={textStyle} + /> + + )} + + + + {t("schedule_settings.timezone")} + + + handleChange("timezone", e.target.value) + } + sx={dropDownStyle} + > + {validMomentTimezones.map((tz) => ( + + {tz.charAt(0).toUpperCase() + + tz.slice(1).toLowerCase()} + + ))} + + + + )} + + + + ); +};