Prepared for Vite Migration
This commit is contained in:
parent
468437b5d0
commit
d46be86f68
14 changed files with 392 additions and 236 deletions
147
src/Navbar.jsx
Normal file
147
src/Navbar.jsx
Normal file
|
@ -0,0 +1,147 @@
|
||||||
|
import { useContext, useState } from "react";
|
||||||
|
import StoreContext from "./ctx/StoreContext.jsx";
|
||||||
|
import JobContext from "./ctx/JobContext.jsx";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Routes,
|
||||||
|
Route,
|
||||||
|
Link,
|
||||||
|
BrowserRouter,
|
||||||
|
Navigate,
|
||||||
|
useLocation,
|
||||||
|
} from "react-router-dom";
|
||||||
|
import AppBar from "@mui/material/AppBar";
|
||||||
|
import Badge, { BadgeProps } from "@mui/material/Badge";
|
||||||
|
import { styled } from "@mui/material/styles";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Toolbar from "@mui/material/Toolbar";
|
||||||
|
import IconButton from "@mui/material/IconButton";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import MenuIcon from "@mui/icons-material/Menu";
|
||||||
|
import Avatar from "@mui/material/Avatar";
|
||||||
|
import Drawer from "@mui/material/Drawer";
|
||||||
|
import ListItemIcon from "@mui/material/ListItemIcon";
|
||||||
|
import ListItemText from "@mui/material/ListItemText";
|
||||||
|
import List from "@mui/material/List";
|
||||||
|
import ListItemButton from "@mui/material/ListItemButton";
|
||||||
|
import NotificationsIcon from "@mui/icons-material/Notifications";
|
||||||
|
import WorkIcon from "@mui/icons-material/Work";
|
||||||
|
import FormatListBulletedIcon from "@mui/icons-material/FormatListBulleted";
|
||||||
|
import SettingsIcon from "@mui/icons-material/Settings";
|
||||||
|
import WarningIcon from "@mui/icons-material/Warning";
|
||||||
|
import InfoIcon from "@mui/icons-material/Info";
|
||||||
|
// Import Pages
|
||||||
|
import Failing from "./views/Failing.jsx";
|
||||||
|
import Alerting from "./views/Alerting.jsx";
|
||||||
|
import Jobs from "./views/Jobs.jsx";
|
||||||
|
import Catalog from "./views/Catalog.jsx";
|
||||||
|
import Settings from "./views/Settings.jsx";
|
||||||
|
import About from "./views/About.jsx";
|
||||||
|
|
||||||
|
const drawerWidth = 240;
|
||||||
|
|
||||||
|
export default function Navbar(props) {
|
||||||
|
const { state: jobState } = useContext(JobContext);
|
||||||
|
const { state: store } = useContext(StoreContext);
|
||||||
|
const { inModal } = props;
|
||||||
|
const pages = store.pages;
|
||||||
|
const icons = [
|
||||||
|
<Badge badgeContent={store.failing.length} color="error">
|
||||||
|
<WarningIcon />
|
||||||
|
</Badge>,
|
||||||
|
<NotificationsIcon />,
|
||||||
|
<Badge badgeContent={jobState.jobs.length} color="primary">
|
||||||
|
<WorkIcon />
|
||||||
|
</Badge>,
|
||||||
|
<FormatListBulletedIcon />,
|
||||||
|
<SettingsIcon />,
|
||||||
|
<InfoIcon />,
|
||||||
|
];
|
||||||
|
|
||||||
|
const location = useLocation();
|
||||||
|
const [drawerOpen, setDrawer] = useState(false);
|
||||||
|
|
||||||
|
const toggleDrawer = () => setDrawer(!drawerOpen);
|
||||||
|
const closeDrawer = () => setDrawer(false);
|
||||||
|
|
||||||
|
const reloadPage = () => window.location.reload(false);
|
||||||
|
|
||||||
|
const SideBadge = styled(Badge)(({ theme }) => ({
|
||||||
|
"& .MuiBadge-badge": {
|
||||||
|
right: -6,
|
||||||
|
top: 10,
|
||||||
|
padding: "0 4px",
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const navHeader = () => {
|
||||||
|
const pathStr =
|
||||||
|
location.pathname.charAt(1).toUpperCase() + location.pathname.slice(2);
|
||||||
|
if (location.pathname !== "/failing") return pathStr;
|
||||||
|
return (
|
||||||
|
<SideBadge
|
||||||
|
badgeContent={store.failing.length}
|
||||||
|
color="error"
|
||||||
|
overlap="circular"
|
||||||
|
>
|
||||||
|
{pathStr}
|
||||||
|
</SideBadge>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const drawerIndex = (isDrawer) => (theme) =>
|
||||||
|
theme.zIndex.modal + 2 - (isDrawer ? 1 : 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AppBar position="fixed" sx={{ bgcolor: "black", zIndex: drawerIndex() }}>
|
||||||
|
<Box sx={{ flexGrow: 1, margin: "0 20px" }}>
|
||||||
|
<Toolbar disableGutters>
|
||||||
|
<IconButton
|
||||||
|
size="large"
|
||||||
|
edge="start"
|
||||||
|
color="inherit"
|
||||||
|
aria-label="menu"
|
||||||
|
sx={{ mr: 2 }}
|
||||||
|
onClick={toggleDrawer}
|
||||||
|
>
|
||||||
|
<MenuIcon />
|
||||||
|
</IconButton>
|
||||||
|
<Drawer
|
||||||
|
open={drawerOpen}
|
||||||
|
onClose={closeDrawer}
|
||||||
|
sx={{ zIndex: drawerIndex(true) }}
|
||||||
|
>
|
||||||
|
<Toolbar />
|
||||||
|
<Box sx={{ width: 250, overflow: "auto" }} role="presentation">
|
||||||
|
<List>
|
||||||
|
{pages.map((text, index) => (
|
||||||
|
<ListItemButton
|
||||||
|
key={text}
|
||||||
|
component={Link}
|
||||||
|
to={"/" + text}
|
||||||
|
selected={location.pathname === "/" + text}
|
||||||
|
onClick={closeDrawer}
|
||||||
|
>
|
||||||
|
<ListItemIcon>{icons[index]}</ListItemIcon>
|
||||||
|
<ListItemText
|
||||||
|
primary={text.charAt(0).toUpperCase() + text.slice(1)}
|
||||||
|
/>
|
||||||
|
</ListItemButton>
|
||||||
|
))}
|
||||||
|
</List>
|
||||||
|
</Box>
|
||||||
|
</Drawer>
|
||||||
|
<Typography variant="h6" noWrap component="div" sx={{ flexGrow: 1 }}>
|
||||||
|
{navHeader()}
|
||||||
|
</Typography>
|
||||||
|
<Avatar
|
||||||
|
style={{ cursor: "pointer" }}
|
||||||
|
alt="QA"
|
||||||
|
src="/assets/QA.png"
|
||||||
|
onClick={reloadPage}
|
||||||
|
/>
|
||||||
|
</Toolbar>
|
||||||
|
</Box>
|
||||||
|
</AppBar>
|
||||||
|
);
|
||||||
|
}
|
102
src/Views.jsx
102
src/Views.jsx
|
@ -1,6 +1,7 @@
|
||||||
import { useContext, useState } from "react";
|
import { useContext, useState } from "react";
|
||||||
import StoreContext from "./ctx/StoreContext.jsx";
|
import StoreContext from "./ctx/StoreContext.jsx";
|
||||||
import JobContext from "./ctx/JobContext.jsx";
|
import JobContext from "./ctx/JobContext.jsx";
|
||||||
|
import Navbar from "./Navbar.jsx";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Routes,
|
Routes,
|
||||||
|
@ -44,106 +45,9 @@ export default function Views() {
|
||||||
const { state: jobState } = useContext(JobContext);
|
const { state: jobState } = useContext(JobContext);
|
||||||
const { state: store } = useContext(StoreContext);
|
const { state: store } = useContext(StoreContext);
|
||||||
|
|
||||||
const pages = ["failing", "alerting", "jobs", "catalog", "settings", "about"];
|
|
||||||
const icons = [
|
|
||||||
<Badge badgeContent={store.failing.length} color="error">
|
|
||||||
<WarningIcon />
|
|
||||||
</Badge>,
|
|
||||||
<NotificationsIcon />,
|
|
||||||
<Badge badgeContent={jobState.jobs.length} color="primary">
|
|
||||||
<WorkIcon />
|
|
||||||
</Badge>,
|
|
||||||
<FormatListBulletedIcon />,
|
|
||||||
<SettingsIcon />,
|
|
||||||
<InfoIcon />,
|
|
||||||
];
|
|
||||||
|
|
||||||
const location = useLocation();
|
|
||||||
const [drawerOpen, setDrawer] = useState(false);
|
|
||||||
|
|
||||||
const toggleDrawer = () => setDrawer(!drawerOpen);
|
|
||||||
const closeDrawer = () => setDrawer(false);
|
|
||||||
|
|
||||||
const reloadPage = () => window.location.reload(false);
|
|
||||||
|
|
||||||
const SideBadge = styled(Badge)(({ theme }) => ({
|
|
||||||
"& .MuiBadge-badge": {
|
|
||||||
right: -6,
|
|
||||||
top: 10,
|
|
||||||
padding: "0 4px",
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
const navHeader = () => {
|
|
||||||
const pathStr =
|
|
||||||
location.pathname.charAt(1).toUpperCase() + location.pathname.slice(2);
|
|
||||||
if (location.pathname !== "/failing") return pathStr;
|
|
||||||
return (
|
|
||||||
<SideBadge
|
|
||||||
badgeContent={store.failing.length}
|
|
||||||
color="error"
|
|
||||||
overlap="circular"
|
|
||||||
>
|
|
||||||
{pathStr}
|
|
||||||
</SideBadge>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="view">
|
<div className="view">
|
||||||
<AppBar
|
<Navbar />
|
||||||
position="fixed"
|
|
||||||
sx={{ bgcolor: "black", zIndex: (theme) => theme.zIndex.drawer + 1 }}
|
|
||||||
>
|
|
||||||
<Box sx={{ flexGrow: 1, margin: "0 20px" }}>
|
|
||||||
<Toolbar disableGutters>
|
|
||||||
<IconButton
|
|
||||||
size="large"
|
|
||||||
edge="start"
|
|
||||||
color="inherit"
|
|
||||||
aria-label="menu"
|
|
||||||
sx={{ mr: 2 }}
|
|
||||||
onClick={toggleDrawer}
|
|
||||||
>
|
|
||||||
<MenuIcon />
|
|
||||||
</IconButton>
|
|
||||||
<Drawer open={drawerOpen} onClose={closeDrawer}>
|
|
||||||
<Toolbar />
|
|
||||||
<Box sx={{ width: 250, overflow: "auto" }} role="presentation">
|
|
||||||
<List>
|
|
||||||
{pages.map((text, index) => (
|
|
||||||
<ListItemButton
|
|
||||||
key={text}
|
|
||||||
component={Link}
|
|
||||||
to={"/" + text}
|
|
||||||
selected={location.pathname === "/" + text}
|
|
||||||
onClick={closeDrawer}
|
|
||||||
>
|
|
||||||
<ListItemIcon>{icons[index]}</ListItemIcon>
|
|
||||||
<ListItemText
|
|
||||||
primary={text.charAt(0).toUpperCase() + text.slice(1)}
|
|
||||||
/>
|
|
||||||
</ListItemButton>
|
|
||||||
))}
|
|
||||||
</List>
|
|
||||||
</Box>
|
|
||||||
</Drawer>
|
|
||||||
<Typography
|
|
||||||
variant="h6"
|
|
||||||
noWrap
|
|
||||||
component="div"
|
|
||||||
sx={{ flexGrow: 1 }}
|
|
||||||
>
|
|
||||||
{navHeader()}
|
|
||||||
</Typography>
|
|
||||||
<Avatar
|
|
||||||
alt="Remy Sharp"
|
|
||||||
src="/assets/QA.png"
|
|
||||||
onClick={reloadPage}
|
|
||||||
/>
|
|
||||||
</Toolbar>
|
|
||||||
</Box>
|
|
||||||
</AppBar>
|
|
||||||
|
|
||||||
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
|
<Box component="main" sx={{ flexGrow: 1, p: 3 }}>
|
||||||
<Toolbar />
|
<Toolbar />
|
||||||
|
@ -153,7 +57,7 @@ export default function Views() {
|
||||||
<Route path="/alerting" element={<Alerting />} />
|
<Route path="/alerting" element={<Alerting />} />
|
||||||
<Route path="/jobs" element={<Jobs />} />
|
<Route path="/jobs" element={<Jobs />} />
|
||||||
<Route path="/catalog" element={<Catalog />} />
|
<Route path="/catalog" element={<Catalog />} />
|
||||||
<Route path="/settings" element={<Settings pages={pages} />} />
|
<Route path="/settings" element={<Settings />} />
|
||||||
<Route path="/about" element={<About />} />
|
<Route path="/about" element={<About />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
@ -100,8 +100,13 @@ export const JobProvider = ({ children }) => {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function jobBuilder() {
|
function jobBuilder(tests) {
|
||||||
mockRun();
|
if (!Array.isArray(tests)) throw Error("Error from within JobContext.jsx");
|
||||||
|
console.log("Would run tests", tests);
|
||||||
|
}
|
||||||
|
|
||||||
|
function retrySingle(test) {
|
||||||
|
console.log("Would retry test", test);
|
||||||
}
|
}
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
|
@ -111,6 +116,8 @@ export const JobProvider = ({ children }) => {
|
||||||
jobCreate,
|
jobCreate,
|
||||||
jobDelete,
|
jobDelete,
|
||||||
retryAll,
|
retryAll,
|
||||||
|
retrySingle,
|
||||||
|
jobBuilder,
|
||||||
activeJobStates,
|
activeJobStates,
|
||||||
};
|
};
|
||||||
const contextValue = useMemo(() => context, [state, dispatch]);
|
const contextValue = useMemo(() => context, [state, dispatch]);
|
||||||
|
|
|
@ -23,16 +23,16 @@ const catalogMock = new Array(10).fill(0).map((v, i) => ({
|
||||||
type: i % 3 ? "api" : "ui",
|
type: i % 3 ? "api" : "ui",
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const failingMock = new Array(10).fill(0).map((v, i) => ({
|
const failingMock = new Array(12).fill(0).map((v, i) => ({
|
||||||
class: `SomeTestClass${i % 2 ? i - 1 : i / 2}`,
|
class: `SomeTestClass${i % 2 ? i - 1 : i / 2}`,
|
||||||
name: `TestThatDoesOneThing${i + 1}`,
|
name: `TestThatDoesOneThing${i + 1}`,
|
||||||
timestamp: `2022-05-10T16:${2 + i}:33.810Z`,
|
timestamp: `2022-05-10T16:0${i < 10 ? i : i - 10}:33.810Z`,
|
||||||
method: `SomeMethod`,
|
method: `SomeMethod`,
|
||||||
silencedUntil: i % 4 ? null : `2022-05-10T16:${2 + i}:33.810Z`,
|
silencedUntil: i % 4 ? null : `2022-05-10T16:0${i}:33.810Z`,
|
||||||
frequency: "1hour",
|
frequency: "1hour",
|
||||||
type: i % 3 ? "api" : "ui",
|
type: i % 3 ? "api" : "ui",
|
||||||
dailyFails: i + 1,
|
dailyFails: i + 1,
|
||||||
screenshot: "https://example.com",
|
screenshot: "https://picsum.photos/1920/1080",
|
||||||
recentResults: [1, 0, 0, 1, 0],
|
recentResults: [1, 0, 0, 1, 0],
|
||||||
isCompound: i % 5 ? false : true,
|
isCompound: i % 5 ? false : true,
|
||||||
failedMessage: `Some Test FailureMessage ${i}`,
|
failedMessage: `Some Test FailureMessage ${i}`,
|
||||||
|
@ -57,6 +57,7 @@ const failingMock = new Array(10).fill(0).map((v, i) => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const initialState = {
|
const initialState = {
|
||||||
|
pages: ["failing", "alerting", "jobs", "catalog", "settings", "about"],
|
||||||
intervals: [],
|
intervals: [],
|
||||||
catalog: catalogMock,
|
catalog: catalogMock,
|
||||||
failing: failingMock,
|
failing: failingMock,
|
||||||
|
@ -85,14 +86,28 @@ export const StoreProvider = ({ children }) => {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
|
|
||||||
function silenceRequest(silenceInfo) {
|
function silenceRequest(silenceInfo) {
|
||||||
const req = {name: silenceInfo.name ?? "*", class: silenceInfo.class ?? "*", method: silenceInfo.method ?? "*", silencedUntil: silenceInfo.silencedUntil };
|
const req = {
|
||||||
|
name: silenceInfo.name ?? "*",
|
||||||
|
class: silenceInfo.class ?? "*",
|
||||||
|
method: silenceInfo.method ?? "*",
|
||||||
|
silencedUntil: silenceInfo.silencedUntil,
|
||||||
|
};
|
||||||
console.log("Would upsert silence", req);
|
console.log("Would upsert silence", req);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeFailure(failure) {
|
||||||
|
const req = {
|
||||||
|
name: failure.name,
|
||||||
|
class: failure.class,
|
||||||
|
};
|
||||||
|
console.log("Would remove failure", req);
|
||||||
|
}
|
||||||
|
|
||||||
const context = {
|
const context = {
|
||||||
state,
|
state,
|
||||||
dispatch,
|
dispatch,
|
||||||
silenceRequest,
|
silenceRequest,
|
||||||
|
removeFailure,
|
||||||
updateStore: (store) => dispatch({ type: ACTIONS.UPDATE, store }),
|
updateStore: (store) => dispatch({ type: ACTIONS.UPDATE, store }),
|
||||||
};
|
};
|
||||||
const contextValue = useMemo(() => context, [state, dispatch]);
|
const contextValue = useMemo(() => context, [state, dispatch]);
|
||||||
|
|
|
@ -42,13 +42,6 @@ export default function Failing() {
|
||||||
setSilenceEntry({ ...silence, open: true });
|
setSilenceEntry({ ...silence, open: true });
|
||||||
};
|
};
|
||||||
|
|
||||||
/* TODO
|
|
||||||
for(var j of activeJobStates()){
|
|
||||||
const failingTest = failing.find((f)=>f.name===j.testName);
|
|
||||||
if(!failingTest) continue;
|
|
||||||
failingTest.jobStatus= j.status;
|
|
||||||
}*/
|
|
||||||
|
|
||||||
const [retryAllOpen, setRetryAllOpen] = useState(false);
|
const [retryAllOpen, setRetryAllOpen] = useState(false);
|
||||||
const retryAllClick = () => setRetryAllOpen(!retryAllOpen);
|
const retryAllClick = () => setRetryAllOpen(!retryAllOpen);
|
||||||
|
|
||||||
|
|
|
@ -8,7 +8,7 @@ import Button from "@mui/material/Button";
|
||||||
import Dialog from "@mui/material/Dialog";
|
import Dialog from "@mui/material/Dialog";
|
||||||
import DialogActions from "@mui/material/DialogActions";
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import Toolbar from "@mui/material/Toolbar";
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
|
||||||
import ClickAwayListener from "@mui/material/ClickAwayListener";
|
import ClickAwayListener from "@mui/material/ClickAwayListener";
|
||||||
|
@ -24,8 +24,7 @@ export default function Jobs() {
|
||||||
const {
|
const {
|
||||||
state: jobState,
|
state: jobState,
|
||||||
dispatch: jobDispatch,
|
dispatch: jobDispatch,
|
||||||
jobUpdate,
|
jobBuilder,
|
||||||
jobCreate,
|
|
||||||
} = useContext(JobContext);
|
} = useContext(JobContext);
|
||||||
|
|
||||||
const { state: store, updateStore } = useContext(StoreContext);
|
const { state: store, updateStore } = useContext(StoreContext);
|
||||||
|
@ -37,21 +36,25 @@ export default function Jobs() {
|
||||||
{ name: "Compound", icon: <ViewColumnIcon /> },
|
{ name: "Compound", icon: <ViewColumnIcon /> },
|
||||||
{ name: "Manual", icon: <PageviewIcon /> },
|
{ name: "Manual", icon: <PageviewIcon /> },
|
||||||
];
|
];
|
||||||
|
|
||||||
const quickOpenClick = (e) => {
|
const quickOpenClick = (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
if(!store.simplifiedControls) return setQuickOpen(!quickOpen);
|
if (!store.simplifiedControls) return setQuickOpen(!quickOpen);
|
||||||
setJobDialogOpen(true);
|
setJobDialogOpen(true);
|
||||||
}
|
};
|
||||||
const quickOpenClose = () => setQuickOpen(false);
|
const quickOpenClose = () => setQuickOpen(false);
|
||||||
|
|
||||||
|
|
||||||
const handleClickOpen = () => setJobDialogOpen(true);
|
const handleClickOpen = () => setJobDialogOpen(true);
|
||||||
const handleClose = () => setJobDialogOpen(false);
|
|
||||||
const [queued, setQueued] = useState([]);
|
const [queued, setQueued] = useState([]);
|
||||||
useEffect(() => {
|
useEffect(() => {}, [jobState.jobs]);
|
||||||
}, [jobState.jobs]);
|
|
||||||
|
const handleClose = (confirmed) => () => {
|
||||||
|
setJobDialogOpen(false);
|
||||||
|
if (!confirmed) return;
|
||||||
|
jobBuilder(queued);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="jobs">
|
<div className="jobs">
|
||||||
|
@ -59,15 +62,20 @@ export default function Jobs() {
|
||||||
<JobBox key={i} job={v} />
|
<JobBox key={i} job={v} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
<Dialog open={jobDialogOpen} onClose={handleClose} maxWidth="xs">
|
<Dialog open={jobDialogOpen} onClose={handleClose()} fullScreen>
|
||||||
|
<Toolbar />
|
||||||
<DialogTitle>New Job</DialogTitle>
|
<DialogTitle>New Job</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<span>Some Selectors</span>
|
<span>Some Selectors</span>
|
||||||
<JobTestSelector queued={queued} availableTests={store.catalog} setQueued={setQueued} />
|
<JobTestSelector
|
||||||
|
queued={queued}
|
||||||
|
availableTests={store.catalog}
|
||||||
|
setQueued={setQueued}
|
||||||
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={handleClose}>Cancel</Button>
|
<Button onClick={handleClose()}>Cancel</Button>
|
||||||
<Button onClick={handleClose} autoFocus>
|
<Button onClick={handleClose(true)} autoFocus>
|
||||||
Start
|
Start
|
||||||
</Button>
|
</Button>
|
||||||
</DialogActions>
|
</DialogActions>
|
||||||
|
|
|
@ -15,7 +15,6 @@ import Typography from "@mui/material/Typography";
|
||||||
export default function Settings(props) {
|
export default function Settings(props) {
|
||||||
const { state: store, updateStore } = useContext(StoreContext);
|
const { state: store, updateStore } = useContext(StoreContext);
|
||||||
const { regions } = store;
|
const { regions } = store;
|
||||||
const { pages } = props;
|
|
||||||
|
|
||||||
const defaultDialog = {
|
const defaultDialog = {
|
||||||
title: "",
|
title: "",
|
||||||
|
@ -35,7 +34,7 @@ export default function Settings(props) {
|
||||||
},
|
},
|
||||||
defaultPage: {
|
defaultPage: {
|
||||||
title: "Default Page",
|
title: "Default Page",
|
||||||
options: pages,
|
options: store.pages,
|
||||||
current: store.defaultPage,
|
current: store.defaultPage,
|
||||||
onSelect: (p) => updateStore({ defaultPage: p }),
|
onSelect: (p) => updateStore({ defaultPage: p }),
|
||||||
},
|
},
|
||||||
|
@ -133,8 +132,6 @@ export default function Settings(props) {
|
||||||
/>
|
/>
|
||||||
</ListItem>
|
</ListItem>
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
<MultiOptionDialog
|
<MultiOptionDialog
|
||||||
id="multi-options-menu"
|
id="multi-options-menu"
|
||||||
keepMounted
|
keepMounted
|
||||||
|
|
|
@ -7,43 +7,44 @@ import ListItemText from "@mui/material/ListItemText";
|
||||||
import ListItemAvatar from "@mui/material/ListItemAvatar";
|
import ListItemAvatar from "@mui/material/ListItemAvatar";
|
||||||
import Checkbox from "@mui/material/Checkbox";
|
import Checkbox from "@mui/material/Checkbox";
|
||||||
|
|
||||||
export default function JobManualSelector(props){
|
export default function JobManualSelector(props) {
|
||||||
const {availableTests} = props;
|
const { availableTests } = props;
|
||||||
const [queued, setQueued] = useState([]);
|
const [queued, setQueued] = useState([]);
|
||||||
useEffect(()=>{
|
useEffect(() => {}, [availableTests]);
|
||||||
},[availableTests]);
|
|
||||||
|
|
||||||
const queueTest = (test) => () => {
|
const queueTest = (test) => () => {
|
||||||
const q = [...queued];
|
const q = [...queued];
|
||||||
const testIndex = q.indexOf(test);
|
const testIndex = q.indexOf(test);
|
||||||
if(testIndex === -1) q.push(test);
|
if (testIndex === -1) q.push(test);
|
||||||
else q.splice(testIndex, 1);
|
else q.splice(testIndex, 1);
|
||||||
setQueued(q);
|
setQueued(q);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box style={{ overflow: "auto", maxHeight: 250 }}>
|
<Box style={{ overflow: "auto", maxHeight: 250 }}>
|
||||||
<List>
|
<List>
|
||||||
{availableTests.map((v, i) => (
|
{availableTests.map((v, i) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={i}
|
key={i}
|
||||||
secondaryAction={<Checkbox edge="end" checked={queued.includes(v)} />}
|
secondaryAction={
|
||||||
disablePadding
|
<Checkbox edge="end" checked={queued.includes(v)} />
|
||||||
onClick={queueTest(v)}
|
}
|
||||||
>
|
disablePadding
|
||||||
<ListItemButton key={i}>
|
onClick={queueTest(v)}
|
||||||
<ListItemText
|
>
|
||||||
primary={
|
<ListItemButton key={i}>
|
||||||
<span>
|
<ListItemText
|
||||||
{v.class}#<strong>{v.name}</strong>
|
primary={
|
||||||
</span>
|
<span>
|
||||||
}
|
{v.class}#<strong>{v.name}</strong>
|
||||||
style={{ wordBreak: "break-word" }}
|
</span>
|
||||||
/>
|
}
|
||||||
</ListItemButton>
|
style={{ wordBreak: "break-word" }}
|
||||||
</ListItem>
|
/>
|
||||||
))}
|
</ListItemButton>
|
||||||
</List>
|
</ListItem>
|
||||||
</Box>
|
))}
|
||||||
);
|
</List>
|
||||||
}
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -6,20 +6,18 @@ import DialogActions from "@mui/material/DialogActions";
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
|
||||||
export default function JobTestSelector(props){
|
export default function JobTestSelector(props) {
|
||||||
const {jobDialogOpen, handleClose, dialogTitle, testSelector} = props;
|
const { jobDialogOpen, handleClose, dialogTitle, testSelector } = props;
|
||||||
return (
|
return (
|
||||||
<Dialog open={jobDialogOpen} onClose={handleClose} maxWidth="xs">
|
<Dialog open={jobDialogOpen} onClose={handleClose} maxWidth="xs">
|
||||||
<DialogTitle>{dialogTitle}</DialogTitle>
|
<DialogTitle>{dialogTitle}</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>{testSelector}</DialogContent>
|
||||||
{testSelector}
|
<DialogActions>
|
||||||
</DialogContent>
|
<Button onClick={handleClose}>Cancel</Button>
|
||||||
<DialogActions>
|
<Button onClick={handleClose} autoFocus>
|
||||||
<Button onClick={handleClose}>Cancel</Button>
|
Start
|
||||||
<Button onClick={handleClose} autoFocus>
|
</Button>
|
||||||
Start
|
</DialogActions>
|
||||||
</Button>
|
</Dialog>
|
||||||
</DialogActions>
|
);
|
||||||
</Dialog>
|
}
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
|
@ -8,7 +8,6 @@ import AccordionSummary from "@mui/material/AccordionSummary";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
|
||||||
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
import PlayArrowIcon from "@mui/icons-material/PlayArrow";
|
||||||
|
|
||||||
import Box from "@mui/material/Box";
|
import Box from "@mui/material/Box";
|
||||||
|
@ -33,10 +32,21 @@ export default function CatalogBox(props) {
|
||||||
|
|
||||||
const toggleOpen = () => setOpen(!open);
|
const toggleOpen = () => setOpen(!open);
|
||||||
|
|
||||||
|
const runTest = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
jobBuilder([catalogTest]);
|
||||||
|
};
|
||||||
|
|
||||||
function Actions() {
|
function Actions() {
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<IconButton color="success" aria-label="play" component="span">
|
<IconButton
|
||||||
|
color="success"
|
||||||
|
aria-label="play"
|
||||||
|
component="span"
|
||||||
|
onClick={runTest}
|
||||||
|
>
|
||||||
<PlayArrowIcon />
|
<PlayArrowIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
|
|
@ -7,6 +7,13 @@ import AccordionDetails from "@mui/material/AccordionDetails";
|
||||||
import AccordionSummary from "@mui/material/AccordionSummary";
|
import AccordionSummary from "@mui/material/AccordionSummary";
|
||||||
import Typography from "@mui/material/Typography";
|
import Typography from "@mui/material/Typography";
|
||||||
|
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import Dialog from "@mui/material/Dialog";
|
||||||
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
import DialogContentText from "@mui/material/DialogContentText";
|
||||||
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
|
|
||||||
import IconButton from "@mui/material/IconButton";
|
import IconButton from "@mui/material/IconButton";
|
||||||
import DeleteIcon from "@mui/icons-material/Delete";
|
import DeleteIcon from "@mui/icons-material/Delete";
|
||||||
import NotificationsIcon from "@mui/icons-material/Notifications";
|
import NotificationsIcon from "@mui/icons-material/Notifications";
|
||||||
|
@ -43,14 +50,23 @@ export default function FailingBox(props) {
|
||||||
jobStatus: testJobStatus,
|
jobStatus: testJobStatus,
|
||||||
} = failingTest;
|
} = failingTest;
|
||||||
|
|
||||||
const { state: jobState, retryTest, retryJobStatus } = useContext(JobContext);
|
const { state: jobState, retrySingle } = useContext(JobContext);
|
||||||
|
|
||||||
const { state: store, updateStore } = useContext(StoreContext);
|
const { state: store, updateStore, removeFailure } = useContext(StoreContext);
|
||||||
|
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
const toggleOpen = () => setOpen(!open);
|
const toggleOpen = () => setOpen(!open);
|
||||||
|
|
||||||
|
const [removeOpen, setRemoveOpen] = useState(false);
|
||||||
|
const removeClick = () => setRemoveOpen(!removeOpen);
|
||||||
|
|
||||||
|
const handleRemoveClose = (confirmed) => (e) => {
|
||||||
|
stopPropagation(e);
|
||||||
|
setRemoveOpen(false);
|
||||||
|
if (!confirmed) return;
|
||||||
|
removeFailure(failingTest);
|
||||||
|
};
|
||||||
|
|
||||||
function badgeColor() {
|
function badgeColor() {
|
||||||
if (dailyFails === 1) return "primary";
|
if (dailyFails === 1) return "primary";
|
||||||
else if (dailyFails === 2) return "secondary";
|
else if (dailyFails === 2) return "secondary";
|
||||||
|
@ -80,9 +96,11 @@ export default function FailingBox(props) {
|
||||||
function Actions() {
|
function Actions() {
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<IconButton aria-label="photo" component="span">
|
<a href={screenshotUrl}>
|
||||||
<PhotoCameraIcon />
|
<IconButton aria-label="photo" component="span">
|
||||||
</IconButton>
|
<PhotoCameraIcon />
|
||||||
|
</IconButton>
|
||||||
|
</a>
|
||||||
|
|
||||||
<IconButton aria-label="retry" component="span">
|
<IconButton aria-label="retry" component="span">
|
||||||
{jobIcon()}
|
{jobIcon()}
|
||||||
|
@ -96,7 +114,12 @@ export default function FailingBox(props) {
|
||||||
>
|
>
|
||||||
<NotificationsIcon />
|
<NotificationsIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
<IconButton color="error" aria-label="delete" component="span">
|
<IconButton
|
||||||
|
color="error"
|
||||||
|
aria-label="delete"
|
||||||
|
component="span"
|
||||||
|
onClick={removeClick}
|
||||||
|
>
|
||||||
<DeleteIcon />
|
<DeleteIcon />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</React.Fragment>
|
</React.Fragment>
|
||||||
|
@ -125,21 +148,52 @@ export default function FailingBox(props) {
|
||||||
horizontal: "left",
|
horizontal: "left",
|
||||||
}}
|
}}
|
||||||
></Badge>
|
></Badge>
|
||||||
|
<Badge
|
||||||
|
sx={{ mr: 2 }}
|
||||||
|
style={{ whiteSpace: "nowrap", left: "3.125rem" }}
|
||||||
|
badgeContent={new Date(timestamp).toLocaleTimeString("en-US")}
|
||||||
|
anchorOrigin={{
|
||||||
|
vertical: "bottom",
|
||||||
|
horizontal: "left",
|
||||||
|
}}
|
||||||
|
></Badge>
|
||||||
|
<Dialog
|
||||||
|
open={removeOpen}
|
||||||
|
onClose={handleRemoveClose()}
|
||||||
|
sx={{ "& .MuiDialog-paper": { width: "80%", maxHeight: 435 } }}
|
||||||
|
maxWidth="xs"
|
||||||
|
>
|
||||||
|
<DialogTitle>Remove failure?</DialogTitle>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogContentText>
|
||||||
|
This will remove 1 test from the database
|
||||||
|
</DialogContentText>
|
||||||
|
</DialogContent>
|
||||||
|
<DialogActions>
|
||||||
|
<Button onClick={handleRemoveClose()}>Cancel</Button>
|
||||||
|
<Button onClick={handleRemoveClose(true)} autoFocus>
|
||||||
|
Yes
|
||||||
|
</Button>
|
||||||
|
</DialogActions>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
<Typography component={"span"} style={{ wordBreak: "break-word" }}>
|
<Typography component={"span"} style={{ wordBreak: "break-word" }}>
|
||||||
{`${testClass}#`}
|
{`${testClass}#`}
|
||||||
<Box fontWeight="bold" display="inline">
|
<Box fontWeight="bold" display="inline">
|
||||||
{testName}{" "}
|
{testName}{" "}
|
||||||
</Box>
|
</Box>
|
||||||
<br />
|
<br />
|
||||||
<span className="recent-results">
|
<div>
|
||||||
{recentResults.map(
|
<span className="recent-results">
|
||||||
(v, i) =>
|
{recentResults.map(
|
||||||
(v && <CheckIcon key={i} color="success" />) || (
|
(v, i) =>
|
||||||
<ClearIcon key={i} color="error" />
|
(v && <CheckIcon key={i} color="success" />) || (
|
||||||
)
|
<ClearIcon key={i} color="error" />
|
||||||
)}
|
)
|
||||||
</span>
|
)}
|
||||||
{isCompound && <ViewColumnIcon />}
|
</span>
|
||||||
|
{isCompound && <ViewColumnIcon />}
|
||||||
|
</div>
|
||||||
</Typography>
|
</Typography>
|
||||||
|
|
||||||
<Stack
|
<Stack
|
||||||
|
@ -153,8 +207,9 @@ export default function FailingBox(props) {
|
||||||
direction="row"
|
direction="row"
|
||||||
sx={{
|
sx={{
|
||||||
ml: "auto",
|
ml: "auto",
|
||||||
mb: "auto",
|
mb: "auto",
|
||||||
mt: "auto",
|
mt: "auto",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
display: { xs: "none", sm: "none", md: "block", lg: "block" },
|
display: { xs: "none", sm: "none", md: "block", lg: "block" },
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
@ -6,42 +6,44 @@ import ListItemButton from "@mui/material/ListItemButton";
|
||||||
import ListItemText from "@mui/material/ListItemText";
|
import ListItemText from "@mui/material/ListItemText";
|
||||||
import Checkbox from "@mui/material/Checkbox";
|
import Checkbox from "@mui/material/Checkbox";
|
||||||
|
|
||||||
export default function JobTestSelector(props){
|
export default function JobTestSelector(props) {
|
||||||
const {availableTests, queued, setQueued} = props;
|
const { availableTests, queued, setQueued } = props;
|
||||||
|
|
||||||
useEffect(()=>{},[availableTests]);
|
useEffect(() => {}, [availableTests]);
|
||||||
|
|
||||||
const queueTest = (test) => () => {
|
const queueTest = (test) => () => {
|
||||||
const q = [...queued];
|
const q = [...queued];
|
||||||
const testIndex = q.indexOf(test);
|
const testIndex = q.indexOf(test);
|
||||||
if(testIndex === -1) q.push(test);
|
if (testIndex === -1) q.push(test);
|
||||||
else q.splice(testIndex, 1);
|
else q.splice(testIndex, 1);
|
||||||
setQueued(q);
|
setQueued(q);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Box style={{ overflow: "auto", maxHeight: 250 }}>
|
<Box style={{ overflow: "auto", maxHeight: 250 }}>
|
||||||
<List>
|
<List>
|
||||||
{availableTests.map((v, i) => (
|
{availableTests.map((v, i) => (
|
||||||
<ListItem
|
<ListItem
|
||||||
key={i}
|
key={i}
|
||||||
secondaryAction={<Checkbox edge="end" checked={queued.includes(v)} />}
|
secondaryAction={
|
||||||
disablePadding
|
<Checkbox edge="end" checked={queued.includes(v)} />
|
||||||
onClick={queueTest(v)}
|
}
|
||||||
>
|
disablePadding
|
||||||
<ListItemButton key={i}>
|
onClick={queueTest(v)}
|
||||||
<ListItemText
|
>
|
||||||
primary={
|
<ListItemButton key={i}>
|
||||||
<span>
|
<ListItemText
|
||||||
{v.class}#<strong>{v.name}</strong>
|
primary={
|
||||||
</span>
|
<span>
|
||||||
}
|
{v.class}#<strong>{v.name}</strong>
|
||||||
style={{ wordBreak: "break-word" }}
|
</span>
|
||||||
/>
|
}
|
||||||
</ListItemButton>
|
style={{ wordBreak: "break-word" }}
|
||||||
</ListItem>
|
/>
|
||||||
))}
|
</ListItemButton>
|
||||||
</List>
|
</ListItem>
|
||||||
</Box>
|
))}
|
||||||
);
|
</List>
|
||||||
}
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import { useState, useRef, useEffect } from "react";
|
import { useState, useRef, useEffect } from "react";
|
||||||
|
|
||||||
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
|
@ -8,12 +10,14 @@ import Dialog from "@mui/material/Dialog";
|
||||||
import RadioGroup from "@mui/material/RadioGroup";
|
import RadioGroup from "@mui/material/RadioGroup";
|
||||||
import Radio from "@mui/material/Radio";
|
import Radio from "@mui/material/Radio";
|
||||||
import FormControlLabel from "@mui/material/FormControlLabel";
|
import FormControlLabel from "@mui/material/FormControlLabel";
|
||||||
|
import Toolbar from "@mui/material/Toolbar";
|
||||||
|
|
||||||
export default function MultiOptionDialog(props) {
|
export default function MultiOptionDialog(props) {
|
||||||
const { dialog: dialogProp, onClose, open, ...other } = props;
|
const { dialog: dialogProp, onClose, open, ...other } = props;
|
||||||
const [value, setValue] = useState(dialogProp.current);
|
const [value, setValue] = useState(dialogProp.current);
|
||||||
const [dialog, setDialog] = useState(dialogProp);
|
const [dialog, setDialog] = useState(dialogProp);
|
||||||
|
const theme = useTheme();
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
const radioGroupRef = useRef(null);
|
const radioGroupRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -35,12 +39,17 @@ export default function MultiOptionDialog(props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
sx={{ "& .MuiDialog-paper": { width: "80%", maxHeight: 435 } }}
|
|
||||||
maxWidth="xs"
|
|
||||||
TransitionProps={{ onEntering: handleEntering }}
|
TransitionProps={{ onEntering: handleEntering }}
|
||||||
open={open}
|
open={open}
|
||||||
{...other}
|
{...other}
|
||||||
|
sx={
|
||||||
|
fullScreen
|
||||||
|
? {}
|
||||||
|
: { "& .MuiDialog-paper": { width: "80%", maxHeight: 435 } }
|
||||||
|
}
|
||||||
|
fullScreen={fullScreen}
|
||||||
>
|
>
|
||||||
|
<Toolbar sx={{ display: { sm: "none" } }} />
|
||||||
<DialogTitle>{dialog.title}</DialogTitle>
|
<DialogTitle>{dialog.title}</DialogTitle>
|
||||||
<DialogContent dividers>
|
<DialogContent dividers>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
|
|
|
@ -1,16 +1,20 @@
|
||||||
import { useState, useContext, useEffect } from "react";
|
import { useState, useContext, useEffect } from "react";
|
||||||
import StoreContext from "../../ctx/StoreContext.jsx";
|
import StoreContext from "../../ctx/StoreContext.jsx";
|
||||||
|
|
||||||
|
import useMediaQuery from "@mui/material/useMediaQuery";
|
||||||
|
import { useTheme } from "@mui/material/styles";
|
||||||
import Button from "@mui/material/Button";
|
import Button from "@mui/material/Button";
|
||||||
import DialogTitle from "@mui/material/DialogTitle";
|
import DialogTitle from "@mui/material/DialogTitle";
|
||||||
import DialogContent from "@mui/material/DialogContent";
|
import DialogContent from "@mui/material/DialogContent";
|
||||||
import DialogActions from "@mui/material/DialogActions";
|
import DialogActions from "@mui/material/DialogActions";
|
||||||
import Dialog from "@mui/material/Dialog";
|
import Dialog from "@mui/material/Dialog";
|
||||||
import TextField from "@mui/material/TextField";
|
import TextField from "@mui/material/TextField";
|
||||||
|
import Toolbar from "@mui/material/Toolbar";
|
||||||
|
|
||||||
export default function SilenceDialog(props) {
|
export default function SilenceDialog(props) {
|
||||||
const { silence, open, onClose } = props;
|
const { silence, open, onClose } = props;
|
||||||
|
const theme = useTheme();
|
||||||
|
const fullScreen = useMediaQuery(theme.breakpoints.down("sm"));
|
||||||
const [silenceEntry, setSilenceEntry] = useState(silence);
|
const [silenceEntry, setSilenceEntry] = useState(silence);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
@ -30,10 +34,16 @@ export default function SilenceDialog(props) {
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog
|
<Dialog
|
||||||
sx={{ "& .MuiDialog-paper": { width: "80%", maxHeight: 435 } }}
|
sx={
|
||||||
|
fullScreen
|
||||||
|
? {}
|
||||||
|
: { "& .MuiDialog-paper": { width: "80%", maxHeight: 435 } }
|
||||||
|
}
|
||||||
maxWidth="xs"
|
maxWidth="xs"
|
||||||
open={open}
|
open={open}
|
||||||
|
fullScreen={fullScreen}
|
||||||
>
|
>
|
||||||
|
<Toolbar sx={{ display: { sm: "none" } }} />
|
||||||
<DialogTitle>Silence Alert</DialogTitle>
|
<DialogTitle>Silence Alert</DialogTitle>
|
||||||
<DialogContent>
|
<DialogContent>
|
||||||
<TextField
|
<TextField
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue