diff --git a/maxun-core/src/browserSide/scraper.js b/maxun-core/src/browserSide/scraper.js index 99c8ee33..1ea4b3b4 100644 --- a/maxun-core/src/browserSide/scraper.js +++ b/maxun-core/src/browserSide/scraper.js @@ -421,7 +421,6 @@ async function clickNextPagination(selector, scrapedData, limit) { return results; }; - window.scrollDown = async function (selector, limit) { let previousHeight = 0; let itemsLoaded = 0; diff --git a/server/src/workflow-management/classes/Generator.ts b/server/src/workflow-management/classes/Generator.ts index a7b5a641..1f59b3f8 100644 --- a/server/src/workflow-management/classes/Generator.ts +++ b/server/src/workflow-management/classes/Generator.ts @@ -7,6 +7,7 @@ import { getElementInformation, getRect, getSelectors, + getChildSelectors, getNonUniqueSelectors, isRuleOvershadowing, selectorAlreadyInWorkflow @@ -53,6 +54,8 @@ export class WorkflowGenerator { */ private getList: boolean = false; + private listSelector: string = ''; + /** * The public constructor of the WorkflowGenerator. * Takes socket for communication as a parameter and registers some important events on it. @@ -103,6 +106,9 @@ export class WorkflowGenerator { this.socket.on('setGetList', (data: { getList: boolean }) => { this.getList = data.getList; }); + this.socket.on('listSelector', (data: { selector: string }) => { + this.listSelector = data.selector; + }) } /** @@ -476,6 +482,11 @@ export class WorkflowGenerator { */ private generateSelector = async (page: Page, coordinates: Coordinates, action: ActionType) => { const elementInfo = await getElementInformation(page, coordinates); + const generalSelector = await getNonUniqueSelectors(page, coordinates) + const childSelectors = await getChildSelectors(page, generalSelector.generalSelector); + + console.log('Non Unique Selectors [DEBUG]:', generalSelector); + console.log('Child Selectors [DEBUG]:', childSelectors); const selectorBasedOnCustomAction = (this.getList === true) ? await getNonUniqueSelectors(page, coordinates) @@ -507,7 +518,14 @@ export class WorkflowGenerator { const displaySelector = await this.generateSelector(page, coordinates, ActionType.Click); const elementInfo = await getElementInformation(page, coordinates); if (rect) { - this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo }); + if (this.getList === true) { + if (this.listSelector !== '') { + const childSelectors = await getChildSelectors(page, this.listSelector || ''); + this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo, childSelectors }) + } + } else { + this.socket.emit('highlighter', { rect, selector: displaySelector, elementInfo }); + } } // reset getList after usage this.getList = false; diff --git a/server/src/workflow-management/selector.ts b/server/src/workflow-management/selector.ts index 00a19f40..0ef42c0c 100644 --- a/server/src/workflow-management/selector.ts +++ b/server/src/workflow-management/selector.ts @@ -4,6 +4,11 @@ import { WhereWhatPair, WorkflowFile } from "maxun-core"; import logger from "../logger"; import { getBestSelectorForAction } from "./utils"; +/*TODO: +1. Handle TS errors (here we definetly know better) +2. Add pending function descriptions + thought process (esp. selector generation) +*/ + type Workflow = WorkflowFile["workflow"]; /** @@ -97,21 +102,6 @@ export const getElementInformation = async ( }, { x: coordinates.x, y: coordinates.y }, ); - - // if (elementInfo) { - // if (elementInfo.tagName === 'A') { - // if (elementInfo.innerText) { - // console.log(`Link text: ${elementInfo.innerText}, URL: ${elementInfo.url}`); - // } else { - // console.log(`URL: ${elementInfo.url}`); - // } - // } else if (elementInfo.tagName === 'IMG') { - // console.log(`Image URL: ${elementInfo.imageUrl}`); - // } else { - // console.log(`Element innerText: ${elementInfo.innerText}`); - // } - // } - return elementInfo; } catch (error) { const { message, stack } = error as Error; @@ -591,8 +581,6 @@ export const getSelectors = async (page: Page, coordinates: Coordinates) => { return output; } - - const genSelectors = (element: HTMLElement | null) => { if (element == null) { return null; @@ -722,6 +710,10 @@ export const getSelectors = async (page: Page, coordinates: Coordinates) => { }; +interface SelectorResult { + generalSelector: string; +} + /** * Returns the best non-unique css {@link Selectors} for the element on the page. * @param page The page instance. @@ -730,18 +722,16 @@ export const getSelectors = async (page: Page, coordinates: Coordinates) => { * @returns {Promise} */ -export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates) => { +export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates): Promise => { try { const selectors = await page.evaluate(({ x, y }: { x: number, y: number }) => { function getNonUniqueSelector(element: HTMLElement): string { let selector = element.tagName.toLowerCase(); - // Avoid using IDs to maintain non-uniqueness if (element.className) { const classes = element.className.split(/\s+/).filter((cls: string) => Boolean(cls)); if (classes.length > 0) { - // Exclude utility classes and escape special characters const validClasses = classes.filter((cls: string) => !cls.startsWith('!') && !cls.includes(':')); if (validClasses.length > 0) { selector += '.' + validClasses.map(cls => CSS.escape(cls)).join('.'); @@ -754,11 +744,16 @@ export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates function getSelectorPath(element: HTMLElement | null): string { const path: string[] = []; - while (element && element !== document.body) { + let depth = 0; + const maxDepth = 2; + + while (element && element !== document.body && depth < maxDepth) { const selector = getNonUniqueSelector(element); path.unshift(selector); element = element.parentElement; + depth++; } + return path.join(' > '); } @@ -771,15 +766,67 @@ export const getNonUniqueSelectors = async (page: Page, coordinates: Coordinates }; }, coordinates); - return selectors || {}; + return selectors || { generalSelector: '' }; } catch (error) { console.error('Error in getNonUniqueSelectors:', error); - return {}; + return { generalSelector: '' }; } }; +export const getChildSelectors = async (page: Page, parentSelector: string): Promise => { + try { + const childSelectors = await page.evaluate((parentSelector: string) => { + function getNonUniqueSelector(element: HTMLElement): string { + let selector = element.tagName.toLowerCase(); + const className = typeof element.className === 'string' ? element.className : ''; + if (className) { + const classes = className.split(/\s+/).filter((cls: string) => Boolean(cls)); + if (classes.length > 0) { + const validClasses = classes.filter((cls: string) => !cls.startsWith('!') && !cls.includes(':')); + if (validClasses.length > 0) { + selector += '.' + validClasses.map(cls => CSS.escape(cls)).join('.'); + } + } + } + + return selector; + } + + function getSelectorPath(element: HTMLElement | null): string { + if (!element || !element.parentElement) return ''; + + const parentSelector = getNonUniqueSelector(element.parentElement); + const elementSelector = getNonUniqueSelector(element); + + return `${parentSelector} > ${elementSelector}`; + } + + function getAllDescendantSelectors(element: HTMLElement, stopAtParent: HTMLElement | null): string[] { + let selectors: string[] = []; + const children = Array.from(element.children) as HTMLElement[]; + + for (const child of children) { + selectors.push(getSelectorPath(child)); + selectors = selectors.concat(getAllDescendantSelectors(child, stopAtParent)); + } + + return selectors; + } + + const parentElement = document.querySelector(parentSelector) as HTMLElement; + if (!parentElement) return []; + + return getAllDescendantSelectors(parentElement, parentElement); + }, parentSelector); + + return childSelectors || []; + } catch (error) { + console.error('Error in getChildSelectors:', error); + return []; + } +}; /** * Returns the first pair from the given workflow that contains the given selector diff --git a/src/components/molecules/ActionSettings.tsx b/src/components/molecules/ActionSettings.tsx index dcf2ba5c..79e120b8 100644 --- a/src/components/molecules/ActionSettings.tsx +++ b/src/components/molecules/ActionSettings.tsx @@ -1,7 +1,7 @@ import React, { useRef } from 'react'; import styled from "styled-components"; import { Button } from "@mui/material"; -import { ActionDescription } from "../organisms/RightSidePanel"; +//import { ActionDescription } from "../organisms/RightSidePanel"; import * as Settings from "./action-settings"; import { useSocketStore } from "../../context/socket"; @@ -42,7 +42,7 @@ export const ActionSettings = ({ action }: ActionSettingsProps) => { return (
- Action settings: + {/* Action settings: */}
diff --git a/src/components/molecules/BrowserNavBar.tsx b/src/components/molecules/BrowserNavBar.tsx index e57e7c21..b35e4d7f 100644 --- a/src/components/molecules/BrowserNavBar.tsx +++ b/src/components/molecules/BrowserNavBar.tsx @@ -1,5 +1,5 @@ import type { - FC, + FC, } from 'react'; import styled from 'styled-components'; @@ -8,9 +8,9 @@ import ArrowBackIcon from '@mui/icons-material/ArrowBack'; import ArrowForwardIcon from '@mui/icons-material/ArrowForward'; import { NavBarButton } from '../atoms/buttons/buttons'; -import { UrlForm } from './UrlForm'; +import { UrlForm } from './UrlForm'; import { useCallback, useEffect, useState } from "react"; -import {useSocketStore} from "../../context/socket"; +import { useSocketStore } from "../../context/socket"; import { getCurrentUrl } from "../../api/recording"; const StyledNavBar = styled.div<{ browserWidth: number }>` @@ -21,8 +21,8 @@ const StyledNavBar = styled.div<{ browserWidth: number }>` `; interface NavBarProps { - browserWidth: number; - handleUrlChanged: (url: string) => void; + browserWidth: number; + handleUrlChanged: (url: string) => void; }; const BrowserNavBar: FC = ({ @@ -30,16 +30,15 @@ const BrowserNavBar: FC = ({ handleUrlChanged, }) => { - // context: const { socket } = useSocketStore(); const [currentUrl, setCurrentUrl] = useState('https://'); - const handleRefresh = useCallback(() : void => { + const handleRefresh = useCallback((): void => { socket?.emit('input:refresh'); }, [socket]); - const handleGoTo = useCallback((address: string) : void => { + const handleGoTo = useCallback((address: string): void => { socket?.emit('input:url', address); }, [socket]); @@ -70,54 +69,54 @@ const BrowserNavBar: FC = ({ } }, [socket, handleCurrentUrlChange]) - const addAddress = (address: string) => { - if (socket) { - handleUrlChanged(address); - handleGoTo(address); - } - }; + const addAddress = (address: string) => { + if (socket) { + handleUrlChanged(address); + handleGoTo(address); + } + }; - return ( - - { - socket?.emit('input:back'); - }} - disabled={false} - > - - + return ( + + { + socket?.emit('input:back'); + }} + disabled={false} + > + + - { - socket?.emit('input:forward'); - }} - disabled={false} - > - - + { + socket?.emit('input:forward'); + }} + disabled={false} + > + + - { - if (socket) { - handleRefresh() - } - }} - disabled={false} - > - - + { + if (socket) { + handleRefresh() + } + }} + disabled={false} + > + + - - - ); + + + ); } export default BrowserNavBar; diff --git a/src/components/molecules/InterpretationButtons.tsx b/src/components/molecules/InterpretationButtons.tsx index 7e0633af..848c7e05 100644 --- a/src/components/molecules/InterpretationButtons.tsx +++ b/src/components/molecules/InterpretationButtons.tsx @@ -29,59 +29,60 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP actionType: string, selector: string, action: string, - open:boolean - }>({ pair: null, actionType: '', selector: '', action: '', open: false} ); + open: boolean + }>({ pair: null, actionType: '', selector: '', action: '', open: false }); const { socket } = useSocketStore(); const { notify } = useGlobalInfoStore(); const finishedHandler = useCallback(() => { - setInfo({...info, isPaused: false}); + setInfo({ ...info, isPaused: false }); enableStepping(false); }, [info, enableStepping]); const breakpointHitHandler = useCallback(() => { - setInfo({running: false, isPaused: true}); + setInfo({ running: false, isPaused: true }); notify('warning', 'Please restart the interpretation, after updating the recording'); enableStepping(true); }, [info, enableStepping]); const decisionHandler = useCallback( - ({pair, actionType, lastData} - : {pair: WhereWhatPair | null, actionType: string, lastData: { selector: string, action: string }}) => { - const {selector, action} = lastData; - setDecisionModal((prevState) => { - return { - pair, - actionType, - selector, - action, - open: true, - } - }) - }, [decisionModal]); + ({ pair, actionType, lastData } + : { pair: WhereWhatPair | null, actionType: string, lastData: { selector: string, action: string } }) => { + const { selector, action } = lastData; + setDecisionModal((prevState) => { + return { + pair, + actionType, + selector, + action, + open: true, + } + }) + }, [decisionModal]); const handleDecision = (decision: boolean) => { - const {pair, actionType} = decisionModal; - socket?.emit('decision', {pair, actionType, decision}); - setDecisionModal({pair: null, actionType: '', selector: '', action: '', open: false}); + const { pair, actionType } = decisionModal; + socket?.emit('decision', { pair, actionType, decision }); + setDecisionModal({ pair: null, actionType: '', selector: '', action: '', open: false }); } const handleDescription = () => { - switch (decisionModal.actionType){ + switch (decisionModal.actionType) { case 'customAction': return ( - - Do you want to use the previously recorded selector - as a where condition for matching the action? - - - [previous action: {decisionModal.action}] -
{decisionModal.selector}
-
+ + Do you want to use the previously recorded selector + as a where condition for matching the action? + + + [previous action: {decisionModal.action}] +
{decisionModal.selector}
+
); - default: return null;} + default: return null; + } } useEffect(() => { @@ -100,12 +101,12 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP const handlePlay = async () => { if (info.isPaused) { socket?.emit("resume"); - setInfo({running: true, isPaused: false}); + setInfo({ running: true, isPaused: false }); enableStepping(false); } else { - setInfo({...info, running: true}); + setInfo({ ...info, running: true }); const finished = await interpretCurrentRecording(); - setInfo({...info, running: false}); + setInfo({ ...info, running: false }); if (finished) { notify('info', 'Interpretation finished'); } else { @@ -131,45 +132,45 @@ export const InterpretationButtons = ({ enableStepping }: InterpretationButtonsP return ( - - + sx={{ marginTop: '10px', marginBottom: '5px', justifyContent: 'space-evenly', }} > + + Pause - - + + {info.isPaused ? 'Resume' : 'Start'} - - + Stop - {}} isOpen={decisionModal.open} canBeClosed={false} - modalStyle={{ - position: 'absolute', - top: '50%', - left: '50%', - transform: 'translate(-50%, -50%)', - width: 500, - background: 'white', - border: '2px solid #000', - boxShadow: '24', - height:'fit-content', - display:'block', - overflow:'scroll', - padding: '5px 25px 10px 25px', - }}> -
- + { }} isOpen={decisionModal.open} canBeClosed={false} + modalStyle={{ + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: 500, + background: 'white', + border: '2px solid #000', + boxShadow: '24', + height: 'fit-content', + display: 'block', + overflow: 'scroll', + padding: '5px 25px 10px 25px', + }}> +
+ { handleDescription() } -
- - +
+ +
diff --git a/src/components/molecules/InterpretationLog.tsx b/src/components/molecules/InterpretationLog.tsx index 02592511..3feda80c 100644 --- a/src/components/molecules/InterpretationLog.tsx +++ b/src/components/molecules/InterpretationLog.tsx @@ -1,30 +1,57 @@ import * as React from 'react'; -import Accordion from '@mui/material/Accordion'; -import AccordionDetails from '@mui/material/AccordionDetails'; -import AccordionSummary from '@mui/material/AccordionSummary'; +import SwipeableDrawer from '@mui/material/SwipeableDrawer'; import Typography from '@mui/material/Typography'; -import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; -import Highlight from 'react-highlight' +import Radio from '@mui/material/Radio'; +import RadioGroup from '@mui/material/RadioGroup'; +import { Button, TextField } from '@mui/material'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormControl from '@mui/material/FormControl'; +import FormLabel from '@mui/material/FormLabel'; +import Highlight from 'react-highlight'; import { useCallback, useEffect, useRef, useState } from "react"; import { useSocketStore } from "../../context/socket"; +import { useBrowserDimensionsStore } from "../../context/browserDimensions"; +import Table from '@mui/material/Table'; +import TableBody from '@mui/material/TableBody'; +import TableCell from '@mui/material/TableCell'; +import TableContainer from '@mui/material/TableContainer'; +import TableHead from '@mui/material/TableHead'; +import TableRow from '@mui/material/TableRow'; +import Paper from '@mui/material/Paper'; +import StorageIcon from '@mui/icons-material/Storage'; -export const InterpretationLog = () => { - const [expanded, setExpanded] = useState(false); +interface InterpretationLogProps { + isOpen: boolean; + setIsOpen: (isOpen: boolean) => void; +} + +export const InterpretationLog: React.FC = ({ isOpen, setIsOpen }) => { const [log, setLog] = useState(''); + const [selectedOption, setSelectedOption] = useState('10'); + const [customValue, setCustomValue] = useState(''); + const [tableData, setTableData] = useState([]); const logEndRef = useRef(null); - const handleChange = (isExpanded: boolean) => (event: React.SyntheticEvent) => { - setExpanded(isExpanded); - }; - + const { width } = useBrowserDimensionsStore(); const { socket } = useSocketStore(); + const toggleDrawer = (newOpen: boolean) => (event: React.KeyboardEvent | React.MouseEvent) => { + if ( + event.type === 'keydown' && + ((event as React.KeyboardEvent).key === 'Tab' || + (event as React.KeyboardEvent).key === 'Shift') + ) { + return; + } + setIsOpen(newOpen); + }; + const scrollLogToBottom = () => { if (logEndRef.current) { - logEndRef.current.scrollIntoView({ behavior: "smooth" }) + logEndRef.current.scrollIntoView({ behavior: "smooth" }); } - } + }; const handleLog = useCallback((msg: string, date: boolean = true) => { if (!date) { @@ -33,14 +60,20 @@ export const InterpretationLog = () => { setLog((prevState) => prevState + '\n' + `[${new Date().toLocaleString()}] ` + msg); } scrollLogToBottom(); - }, [log, scrollLogToBottom]) + }, [log, scrollLogToBottom]); - const handleSerializableCallback = useCallback((data: string) => { + const handleSerializableCallback = useCallback((data: any) => { setLog((prevState) => prevState + '\n' + '---------- Serializable output data received ----------' + '\n' + JSON.stringify(data, null, 2) + '\n' + '--------------------------------------------------'); + + // Set table data + if (Array.isArray(data)) { + setTableData(data); + } + scrollLogToBottom(); - }, [log, scrollLogToBottom]) + }, [log, scrollLogToBottom]); const handleBinaryCallback = useCallback(({ data, mimetype }: any) => { setLog((prevState) => @@ -48,7 +81,15 @@ export const InterpretationLog = () => { + `mimetype: ${mimetype}` + '\n' + `data: ${JSON.stringify(data)}` + '\n' + '------------------------------------------------'); scrollLogToBottom(); - }, [log, scrollLogToBottom]) + }, [log, scrollLogToBottom]); + + const handleRadioChange = (event: React.ChangeEvent) => { + setSelectedOption(event.target.value); + }; + + const handleCustomValueChange = (event: React.ChangeEvent) => { + setCustomValue(event.target.value); + }; useEffect(() => { socket?.on('log', handleLog); @@ -58,41 +99,113 @@ export const InterpretationLog = () => { socket?.off('log', handleLog); socket?.off('serializableCallback', handleSerializableCallback); socket?.off('binaryCallback', handleBinaryCallback); - } - }, [socket, handleLog]) + }; + }, [socket, handleLog, handleSerializableCallback, handleBinaryCallback]); + // Extract columns dynamically from the first item of tableData + const columns = tableData.length > 0 ? Object.keys(tableData[0]) : []; + return (
- - } - aria-controls="panel1bh-content" - id="panel1bh-header" - > - - Interpretation Log - - - -
- - {log} - -
+ Interpretation Log + + + + Output Data Preview + +
+ {/* + {log} + */} + {tableData.length > 0 && ( + + + + + {columns.map((column) => ( + {column} + ))} + + + + {tableData.map((row, index) => ( + + {columns.map((column) => ( + {row[column]} + ))} + + ))} + +
+
+ )} +
+ + +

What is the maximum number of rows you want to extract?

+
+ + } label="10" /> + } label="100" /> + } label="Custom" /> + {selectedOption === 'custom' && ( + + )} + +
+
+

How can we find the next item?

+

Select and review the pagination setting this webpage is using

+ +
- - +
+
+
); } diff --git a/src/components/organisms/BrowserContent.tsx b/src/components/organisms/BrowserContent.tsx index 3b489968..21c337f5 100644 --- a/src/components/organisms/BrowserContent.tsx +++ b/src/components/organisms/BrowserContent.tsx @@ -123,7 +123,8 @@ export const BrowserContent = () => { tabIndex={tabIndex} /> diff --git a/src/components/organisms/BrowserWindow.tsx b/src/components/organisms/BrowserWindow.tsx index 6a8cd800..7ae888a1 100644 --- a/src/components/organisms/BrowserWindow.tsx +++ b/src/components/organisms/BrowserWindow.tsx @@ -50,17 +50,19 @@ const getAttributeOptions = (tagName: string, elementInfo: ElementInfo | null): export const BrowserWindow = () => { const [canvasRef, setCanvasReference] = useState | undefined>(undefined); const [screenShot, setScreenShot] = useState(""); - const [highlighterData, setHighlighterData] = useState<{ rect: DOMRect, selector: string, elementInfo: ElementInfo | null; } | null>(null); + const [highlighterData, setHighlighterData] = useState<{ rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[] } | null>(null); const [showAttributeModal, setShowAttributeModal] = useState(false); const [attributeOptions, setAttributeOptions] = useState([]); const [selectedElement, setSelectedElement] = useState<{ selector: string, info: ElementInfo | null } | null>(null); + const [currentListId, setCurrentListId] = useState(null); const [listSelector, setListSelector] = useState(null); const [fields, setFields] = useState>({}); + const [paginationSelector, setPaginationSelector] = useState(''); const { socket } = useSocketStore(); const { width, height } = useBrowserDimensionsStore(); - const { getText, getList } = useActionContext(); + const { getText, getList, paginationMode, paginationType } = useActionContext(); const { addTextStep, addListStep } = useBrowserSteps(); const onMouseMove = (e: MouseEvent) => { @@ -78,6 +80,18 @@ export const BrowserWindow = () => { } }; + const resetListState = useCallback(() => { + setListSelector(null); + setFields({}); + setCurrentListId(null); + }, []); + + useEffect(() => { + if (!getList) { + resetListState(); + } + }, [getList, resetListState]); + const screencastHandler = useCallback((data: string) => { setScreenShot(data); }, [screenShot]); @@ -96,12 +110,33 @@ export const BrowserWindow = () => { } }, [screenShot, canvasRef, socket, screencastHandler]); - const highlighterHandler = useCallback((data: { rect: DOMRect, selector: string, elementInfo: ElementInfo | null }) => { + const highlighterHandler = useCallback((data: { rect: DOMRect, selector: string, elementInfo: ElementInfo | null, childSelectors?: string[] }) => { if (getList === true) { socket?.emit('setGetList', { getList: true }); + if (listSelector) { + socket?.emit('listSelector', { selector: listSelector }); + if (paginationMode) { + // Pagination mode: only set highlighterData if type is not empty, 'none', 'scrollDown', or 'scrollUp' + if (paginationType !== '' && paginationType !== 'scrollDown' && paginationType !== 'scrollUp' && paginationType !== 'none') { + setHighlighterData(data); + } else { + setHighlighterData(null); + } + } else if (data.childSelectors && data.childSelectors.includes(data.selector)) { + // !Pagination mode: highlight only valid child elements within the listSelector + setHighlighterData(data); + } else { + // If not a valid child in normal mode, clear the highlighter + setHighlighterData(null); + } + } else { + setHighlighterData(data); // Set highlighterData for the initial listSelector selection + } + } else { + setHighlighterData(data); // For non-list steps } - setHighlighterData(data); - }, [highlighterData, getList, socket]); + }, [highlighterData, getList, socket, listSelector, paginationMode, paginationType]); + useEffect(() => { document.addEventListener('mousemove', onMouseMove, false); @@ -127,6 +162,7 @@ export const BrowserWindow = () => { clickY >= highlightRect.top && clickY <= highlightRect.bottom ) { + const options = getAttributeOptions(highlighterData.elementInfo?.tagName || '', highlighterData.elementInfo); if (getText === true) { @@ -153,17 +189,32 @@ export const BrowserWindow = () => { } } + if (paginationMode && getList) { + // Only allow selection in pagination mode if type is not empty, 'scrollDown', or 'scrollUp' + if (paginationType !== '' && paginationType !== 'scrollDown' && paginationType !== 'scrollUp' && paginationType !== 'none') { + setPaginationSelector(highlighterData.selector); + addListStep(listSelector!, fields, currentListId || 0, { type: paginationType, selector: highlighterData.selector }); + } + return; + } + if (getList === true && !listSelector) { setListSelector(highlighterData.selector); - } else if (getList === true && listSelector) { + setCurrentListId(Date.now()); + setFields({}); + } else if (getList === true && listSelector && currentListId) { + const attribute = options[0].value; + const data = attribute === 'href' ? highlighterData.elementInfo?.url || '' : + attribute === 'src' ? highlighterData.elementInfo?.imageUrl || '' : + highlighterData.elementInfo?.innerText || ''; + // Add fields to the list if (options.length === 1) { - // Handle directly without showing the modal const attribute = options[0].value; const newField: TextStep = { id: Date.now(), type: 'text', label: `Label ${Object.keys(fields).length + 1}`, - data: highlighterData.elementInfo?.innerText || '', + data: data, selectorObj: { selector: highlighterData.selector, tag: highlighterData.elementInfo?.tagName, @@ -176,14 +227,15 @@ export const BrowserWindow = () => { ...prevFields, [newField.label]: newField }; + console.log(updatedFields) return updatedFields; }); if (listSelector) { - addListStep(listSelector, { ...fields, [newField.label]: newField }); + addListStep(listSelector, { ...fields, [newField.label]: newField }, currentListId, { type: '', selector: paginationSelector }); } + } else { - // Show the modal if there are multiple options setAttributeOptions(options); setSelectedElement({ selector: highlighterData.selector, @@ -217,12 +269,12 @@ export const BrowserWindow = () => { attribute: attribute }); } - if (getList === true) { + if (getList === true && listSelector && currentListId) { const newField: TextStep = { id: Date.now(), type: 'text', label: `Label ${Object.keys(fields).length + 1}`, - data: selectedElement.info?.innerText || '', + data: data, selectorObj: { selector: selectedElement.selector, tag: selectedElement.info?.tagName, @@ -235,18 +287,32 @@ export const BrowserWindow = () => { ...prevFields, [newField.label]: newField }; + console.log(updatedFields) + return updatedFields; }); if (listSelector) { - addListStep(listSelector, { ...fields, [newField.label]: newField }); + addListStep(listSelector, { ...fields, [newField.label]: newField }, currentListId, { type: '', selector: paginationSelector }); } + } } } setShowAttributeModal(false); }; + const resetPaginationSelector = useCallback(() => { + setPaginationSelector(''); + }, []); + + useEffect(() => { + if (!paginationMode) { + resetPaginationSelector(); + } + }, [paginationMode, resetPaginationSelector]); + + return (
{ diff --git a/src/components/organisms/LeftSidePanel.tsx b/src/components/organisms/LeftSidePanel.tsx index 164dc38c..09c5fc46 100644 --- a/src/components/organisms/LeftSidePanel.tsx +++ b/src/components/organisms/LeftSidePanel.tsx @@ -14,21 +14,21 @@ import { RunSettings } from "../molecules/RunSettings"; const fetchWorkflow = (id: string, callback: (response: WorkflowFile) => void) => { getActiveWorkflow(id).then( - (response ) => { - if (response){ + (response) => { + if (response) { callback(response); } else { throw new Error("No workflow found"); } } - ).catch((error) => {console.log(error.message)}) + ).catch((error) => { console.log(error.message) }) }; interface LeftSidePanelProps { sidePanelRef: HTMLDivElement | null; alreadyHasScrollbar: boolean; recordingName: string; - handleSelectPairForEdit: (pair:WhereWhatPair, index:number) => void; + handleSelectPairForEdit: (pair: WhereWhatPair, index: number) => void; } export const LeftSidePanel = ( @@ -59,10 +59,11 @@ export const LeftSidePanel = ( fetchWorkflow(id, workflowHandler); } // fetch workflow in 15min intervals - let interval = setInterval(() =>{ - if (id) { - fetchWorkflow(id, workflowHandler); - }}, (1000 * 60 * 15)); + let interval = setInterval(() => { + if (id) { + fetchWorkflow(id, workflowHandler); + } + }, (1000 * 60 * 15)); return () => clearInterval(interval) }, [id]); @@ -104,19 +105,19 @@ export const LeftSidePanel = ( flexDirection: 'column', }} > - + - setTab(newTab)}> - - { - getParamsOfActiveWorkflow(id).then((response) => { - if (response) { - setParams(response); - } - }) - }}/> - - + setTab(newTab)}> + + { + getParamsOfActiveWorkflow(id).then((response) => { + if (response) { + setParams(response); + } + }) + }} /> + + + settings={settings} setSettings={setSettings} /> diff --git a/src/components/organisms/RightSidePanel.tsx b/src/components/organisms/RightSidePanel.tsx index 9a9fff51..d73d7ff2 100644 --- a/src/components/organisms/RightSidePanel.tsx +++ b/src/components/organisms/RightSidePanel.tsx @@ -3,29 +3,40 @@ import { Button, Paper, Box, TextField } from "@mui/material"; import EditIcon from '@mui/icons-material/Edit'; import TextFieldsIcon from '@mui/icons-material/TextFields'; import DocumentScannerIcon from '@mui/icons-material/DocumentScanner'; -import styled from "styled-components"; import { SimpleBox } from "../atoms/Box"; import Typography from "@mui/material/Typography"; import { useGlobalInfoStore } from "../../context/globalInfo"; -import { useActionContext } from '../../context/browserActions'; -import { useBrowserSteps, ListStep, TextStep, SelectorObject } from '../../context/browserSteps'; +import { PaginationType, useActionContext, LimitType } from '../../context/browserActions'; +import { useBrowserSteps } from '../../context/browserSteps'; import { useSocketStore } from '../../context/socket'; import { ScreenshotSettings } from '../../shared/types'; import InputAdornment from '@mui/material/InputAdornment'; +import { SidePanelHeader } from '../molecules/SidePanelHeader'; +import FormControlLabel from '@mui/material/FormControlLabel'; +import FormControl from '@mui/material/FormControl'; +import FormLabel from '@mui/material/FormLabel'; +import Radio from '@mui/material/Radio'; +import RadioGroup from '@mui/material/RadioGroup'; // TODO: // 1. Handle field label update // 2. Handle field deletion | confirmation // 3. Add description for each browser step // 4. Handle non custom action steps +interface RightSidePanelProps { + onFinishCapture: () => void; +} -export const RightSidePanel = () => { +export const RightSidePanel: React.FC = ({ onFinishCapture }) => { const [textLabels, setTextLabels] = useState<{ [id: number]: string }>({}); const [errors, setErrors] = useState<{ [id: number]: string }>({}); const [confirmedTextSteps, setConfirmedTextSteps] = useState<{ [id: number]: boolean }>({}); + const [showPaginationOptions, setShowPaginationOptions] = useState(false); + const [showLimitOptions, setShowLimitOptions] = useState(false); + const [captureStage, setCaptureStage] = useState<'initial' | 'pagination' | 'limit' | 'complete'>('initial'); const { lastAction, notify } = useGlobalInfoStore(); - const { getText, startGetText, stopGetText, getScreenshot, startGetScreenshot, stopGetScreenshot, getList, startGetList, stopGetList } = useActionContext(); + const { getText, startGetText, stopGetText, getScreenshot, startGetScreenshot, stopGetScreenshot, paginationMode, getList, startGetList, stopGetList, startPaginationMode, stopPaginationMode, paginationType, updatePaginationType, limitMode, limitType, customLimit, updateLimitType, updateCustomLimit, stopLimitMode, startLimitMode } = useActionContext(); const { browserSteps, updateBrowserTextStepLabel, deleteBrowserStep, addScreenshotStep } = useBrowserSteps(); const { socket } = useSocketStore(); @@ -83,11 +94,17 @@ export const RightSidePanel = () => { if (hasTextSteps) { socket?.emit('action', { action: 'scrapeSchema', settings }); } + onFinishCapture(); }, [stopGetText, getTextSettingsObject, socket, browserSteps, confirmedTextSteps]); const getListSettingsObject = useCallback(() => { - let settings: { listSelector?: string; fields?: Record } = {}; + let settings: { + listSelector?: string; + fields?: Record; + pagination?: { type: string; selector?: string }; + limit?: number; + } = {}; browserSteps.forEach(step => { if (step.type === 'list' && step.listSelector && Object.keys(step.fields).length > 0) { @@ -97,36 +114,101 @@ export const RightSidePanel = () => { fields[label] = { selector: field.selectorObj.selector, tag: field.selectorObj.tag, - attribute: field.selectorObj.attribute + attribute: field.selectorObj.attribute, }; } }); settings = { listSelector: step.listSelector, - fields: fields + fields: fields, + pagination: { type: paginationType, selector: step.pagination?.selector }, + limit: parseInt(limitType === 'custom' ? customLimit : limitType), }; } }); return settings; - }, [browserSteps]); + }, [browserSteps, paginationType, limitType, customLimit]); + const resetListState = useCallback(() => { + setShowPaginationOptions(false); + updatePaginationType(''); + setShowLimitOptions(false); + updateLimitType(''); + updateCustomLimit(''); + }, [updatePaginationType, updateLimitType, updateCustomLimit]); + + const handleStopGetList = useCallback(() => { + stopGetList(); + resetListState(); + }, [stopGetList, resetListState]); const stopCaptureAndEmitGetListSettings = useCallback(() => { - stopGetList(); const settings = getListSettingsObject(); if (settings) { socket?.emit('action', { action: 'scrapeList', settings }); } else { notify('error', 'Unable to create list settings. Make sure you have defined a field for the list.'); } - }, [stopGetList, getListSettingsObject, socket, notify]); + handleStopGetList(); + onFinishCapture(); + }, [stopGetList, getListSettingsObject, socket, notify, handleStopGetList]); - // const handleListFieldChange = (stepId: number, key: 'label' | 'data', value: string) => { - // updateListStepField(stepId, key, value); - // }; + const handleConfirmListCapture = useCallback(() => { + switch (captureStage) { + case 'initial': + startPaginationMode(); + setShowPaginationOptions(true); + setCaptureStage('pagination'); + break; + + case 'pagination': + if (!paginationType) { + notify('error', 'Please select a pagination type.'); + return; + } + const settings = getListSettingsObject(); + const paginationSelector = settings.pagination?.selector; + if (['clickNext', 'clickLoadMore'].includes(paginationType) && !paginationSelector) { + notify('error', 'Please select the pagination element first.'); + return; + } + stopPaginationMode(); + setShowPaginationOptions(false); + startLimitMode(); + setShowLimitOptions(true); + setCaptureStage('limit'); + break; + + case 'limit': + if (!limitType || (limitType === 'custom' && !customLimit)) { + notify('error', 'Please select a limit or enter a custom limit.'); + return; + } + const limit = limitType === 'custom' ? parseInt(customLimit) : parseInt(limitType); + if (isNaN(limit) || limit <= 0) { + notify('error', 'Please enter a valid limit.'); + return; + } + stopLimitMode(); + setShowLimitOptions(false); + stopCaptureAndEmitGetListSettings(); + setCaptureStage('complete'); + break; + + case 'complete': + setCaptureStage('initial'); + break; + } + }, [captureStage, paginationType, limitType, customLimit, startPaginationMode, stopPaginationMode, startLimitMode, stopLimitMode, notify, stopCaptureAndEmitGetListSettings, getListSettingsObject]); + + const handlePaginationSettingSelect = (option: PaginationType) => { + updatePaginationType(option); + if (['clickNext', 'clickLoadMore'].includes(option)) { + } + }; const captureScreenshot = (fullPage: boolean) => { const screenshotSettings: ScreenshotSettings = { @@ -147,28 +229,76 @@ export const RightSidePanel = () => { Last action: {` ${lastAction}`} - + {!getText && !getScreenshot && !getList && } - {getList && + {getList && ( <> - - + + - } - + )} + {showPaginationOptions && ( + + How can we find the next list item on the page? + + + + + + + )} + {showLimitOptions && ( + + +

What is the maximum number of rows you want to extract?

+
+ updateLimitType(e.target.value as LimitType)} + sx={{ + display: 'flex', + flexDirection: 'column', + width: '500px' + }} + > + } label="10" /> + } label="100" /> +
+ } label="Custom" /> + {limitType === 'custom' && ( + updateCustomLimit(e.target.value)} + placeholder="Enter number" + sx={{ + marginLeft: '10px', + '& input': { + padding: '10px', + }, + }} + /> + )} +
+
+
+ )} {!getText && !getScreenshot && !getList && } {getText && <> - - + + } - {!getText && !getScreenshot && !getList && } {getScreenshot && ( @@ -178,7 +308,6 @@ export const RightSidePanel = () => { )}
- {browserSteps.map(step => ( diff --git a/src/context/browserActions.tsx b/src/context/browserActions.tsx index 5a7c12b5..6590913a 100644 --- a/src/context/browserActions.tsx +++ b/src/context/browserActions.tsx @@ -1,15 +1,30 @@ import React, { createContext, useContext, useState, ReactNode } from 'react'; +export type PaginationType = 'scrollDown' | 'scrollUp' | 'clickNext' | 'clickLoadMore' | 'none' | ''; +export type LimitType = '10' | '100' | 'custom' | ''; + interface ActionContextProps { getText: boolean; getList: boolean; getScreenshot: boolean; + paginationMode: boolean; + limitMode: boolean; + paginationType: PaginationType; + limitType: LimitType; + customLimit: string; + startPaginationMode: () => void; startGetText: () => void; stopGetText: () => void; startGetList: () => void; stopGetList: () => void; startGetScreenshot: () => void; stopGetScreenshot: () => void; + stopPaginationMode: () => void; + updatePaginationType: (type: PaginationType) => void; + startLimitMode: () => void; + stopLimitMode: () => void; + updateLimitType: (type: LimitType) => void; + updateCustomLimit: (limit: string) => void; } const ActionContext = createContext(undefined); @@ -18,18 +33,60 @@ export const ActionProvider = ({ children }: { children: ReactNode }) => { const [getText, setGetText] = useState(false); const [getList, setGetList] = useState(false); const [getScreenshot, setGetScreenshot] = useState(false); + const [paginationMode, setPaginationMode] = useState(false); + const [limitMode, setLimitMode] = useState(false); + const [paginationType, setPaginationType] = useState(''); + const [limitType, setLimitType] = useState(''); + const [customLimit, setCustomLimit] = useState(''); + + const updatePaginationType = (type: PaginationType) => setPaginationType(type); + const updateLimitType = (type: LimitType) => setLimitType(type); + const updateCustomLimit = (limit: string) => setCustomLimit(limit); + + const startPaginationMode = () => setPaginationMode(true); + const stopPaginationMode = () => setPaginationMode(false); + + const startLimitMode = () => setLimitMode(true); + const stopLimitMode = () => setLimitMode(false); const startGetText = () => setGetText(true); const stopGetText = () => setGetText(false); const startGetList = () => setGetList(true); - const stopGetList = () => setGetList(false); + const stopGetList = () => { + setGetList(false); + setPaginationType(''); + setLimitType(''); + setCustomLimit(''); + }; const startGetScreenshot = () => setGetScreenshot(true); const stopGetScreenshot = () => setGetScreenshot(false); return ( - + {children} ); @@ -41,4 +98,4 @@ export const useActionContext = () => { throw new Error('useActionContext must be used within an ActionProvider'); } return context; -}; +}; \ No newline at end of file diff --git a/src/context/browserSteps.tsx b/src/context/browserSteps.tsx index 59cdb546..1c9dbf34 100644 --- a/src/context/browserSteps.tsx +++ b/src/context/browserSteps.tsx @@ -19,6 +19,11 @@ export interface ListStep { type: 'list'; listSelector: string; fields: { [key: string]: TextStep }; + pagination?: { + type: string; + selector: string; + }; + limit?: number; } type BrowserStep = TextStep | ScreenshotStep | ListStep; @@ -33,7 +38,7 @@ export interface SelectorObject { interface BrowserStepsContextType { browserSteps: BrowserStep[]; addTextStep: (label: string, data: string, selectorObj: SelectorObject) => void; - addListStep: (listSelector: string, fields: { [key: string]: TextStep }) => void + addListStep: (listSelector: string, fields: { [key: string]: TextStep }, listId: number, pagination?: { type: string; selector: string }, limit?: number) => void addScreenshotStep: (fullPage: boolean) => void; deleteBrowserStep: (id: number) => void; updateBrowserTextStepLabel: (id: number, newLabel: string) => void; @@ -51,10 +56,10 @@ export const BrowserStepsProvider: React.FC<{ children: React.ReactNode }> = ({ ]); }; - const addListStep = (listSelector: string, newFields: { [key: string]: TextStep }) => { + const addListStep = (listSelector: string, newFields: { [key: string]: TextStep }, listId: number, pagination?: { type: string; selector: string }, limit?: number) => { setBrowserSteps(prevSteps => { const existingListStepIndex = prevSteps.findIndex( - step => step.type === 'list' && step.listSelector === listSelector + step => step.type === 'list' && step.id === listId ); if (existingListStepIndex !== -1) { // Update the existing ListStep with new fields @@ -62,20 +67,21 @@ export const BrowserStepsProvider: React.FC<{ children: React.ReactNode }> = ({ const existingListStep = updatedSteps[existingListStepIndex] as ListStep; updatedSteps[existingListStepIndex] = { ...existingListStep, - fields: { ...existingListStep.fields, ...newFields } + fields: { ...existingListStep.fields, ...newFields }, + pagination: pagination, + limit: limit, }; return updatedSteps; } else { // Create a new ListStep return [ ...prevSteps, - { id: Date.now(), type: 'list', listSelector, fields: newFields } + { id: listId, type: 'list', listSelector, fields: newFields, pagination, limit } ]; } }); }; - const addScreenshotStep = (fullPage: boolean) => { setBrowserSteps(prevSteps => [ ...prevSteps, diff --git a/src/pages/PageWrappper.tsx b/src/pages/PageWrappper.tsx index 686d4cd4..2cbf3102 100644 --- a/src/pages/PageWrappper.tsx +++ b/src/pages/PageWrappper.tsx @@ -7,11 +7,8 @@ import { MainPage } from "./MainPage"; import { useGlobalInfoStore } from "../context/globalInfo"; import { getActiveBrowserId } from "../api/recording"; import { AlertSnackbar } from "../components/atoms/AlertSnackbar"; -import { InterpretationLog } from "../components/molecules/InterpretationLog"; - export const PageWrapper = () => { - const [recordingName, setRecordingName] = useState(''); const [open, setOpen] = useState(false); @@ -54,7 +51,6 @@ export const PageWrapper = () => { - ) diff --git a/src/pages/RecordingPage.tsx b/src/pages/RecordingPage.tsx index fa5c3a14..8683e691 100644 --- a/src/pages/RecordingPage.tsx +++ b/src/pages/RecordingPage.tsx @@ -32,6 +32,7 @@ export const RecordingPage = ({ recordingName }: RecordingPageProps) => { pair: null, index: 0, }); + const [showOutputData, setShowOutputData] = useState(false); const browserContentRef = React.useRef(null); const workflowListRef = React.useRef(null); @@ -40,6 +41,10 @@ export const RecordingPage = ({ recordingName }: RecordingPageProps) => { const { setWidth } = useBrowserDimensionsStore(); const { browserId, setBrowserId } = useGlobalInfoStore(); + const handleShowOutputData = useCallback(() => { + setShowOutputData(true); + }, []); + const handleSelectPairForEdit = (pair: WhereWhatPair, index: number) => { setPairForEdit({ pair, @@ -47,7 +52,6 @@ export const RecordingPage = ({ recordingName }: RecordingPageProps) => { }); }; - //resize browser content when loaded event is fired useEffect(() => changeBrowserDimensions(), [isLoaded]) useEffect(() => { @@ -122,10 +126,10 @@ export const RecordingPage = ({ recordingName }: RecordingPageProps) => { - + - + : }