refactor: Modularize

This commit is contained in:
Jayden
2024-07-20 17:10:54 -05:00
parent 6082435d7f
commit 2b2de523d4
8 changed files with 399 additions and 302 deletions
+120
View File
@@ -0,0 +1,120 @@
import React, { useState, Dispatch, SetStateAction } from "react";
import {
Typography,
TextField,
Button,
Table,
TableBody,
TableContainer,
TableCell,
TableHead,
TableRow,
Box,
IconButton,
Tooltip,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import { Element } from "../../types";
interface Props {
rows: Element[];
setRows: Dispatch<SetStateAction<Element[]>>;
submittedURL: string;
}
export const ElementTable = ({ rows, setRows, submittedURL }: Props) => {
const [newRow, setNewRow] = useState<Element>({
name: "",
xpath: "",
url: "",
});
const handleAddRow = () => {
const updatedRow = { ...newRow, url: submittedURL };
setRows([...rows, updatedRow]);
setNewRow({ name: "", xpath: "", url: "" });
};
const handleDeleteRow = (elementName: string) => {
setRows(
rows.filter((r) => {
return elementName !== r.name;
})
);
};
return (
<>
<Box display="flex" gap={2} marginBottom={2} className="items-center">
<TextField
label="Name"
variant="outlined"
fullWidth
value={newRow.name}
onChange={(e) => setNewRow({ ...newRow, name: e.target.value })}
/>
<TextField
label="XPath"
variant="outlined"
fullWidth
value={newRow.xpath}
onChange={(e) => setNewRow({ ...newRow, xpath: e.target.value })}
/>
<Tooltip
title={
newRow.xpath.length > 0 && newRow.name.length > 0
? "Add Element"
: "Fill out all fields to add an element"
}
placement="top"
>
<span>
<IconButton
aria-label="add"
size="small"
onClick={handleAddRow}
sx={{ height: "40px", width: "40px" }}
disabled={!(newRow.xpath.length > 0 && newRow.name.length > 0)}
>
<AddIcon fontSize="inherit" sx={{ color: "black" }} />
</IconButton>
</span>
</Tooltip>
</Box>
<Typography variant="h4">Elements</Typography>
<TableContainer
component={Box}
sx={{ maxHeight: "50%", overflow: "auto" }}
>
<Table stickyHeader className="mb-4">
<TableHead>
<TableRow>
<TableCell>
<Typography sx={{ fontWeight: "bold" }}>Name</Typography>
</TableCell>
<TableCell>
<Typography sx={{ fontWeight: "bold" }}>XPath</Typography>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, index) => (
<TableRow key={index}>
<TableCell>
<Typography>{row.name}</Typography>
</TableCell>
<TableCell>
<Typography>{row.xpath}</Typography>
</TableCell>
<TableCell>
<Button onClick={() => handleDeleteRow(row.name)}>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</>
);
};
+171
View File
@@ -0,0 +1,171 @@
import React, { useState, useEffect, useRef, Dispatch } from "react";
import {
TextField,
Button,
Box,
Checkbox,
FormControlLabel,
Accordion,
AccordionSummary,
AccordionDetails,
} from "@mui/material";
import CircularProgress from "@mui/material/CircularProgress";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
import { Element, Result } from "../../types";
import { useAuth } from "../../contexts/AuthContext";
interface stateProps {
submittedURL: string;
setSubmittedURL: Dispatch<React.SetStateAction<string>>;
rows: Element[];
setResults: Dispatch<React.SetStateAction<Result>>;
setSnackbarMessage: Dispatch<React.SetStateAction<string>>;
setSnackbarOpen: Dispatch<React.SetStateAction<boolean>>;
}
interface Props {
stateProps: stateProps;
}
interface JobOptions {
multi_page_scrape: boolean;
custom_headers: null | Object;
}
export const JobSubmitter = ({ stateProps }: Props) => {
const { user } = useAuth();
const {
submittedURL,
setSubmittedURL,
rows,
setResults,
setSnackbarMessage,
setSnackbarOpen,
} = stateProps;
const [isValidURL, setIsValidUrl] = useState<boolean>(true);
const [urlError, setUrlError] = useState<string | null>(null);
const [loading, setLoading] = useState<boolean>(false);
const [jobOptions, setJobOptions] = useState<JobOptions>({
multi_page_scrape: false,
custom_headers: null,
});
function validateURL(url: string): boolean {
try {
new URL(url);
return true;
} catch (_) {
return false;
}
}
const handleSubmit = () => {
if (!validateURL(submittedURL)) {
setIsValidUrl(false);
setUrlError("Please enter a valid URL.");
return;
}
setIsValidUrl(true);
setUrlError(null);
setLoading(true);
fetch("/api/submit-scrape-job", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
url: submittedURL,
elements: rows,
user: user?.email,
time_created: new Date().toISOString(),
}),
})
.then((response) => {
if (!response.ok) {
return response.json().then((error) => {
throw new Error(error.error);
});
}
return response.json();
})
.then((data) => setResults(data))
.catch((error) => {
setSnackbarMessage(error.message || "An error occurred.");
setSnackbarOpen(true);
})
.finally(() => setLoading(false));
};
return (
<>
<div className="flex flex-row space-x-4 items-center mb-2">
<TextField
label="URL"
variant="outlined"
fullWidth
value={submittedURL}
onChange={(e) => setSubmittedURL(e.target.value)}
error={!isValidURL}
helperText={!isValidURL ? urlError : ""}
/>
<Button
variant="contained"
color="primary"
size="small"
onClick={handleSubmit}
disabled={!(rows.length > 0) || loading}
>
{loading ? <CircularProgress size={24} /> : "Submit"}
</Button>
</div>
<Box
bgcolor="background.paper"
className="p-2 mb-2 flex flex-row space-x-2"
>
<FormControlLabel
label="Multi-Page Scrape"
control={
<Checkbox
checked={jobOptions.multi_page_scrape}
onChange={() =>
setJobOptions((prevJobOptions) => ({
...prevJobOptions,
multi_page_scrape: !prevJobOptions.multi_page_scrape,
}))
}
/>
}
></FormControlLabel>
<Accordion style={{ padding: 1 }}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls="panel1-content"
id="panel1-header"
>
Custom Headers (JSON)
</AccordionSummary>
<AccordionDetails>
<TextField
InputLabelProps={{ shrink: false }}
fullWidth
multiline
minRows={4}
variant="outlined"
value={jobOptions.custom_headers}
onChange={(e) =>
setJobOptions((prevJobOptions) => ({
...prevJobOptions,
custom_headers: e.target.value,
}))
}
style={{ maxHeight: "20vh", overflow: "auto" }}
className="mt-2"
/>
</AccordionDetails>
</Accordion>
</Box>
</>
);
};
+69
View File
@@ -0,0 +1,69 @@
import React, { useState, useEffect, useRef } from "react";
import {
Typography,
Table,
TableBody,
TableContainer,
TableCell,
TableHead,
TableRow,
Box,
} from "@mui/material";
import { Result } from "../../types";
interface stateProps {
results: Result;
}
interface Props {
stateProps: stateProps;
resultsRef: React.MutableRefObject<HTMLElement | null>;
}
export const ResultsTable = ({ stateProps, resultsRef }: Props) => {
const { results } = stateProps;
return (
<>
{Object.keys(results).length ? (
<>
<Typography variant="h4">Results</Typography>
<TableContainer
component={Box}
ref={resultsRef}
sx={{ maxHeight: "50%", overflow: "auto", marginTop: "20px" }}
>
<Table stickyHeader>
<TableHead>
<TableRow>
<TableCell>
<Typography sx={{ fontWeight: "bold" }}>Name</Typography>
</TableCell>
<TableCell>
<Typography sx={{ fontWeight: "bold" }}>XPath</Typography>
</TableCell>
<TableCell>
<Typography sx={{ fontWeight: "bold" }}>Text</Typography>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.keys(results).map((key, index) => (
<React.Fragment key={index}>
{results[key].map((result, resultIndex) => (
<TableRow key={resultIndex}>
<TableCell>{result.name}</TableCell>
<TableCell>{result.xpath}</TableCell>
<TableCell>{result.text}</TableCell>
</TableRow>
))}
</React.Fragment>
))}
</TableBody>
</Table>
</TableContainer>
</>
) : null}
</>
);
};
+3
View File
@@ -0,0 +1,3 @@
export * from "./ElementTable";
export * from "./JobSubmitter";
export * from "./ResultsTable";
+20 -302
View File
@@ -1,90 +1,19 @@
import React, { useState, useEffect, useRef } from "react";
import {
Typography,
FormControl,
InputLabel,
Select,
MenuItem,
TextField,
Button,
Table,
TableBody,
TableContainer,
TableCell,
TableHead,
TableRow,
Container,
Box,
IconButton,
Tooltip,
Snackbar,
Alert,
Checkbox,
FormControlLabel,
Accordion,
AccordionActions,
AccordionSummary,
AccordionDetails,
} from "@mui/material";
import AddIcon from "@mui/icons-material/Add";
import { useAuth } from "../contexts/AuthContext";
import { Typography, Container, Box, Snackbar, Alert } from "@mui/material";
import { useRouter } from "next/router";
import CircularProgress from "@mui/material/CircularProgress";
import ExpandMoreIcon from "@mui/icons-material/ExpandMore";
interface Element {
name: string;
xpath: string;
url: string;
}
interface ScrapeResult {
xpath: string;
text: string;
name: string;
}
interface JobOptions {
multi_page_scrape: boolean;
custom_headers: null | Object;
}
type Result = {
[key: string]: ScrapeResult[];
};
function validateURL(url: string): boolean {
try {
new URL(url);
return true;
} catch (_) {
return false;
}
}
import { Element, Result } from "../types";
import { ElementTable, JobSubmitter, ResultsTable } from "../components/submit";
const Home = () => {
const { user } = useAuth();
const router = useRouter();
const { elements, url } = router.query;
const [submittedURL, setSubmittedURL] = useState("");
const [isValidURL, setIsValidUrl] = useState<boolean>(true);
const [urlError, setUrlError] = useState<string | null>(null);
const [rows, setRows] = useState<Element[]>([]);
const [results, setResults] = useState<null | Result>(null);
const [newRow, setNewRow] = useState<Element>({
name: "",
xpath: "",
url: "",
});
const [loading, setLoading] = useState(false);
const [results, setResults] = useState<Result>({});
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState("");
const [jobOptions, setJobOptions] = useState<JobOptions>({
multi_page_scrape: false,
custom_headers: null,
});
const resultsRef = useRef<HTMLTableElement | null>(null);
@@ -103,57 +32,6 @@ const Home = () => {
}
}, [results]);
const handleAddRow = () => {
const updatedRow = { ...newRow, url: submittedURL };
setRows([...rows, updatedRow]);
setNewRow({ name: "", xpath: "", url: "" });
};
const handleDeleteRow = (elementName: string) => {
setRows(
rows.filter((r) => {
return elementName !== r.name;
}),
);
};
const handleSubmit = () => {
if (!validateURL(submittedURL)) {
setIsValidUrl(false);
setUrlError("Please enter a valid URL.");
return;
}
setIsValidUrl(true);
setUrlError(null);
setLoading(true);
fetch("/api/submit-scrape-job", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
url: submittedURL,
elements: rows,
user: user?.email,
time_created: new Date().toISOString(),
}),
})
.then((response) => {
if (!response.ok) {
return response.json().then((error) => {
throw new Error(error.error);
});
}
return response.json();
})
.then((data) => setResults(data))
.catch((error) => {
setSnackbarMessage(error.message || "An error occurred.");
setSnackbarOpen(true);
})
.finally(() => setLoading(false));
};
const handleCloseSnackbar = () => {
setSnackbarOpen(false);
};
@@ -172,182 +50,22 @@ const Home = () => {
<Typography variant="h1" gutterBottom textAlign="center">
Scraperr
</Typography>
<div className="flex flex-row space-x-4 items-center mb-2">
<TextField
label="URL"
variant="outlined"
fullWidth
value={submittedURL}
onChange={(e) => setSubmittedURL(e.target.value)}
error={!isValidURL}
helperText={!isValidURL ? urlError : ""}
/>
<Button
variant="contained"
color="primary"
size="small"
onClick={handleSubmit}
disabled={!(rows.length > 0) || loading}
>
{loading ? <CircularProgress size={24} /> : "Submit"}
</Button>
</div>
<Box
bgcolor="background.paper"
className="p-2 mb-2 flex flex-row space-x-2"
>
<FormControlLabel
label="Multi-Page Scrape"
control={
<Checkbox
checked={jobOptions.multi_page_scrape}
onChange={() =>
setJobOptions((prevJobOptions) => ({
...prevJobOptions,
multi_page_scrape: !prevJobOptions.multi_page_scrape,
}))
}
/>
}
></FormControlLabel>
<Accordion style={{ padding: 1 }}>
<AccordionSummary
expandIcon={<ExpandMoreIcon />}
aria-controls="panel1-content"
id="panel1-header"
>
Custom Headers (JSON)
</AccordionSummary>
<AccordionDetails>
<TextField
InputLabelProps={{ shrink: false }}
fullWidth
multiline
minRows={4}
variant="outlined"
value={jobOptions.custom_headers}
onChange={(e) =>
setJobOptions((prevJobOptions) => ({
...prevJobOptions,
custom_headers: e.target.value,
}))
}
style={{ maxHeight: "20vh", overflow: "auto" }}
className="mt-2"
/>
</AccordionDetails>
</Accordion>
</Box>
<Box display="flex" gap={2} marginBottom={2} className="items-center">
<TextField
label="Name"
variant="outlined"
fullWidth
value={newRow.name}
onChange={(e) => setNewRow({ ...newRow, name: e.target.value })}
/>
<TextField
label="XPath"
variant="outlined"
fullWidth
value={newRow.xpath}
onChange={(e) => setNewRow({ ...newRow, xpath: e.target.value })}
/>
<Tooltip
title={
newRow.xpath.length > 0 && newRow.name.length > 0
? "Add Element"
: "Fill out all fields to add an element"
}
placement="top"
>
<span>
<IconButton
aria-label="add"
size="small"
onClick={handleAddRow}
sx={{ height: "40px", width: "40px" }}
disabled={!(newRow.xpath.length > 0 && newRow.name.length > 0)}
>
<AddIcon fontSize="inherit" sx={{ color: "black" }} />
</IconButton>
</span>
</Tooltip>
</Box>
<Typography variant="h4">Elements</Typography>
<TableContainer
component={Box}
sx={{ maxHeight: "50%", overflow: "auto" }}
>
<Table stickyHeader className="mb-4">
<TableHead>
<TableRow>
<TableCell>
<Typography sx={{ fontWeight: "bold" }}>Name</Typography>
</TableCell>
<TableCell>
<Typography sx={{ fontWeight: "bold" }}>XPath</Typography>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{rows.map((row, index) => (
<TableRow key={index}>
<TableCell>
<Typography>{row.name}</Typography>
</TableCell>
<TableCell>
<Typography>{row.xpath}</Typography>
</TableCell>
<TableCell>
<Button onClick={() => handleDeleteRow(row.name)}>
Delete
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
{results && (
<>
<Typography variant="h4">Results</Typography>
<TableContainer
component={Box}
ref={resultsRef}
sx={{ maxHeight: "50%", overflow: "auto", marginTop: "20px" }}
>
<Table stickyHeader>
<TableHead>
<TableRow>
<TableCell>
<Typography sx={{ fontWeight: "bold" }}>Name</Typography>
</TableCell>
<TableCell>
<Typography sx={{ fontWeight: "bold" }}>XPath</Typography>
</TableCell>
<TableCell>
<Typography sx={{ fontWeight: "bold" }}>Text</Typography>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{Object.keys(results).map((key, index) => (
<React.Fragment key={index}>
{results[key].map((result, resultIndex) => (
<TableRow key={resultIndex}>
<TableCell>{result.name}</TableCell>
<TableCell>{result.xpath}</TableCell>
<TableCell>{result.text}</TableCell>
</TableRow>
))}
</React.Fragment>
))}
</TableBody>
</Table>
</TableContainer>
</>
)}
<JobSubmitter
stateProps={{
submittedURL,
setSubmittedURL,
rows,
setResults,
setSnackbarMessage,
setSnackbarOpen,
}}
/>
<ElementTable
rows={rows}
setRows={setRows}
submittedURL={submittedURL}
/>
<ResultsTable stateProps={{ results }} resultsRef={resultsRef} />
</Container>
<Snackbar
open={snackbarOpen}
+5
View File
@@ -0,0 +1,5 @@
export interface Element {
name: string;
xpath: string;
url: string;
}
+2
View File
@@ -0,0 +1,2 @@
export * from "./element";
export * from "./result";
+9
View File
@@ -0,0 +1,9 @@
interface ScrapeResult {
xpath: string;
text: string;
name: string;
}
export type Result = {
[key: string]: ScrapeResult[];
};