mirror of
https://github.com/Kingsrook/qqq-frontend-material-dashboard.git
synced 2025-07-18 05:10:45 +00:00
Add data bag view widget, and editor.
This commit is contained in:
209
src/qqq/components/databags/DataBagDataEditor.tsx
Normal file
209
src/qqq/components/databags/DataBagDataEditor.tsx
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
/*
|
||||||
|
* QQQ - Low-code Application Framework for Engineers.
|
||||||
|
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||||
|
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||||
|
* contact@kingsrook.com
|
||||||
|
* https://github.com/Kingsrook/
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
|
||||||
|
import {ToggleButton, ToggleButtonGroup, Typography} from "@mui/material";
|
||||||
|
import Alert from "@mui/material/Alert";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Card from "@mui/material/Card";
|
||||||
|
import Grid from "@mui/material/Grid";
|
||||||
|
import Snackbar from "@mui/material/Snackbar";
|
||||||
|
import TextField from "@mui/material/TextField";
|
||||||
|
import FormData from "form-data";
|
||||||
|
import React, {useReducer, useState} from "react";
|
||||||
|
import AceEditor from "react-ace";
|
||||||
|
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
|
||||||
|
import Client from "qqq/utils/qqq/Client";
|
||||||
|
import DataBagPreview from "./DataBagPreview";
|
||||||
|
|
||||||
|
export interface DataBagDataEditorProps
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
dataBagId: number;
|
||||||
|
data: string;
|
||||||
|
closeCallback: any;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const qController = Client.getInstance();
|
||||||
|
|
||||||
|
function DataBagDataEditor({title, dataBagId, data, closeCallback}: DataBagDataEditorProps): JSX.Element
|
||||||
|
{
|
||||||
|
const [closing, setClosing] = useState(false);
|
||||||
|
const [updatedCode, setUpdatedCode] = useState(data)
|
||||||
|
const [commitMessage, setCommitMessage] = useState("")
|
||||||
|
const [openTool, setOpenTool] = useState(null);
|
||||||
|
const [errorAlert, setErrorAlert] = useState("")
|
||||||
|
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
|
const changeOpenTool = (event: React.MouseEvent<HTMLElement>, newValue: string | null) =>
|
||||||
|
{
|
||||||
|
setOpenTool(newValue);
|
||||||
|
|
||||||
|
// need this to make Ace recognize new height.
|
||||||
|
setTimeout(() =>
|
||||||
|
{
|
||||||
|
window.dispatchEvent(new Event("resize"))
|
||||||
|
}, 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
const saveClicked = () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
JSON.parse(updatedCode)
|
||||||
|
}
|
||||||
|
catch(e)
|
||||||
|
{
|
||||||
|
setErrorAlert("Cannot save Data Bag Contents. Invalid json: " + e);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setClosing(true);
|
||||||
|
|
||||||
|
(async () =>
|
||||||
|
{
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("dataBagId", dataBagId);
|
||||||
|
formData.append("data", updatedCode);
|
||||||
|
formData.append("commitMessage", commitMessage);
|
||||||
|
|
||||||
|
//////////////////////////////////////////////////////////////////
|
||||||
|
// we don't want this job to go async, so, pass a large timeout //
|
||||||
|
//////////////////////////////////////////////////////////////////
|
||||||
|
formData.append("_qStepTimeoutMillis", 60 * 1000);
|
||||||
|
|
||||||
|
const formDataHeaders = {
|
||||||
|
"content-type": "multipart/form-data; boundary=--------------------------320289315924586491558366",
|
||||||
|
};
|
||||||
|
|
||||||
|
const processResult = await qController.processInit("storeDataBagVersion", formData, formDataHeaders);
|
||||||
|
if (processResult instanceof QJobError)
|
||||||
|
{
|
||||||
|
const jobError = processResult as QJobError
|
||||||
|
closeCallback(null, "failed", jobError.userFacingError ?? jobError.error);
|
||||||
|
}
|
||||||
|
console.log("process result");
|
||||||
|
console.log(processResult);
|
||||||
|
|
||||||
|
closeCallback(null, "saved", "Saved New Data Bag Version");
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelClicked = () =>
|
||||||
|
{
|
||||||
|
setClosing(true);
|
||||||
|
closeCallback(null, "cancelled");
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCode = (value: string, event: any) =>
|
||||||
|
{
|
||||||
|
console.log("Updating code")
|
||||||
|
setUpdatedCode(value);
|
||||||
|
forceUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateCommitMessage = (event: React.ChangeEvent<HTMLInputElement>) =>
|
||||||
|
{
|
||||||
|
setCommitMessage(event.target.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Box sx={{position: "absolute", overflowY: "auto", height: "100%", width: "100%"}} p={6}>
|
||||||
|
<Card sx={{height: "100%", p: 3}}>
|
||||||
|
|
||||||
|
<Snackbar open={errorAlert !== null && errorAlert !== ""} onClose={(event?: React.SyntheticEvent | Event, reason?: string) =>
|
||||||
|
{
|
||||||
|
if (reason === "clickaway")
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setErrorAlert("")
|
||||||
|
}} anchorOrigin={{vertical: "top", horizontal: "center"}}>
|
||||||
|
<Alert color="error" onClose={() => setErrorAlert("")}>
|
||||||
|
{errorAlert}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
|
||||||
|
<Box display="flex" justifyContent="space-between" alignItems="center">
|
||||||
|
<Typography variant="h5" pb={1}>
|
||||||
|
{title}
|
||||||
|
</Typography>
|
||||||
|
|
||||||
|
<Box>
|
||||||
|
<Typography variant="body2" display="inline" pr={1}>
|
||||||
|
Tools:
|
||||||
|
</Typography>
|
||||||
|
<ToggleButtonGroup
|
||||||
|
value={openTool}
|
||||||
|
exclusive
|
||||||
|
onChange={changeOpenTool}
|
||||||
|
size="small"
|
||||||
|
sx={{pb: 1}}
|
||||||
|
>
|
||||||
|
<ToggleButton value="preview">Preview</ToggleButton>
|
||||||
|
</ToggleButtonGroup>
|
||||||
|
</Box>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<Box sx={{height: openTool ? "45%" : "100%"}}>
|
||||||
|
<AceEditor
|
||||||
|
mode="json"
|
||||||
|
theme="github"
|
||||||
|
name="editor"
|
||||||
|
editorProps={{$blockScrolling: true}}
|
||||||
|
onChange={updateCode}
|
||||||
|
width="100%"
|
||||||
|
height="100%"
|
||||||
|
value={updatedCode}
|
||||||
|
style={{border: "1px solid gray"}}
|
||||||
|
/>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
{
|
||||||
|
openTool &&
|
||||||
|
<Box sx={{height: "45%"}} pt={2}>
|
||||||
|
{
|
||||||
|
openTool == "preview" &&
|
||||||
|
<Box fontSize="14px" overflow="auto" height="100%" border="1px solid gray" pt={1}>
|
||||||
|
<DataBagPreview json={updatedCode} />
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
|
||||||
|
<Box pt={1}>
|
||||||
|
<Grid container alignItems="flex-end">
|
||||||
|
<Box width="50%">
|
||||||
|
<TextField id="commitMessage" label="Commit Message" variant="standard" fullWidth value={commitMessage} onChange={updateCommitMessage} />
|
||||||
|
</Box>
|
||||||
|
<Grid container justifyContent="flex-end" spacing={3}>
|
||||||
|
<QCancelButton disabled={closing} onClickHandler={cancelClicked} />
|
||||||
|
<QSaveButton disabled={closing} onClickHandler={saveClicked} />
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Box>
|
||||||
|
</Card>
|
||||||
|
</Box>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DataBagDataEditor;
|
141
src/qqq/components/databags/DataBagPreview.tsx
Normal file
141
src/qqq/components/databags/DataBagPreview.tsx
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
/*
|
||||||
|
* QQQ - Low-code Application Framework for Engineers.
|
||||||
|
* Copyright (C) 2021-2023. Kingsrook, LLC
|
||||||
|
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||||
|
* contact@kingsrook.com
|
||||||
|
* https://github.com/Kingsrook/
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Icon from "@mui/material/Icon";
|
||||||
|
import React, {useEffect, useReducer, useState} from "react";
|
||||||
|
|
||||||
|
interface Props
|
||||||
|
{
|
||||||
|
data?: any;
|
||||||
|
json?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
DataBagPreview.defaultProps = {
|
||||||
|
data: null,
|
||||||
|
json: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
function DataBagPreview({data, json}: Props): JSX.Element
|
||||||
|
{
|
||||||
|
const [openPreviewDivs, setOpenPreviewDivs] = useState(new Set<string>)
|
||||||
|
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||||
|
const [dataToRender, setDataToRender] = useState(null as any);
|
||||||
|
const [errorMessage, setErrorMessage] = useState(null as string)
|
||||||
|
|
||||||
|
useEffect(() =>
|
||||||
|
{
|
||||||
|
if(data)
|
||||||
|
{
|
||||||
|
setDataToRender(data);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const dataToRender = JSON.parse(json)
|
||||||
|
setDataToRender(dataToRender);
|
||||||
|
setErrorMessage(null);
|
||||||
|
}
|
||||||
|
catch(e)
|
||||||
|
{
|
||||||
|
setErrorMessage("Error parsing JSON: " + e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [data, json]);
|
||||||
|
|
||||||
|
const togglePreviewDiv = (id: string) =>
|
||||||
|
{
|
||||||
|
if(openPreviewDivs.has(id))
|
||||||
|
{
|
||||||
|
openPreviewDivs.delete(id);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
openPreviewDivs.add(id);
|
||||||
|
}
|
||||||
|
setOpenPreviewDivs(openPreviewDivs);
|
||||||
|
console.log(openPreviewDivs);
|
||||||
|
forceUpdate();
|
||||||
|
}
|
||||||
|
|
||||||
|
const previewObject = (object: any, path: string): JSX.Element =>
|
||||||
|
{
|
||||||
|
if(typeof object == "object")
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Box ml={3}>
|
||||||
|
{
|
||||||
|
Object.keys(object).map((key: any, index: any) =>
|
||||||
|
{
|
||||||
|
const divId = `${path}.${key}`
|
||||||
|
const childIsObject = (typeof object[key] == "object");
|
||||||
|
return (
|
||||||
|
<React.Fragment key={index}>
|
||||||
|
<Box>
|
||||||
|
{
|
||||||
|
childIsObject
|
||||||
|
? <Icon sx={{position: "relative", top: "2px"}} onClick={() => togglePreviewDiv(divId)} style={{cursor: "pointer"}}>{openPreviewDivs.has(divId) ? "expand_more" : "chevron_right"}</Icon>
|
||||||
|
: <Box component="span" px="4px">•</Box>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
childIsObject
|
||||||
|
? <span className="fieldLabel" onClick={() => togglePreviewDiv(divId)} style={{"cursor": "pointer"}}>{key}:</span>
|
||||||
|
: <span className="fieldLabel">{key}:</span>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
childIsObject && openPreviewDivs.has(divId) && <Box>{previewObject(object[key], `${path}.${key}`)}</Box>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
!childIsObject && <span className="fieldValue">{object[key]}</span>
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
</React.Fragment>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
return (<>{object}</>)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getDataBagPreview = (data: any): JSX.Element=>
|
||||||
|
{
|
||||||
|
console.log("getDataBagPreview:")
|
||||||
|
return previewObject(data, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{errorMessage == null && dataToRender && getDataBagPreview(dataToRender)}
|
||||||
|
{errorMessage && <Box p={2}>{errorMessage}</Box>}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default DataBagPreview;
|
@ -36,6 +36,7 @@ import DefaultLineChart from "qqq/components/widgets/charts/linechart/DefaultLin
|
|||||||
import SmallLineChart from "qqq/components/widgets/charts/linechart/SmallLineChart";
|
import SmallLineChart from "qqq/components/widgets/charts/linechart/SmallLineChart";
|
||||||
import PieChart from "qqq/components/widgets/charts/piechart/PieChart";
|
import PieChart from "qqq/components/widgets/charts/piechart/PieChart";
|
||||||
import StackedBarChart from "qqq/components/widgets/charts/StackedBarChart";
|
import StackedBarChart from "qqq/components/widgets/charts/StackedBarChart";
|
||||||
|
import DataBagViewer from "qqq/components/widgets/misc/DataBagViewer";
|
||||||
import DividerWidget from "qqq/components/widgets/misc/Divider";
|
import DividerWidget from "qqq/components/widgets/misc/Divider";
|
||||||
import FieldValueListWidget from "qqq/components/widgets/misc/FieldValueListWidget";
|
import FieldValueListWidget from "qqq/components/widgets/misc/FieldValueListWidget";
|
||||||
import QuickSightChart from "qqq/components/widgets/misc/QuickSightChart";
|
import QuickSightChart from "qqq/components/widgets/misc/QuickSightChart";
|
||||||
@ -419,6 +420,14 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omit
|
|||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
widgetMetaData.type === "dataBagViewer" && (
|
||||||
|
widgetData && widgetData[i] && widgetData[i].queryParams &&
|
||||||
|
<Widget widgetMetaData={widgetMetaData}>
|
||||||
|
<DataBagViewer dataBagId={widgetData[i].queryParams.id} />
|
||||||
|
</Widget>
|
||||||
|
)
|
||||||
|
}
|
||||||
</Box>
|
</Box>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
393
src/qqq/components/widgets/misc/DataBagViewer.tsx
Normal file
393
src/qqq/components/widgets/misc/DataBagViewer.tsx
Normal file
@ -0,0 +1,393 @@
|
|||||||
|
/*
|
||||||
|
* QQQ - Low-code Application Framework for Engineers.
|
||||||
|
* Copyright (C) 2021-2022. Kingsrook, LLC
|
||||||
|
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||||
|
* contact@kingsrook.com
|
||||||
|
* https://github.com/Kingsrook/
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException";
|
||||||
|
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
|
||||||
|
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
|
||||||
|
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
|
||||||
|
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
|
||||||
|
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
|
||||||
|
import Alert from "@mui/material/Alert";
|
||||||
|
import Avatar from "@mui/material/Avatar";
|
||||||
|
import Box from "@mui/material/Box";
|
||||||
|
import Button from "@mui/material/Button";
|
||||||
|
import Chip from "@mui/material/Chip";
|
||||||
|
import Divider from "@mui/material/Divider";
|
||||||
|
import Grid from "@mui/material/Grid";
|
||||||
|
import Icon from "@mui/material/Icon";
|
||||||
|
import List from "@mui/material/List";
|
||||||
|
import ListItem from "@mui/material/ListItem";
|
||||||
|
import ListItemAvatar from "@mui/material/ListItemAvatar";
|
||||||
|
import ListItemText from "@mui/material/ListItemText";
|
||||||
|
import Modal from "@mui/material/Modal";
|
||||||
|
import Snackbar from "@mui/material/Snackbar";
|
||||||
|
import Tab from "@mui/material/Tab";
|
||||||
|
import Tabs from "@mui/material/Tabs";
|
||||||
|
import Typography from "@mui/material/Typography";
|
||||||
|
import React, {useReducer, useState} from "react";
|
||||||
|
import AceEditor from "react-ace";
|
||||||
|
import DataBagDataEditor, {DataBagDataEditorProps} from "qqq/components/databags/DataBagDataEditor";
|
||||||
|
import DataBagPreview from "qqq/components/databags/DataBagPreview";
|
||||||
|
import TabPanel from "qqq/components/misc/TabPanel";
|
||||||
|
import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip";
|
||||||
|
import {LoadingState} from "qqq/models/LoadingState";
|
||||||
|
import DeveloperModeUtils from "qqq/utils/DeveloperModeUtils";
|
||||||
|
import Client from "qqq/utils/qqq/Client";
|
||||||
|
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||||
|
|
||||||
|
import "ace-builds/src-noconflict/mode-java";
|
||||||
|
import "ace-builds/src-noconflict/mode-javascript";
|
||||||
|
import "ace-builds/src-noconflict/mode-json";
|
||||||
|
import "ace-builds/src-noconflict/theme-github";
|
||||||
|
import "ace-builds/src-noconflict/ext-language_tools";
|
||||||
|
|
||||||
|
const qController = Client.getInstance();
|
||||||
|
|
||||||
|
// Declaring props types for ViewForm
|
||||||
|
interface Props
|
||||||
|
{
|
||||||
|
dataBagId: number
|
||||||
|
}
|
||||||
|
|
||||||
|
DataBagViewer.defaultProps =
|
||||||
|
{
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DataBagViewer({dataBagId}: Props): JSX.Element
|
||||||
|
{
|
||||||
|
const [dataBagRecord, setDataBagRecord] = useState(null as QRecord);
|
||||||
|
const [asyncLoadInited, setAsyncLoadInited] = useState(false);
|
||||||
|
const [versionRecordList, setVersionRecordList] = useState(null as QRecord[]);
|
||||||
|
const [selectedVersionRecord, setSelectedVersionRecord] = useState(null as QRecord);
|
||||||
|
const [currentVersionId , setCurrentVersionId] = useState(null as number);
|
||||||
|
const [notFoundMessage, setNotFoundMessage] = useState(null);
|
||||||
|
const [selectedTab, setSelectedTab] = useState(0);
|
||||||
|
const [editorProps, setEditorProps] = useState(null as DataBagDataEditorProps);
|
||||||
|
const [successText, setSuccessText] = useState(null as string);
|
||||||
|
const [failText, setFailText] = useState(null as string)
|
||||||
|
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||||
|
|
||||||
|
const [loadingSelectedVersion, _] = useState(new LoadingState(forceUpdate, "loading"));
|
||||||
|
|
||||||
|
if (!asyncLoadInited)
|
||||||
|
{
|
||||||
|
setAsyncLoadInited(true);
|
||||||
|
|
||||||
|
(async () =>
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
const dataBagRecord = await qController.get("dataBag", dataBagId);
|
||||||
|
setDataBagRecord(dataBagRecord);
|
||||||
|
|
||||||
|
const criteria = [new QFilterCriteria("dataBagId", QCriteriaOperator.EQUALS, [dataBagId])];
|
||||||
|
const orderBys = [new QFilterOrderBy("sequenceNo", false)];
|
||||||
|
const filter = new QQueryFilter(criteria, orderBys);
|
||||||
|
const versions = await qController.query("dataBagVersion", filter, 25, 0);
|
||||||
|
console.log("Fetched versions:");
|
||||||
|
console.log(versions);
|
||||||
|
setVersionRecordList(versions);
|
||||||
|
|
||||||
|
if(versions && versions.length > 0)
|
||||||
|
{
|
||||||
|
setCurrentVersionId(versions[0].values.get("id"));
|
||||||
|
const latestVersion = await qController.get("dataBagVersion", versions[0].values.get("id"));
|
||||||
|
console.log("Fetched latestVersion:");
|
||||||
|
console.log(latestVersion);
|
||||||
|
setSelectedVersionRecord(latestVersion);
|
||||||
|
loadingSelectedVersion.setNotLoading();
|
||||||
|
forceUpdate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (e)
|
||||||
|
{
|
||||||
|
if (e instanceof QException)
|
||||||
|
{
|
||||||
|
if ((e as QException).status === "404")
|
||||||
|
{
|
||||||
|
setNotFoundMessage("Data bag data could not be found.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setNotFoundMessage("Error loading data bag data: " + e);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
|
||||||
|
const editData = (data: string) =>
|
||||||
|
{
|
||||||
|
const editorProps = {} as DataBagDataEditorProps;
|
||||||
|
editorProps.title = (data ? "Editing Contents of Data Bag: " : "Initializing Contents of Data Bag: ") + dataBagRecord?.values?.get("name");
|
||||||
|
editorProps.data = data;
|
||||||
|
editorProps.dataBagId = dataBagId;
|
||||||
|
setEditorProps(editorProps);
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeEditingScript = (event: object, reason: string, alert: string = null) =>
|
||||||
|
{
|
||||||
|
if (reason === "backdropClick" || reason === "escapeKeyDown")
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason === "saved")
|
||||||
|
{
|
||||||
|
setAsyncLoadInited(false);
|
||||||
|
forceUpdate();
|
||||||
|
|
||||||
|
if (alert)
|
||||||
|
{
|
||||||
|
setSuccessText(alert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (reason === "failed")
|
||||||
|
{
|
||||||
|
setAsyncLoadInited(false);
|
||||||
|
forceUpdate();
|
||||||
|
|
||||||
|
if (alert)
|
||||||
|
{
|
||||||
|
setFailText(alert);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setEditorProps(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const changeTab = (newValue: number) =>
|
||||||
|
{
|
||||||
|
setSelectedTab(newValue);
|
||||||
|
forceUpdate();
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectVersion = (version: QRecord) =>
|
||||||
|
{
|
||||||
|
(async () =>
|
||||||
|
{
|
||||||
|
// fetch the full version
|
||||||
|
setSelectedVersionRecord(version);
|
||||||
|
loadingSelectedVersion.setLoading();
|
||||||
|
|
||||||
|
const selectedVersion = await qController.get("dataBagVersion", version.values.get("id"));
|
||||||
|
console.log("Fetched selectedVersion:");
|
||||||
|
console.log(selectedVersion);
|
||||||
|
setSelectedVersionRecord(selectedVersion);
|
||||||
|
loadingSelectedVersion.setNotLoading();
|
||||||
|
forceUpdate();
|
||||||
|
})();
|
||||||
|
};
|
||||||
|
|
||||||
|
function getVersionsList(versionRecordList: QRecord[], selectedVersionRecord: QRecord)
|
||||||
|
{
|
||||||
|
return <List sx={{pl: 3, height: "400px", overflow: "auto"}}>
|
||||||
|
{
|
||||||
|
(versionRecordList == null || versionRecordList.length == 0) ?
|
||||||
|
<Typography variant="body2">
|
||||||
|
There are not any versions of this data bag.
|
||||||
|
</Typography>
|
||||||
|
: <></>
|
||||||
|
}
|
||||||
|
{
|
||||||
|
versionRecordList?.map((version: any) => (
|
||||||
|
<React.Fragment key={version.values.get("id")}>
|
||||||
|
<ListItem sx={{p: 1}} alignItems="flex-start" selected={selectedVersionRecord?.values?.get("id") == version.values.get("id")} onClick={(event) => selectVersion(version)}>
|
||||||
|
<ListItemAvatar>
|
||||||
|
<Avatar sx={{bgcolor: DeveloperModeUtils.revToColor("", dataBagId, version.values.get("sequenceNo"))}}>{`${version.values.get("sequenceNo")}`}</Avatar>
|
||||||
|
</ListItemAvatar>
|
||||||
|
<ListItemText
|
||||||
|
primaryTypographyProps={{fontSize: "1rem"}}
|
||||||
|
secondaryTypographyProps={{fontSize: ".85rem"}}
|
||||||
|
primary={
|
||||||
|
<div style={{whiteSpace: "nowrap", overflow: "hidden", textOverflow: "ellipsis"}} title={version.values.get("commitMessage")}>
|
||||||
|
{currentVersionId == version?.values?.get("id") && <Chip label="CURRENT" color="success" variant="outlined" size="small" sx={{mr: 1, fontSize: "0.75rem"}} />}
|
||||||
|
{version.values.get("commitMessage")}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
secondary={
|
||||||
|
<>
|
||||||
|
{ValueUtils.formatDateTime(version.values.get("createDate"))}
|
||||||
|
<br />
|
||||||
|
{version.values.get("author")}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ListItem>
|
||||||
|
<Divider sx={{my: 0.5}} variant="inset" component="li" />
|
||||||
|
</React.Fragment>
|
||||||
|
))
|
||||||
|
}
|
||||||
|
</List>;
|
||||||
|
}
|
||||||
|
|
||||||
|
let editButtonTooltip = "";
|
||||||
|
let editButtonText = "Create New Version";
|
||||||
|
if (currentVersionId)
|
||||||
|
{
|
||||||
|
if (currentVersionId === selectedVersionRecord?.values?.get("id"))
|
||||||
|
{
|
||||||
|
editButtonTooltip = "If you make any changes to this data bag, a new version will be created when you hit Save.";
|
||||||
|
editButtonText = "Edit";
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
editButtonTooltip = "If you want to make this previous Version active, bring up the Edit window, make any changes " +
|
||||||
|
"to the old Version if they are needed, then click Save. A new Version will be created, and set as Current.";
|
||||||
|
editButtonText = "Edit and Activate";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Grid container>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<Box>
|
||||||
|
{
|
||||||
|
notFoundMessage
|
||||||
|
?
|
||||||
|
<Box>{notFoundMessage}</Box>
|
||||||
|
:
|
||||||
|
<Box>
|
||||||
|
{
|
||||||
|
successText ? (
|
||||||
|
<Snackbar open={successText !== null && successText !== ""} autoHideDuration={6000} onClose={() => setSuccessText(null)} anchorOrigin={{vertical: "top", horizontal: "center"}}>
|
||||||
|
<Alert color="success" onClose={() => setSuccessText(null)}>
|
||||||
|
{successText}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
) : ("")
|
||||||
|
}
|
||||||
|
{
|
||||||
|
failText ? (
|
||||||
|
<Snackbar open={failText !== null && failText !== ""} autoHideDuration={6000} onClose={() => setFailText(null)} anchorOrigin={{vertical: "top", horizontal: "center"}}>
|
||||||
|
<Alert color="error" onClose={() => setFailText(null)}>
|
||||||
|
{failText}
|
||||||
|
</Alert>
|
||||||
|
</Snackbar>
|
||||||
|
) : ("")
|
||||||
|
}
|
||||||
|
|
||||||
|
<Grid container spacing={3}>
|
||||||
|
<Grid item xs={12}>
|
||||||
|
<>
|
||||||
|
<Box display="flex" alignItems="center" justifyContent="space-between" gap={2} mt={-6}>
|
||||||
|
<Typography variant="h5" p={2}></Typography>
|
||||||
|
<Tabs
|
||||||
|
sx={{mr: 1}}
|
||||||
|
value={selectedTab}
|
||||||
|
onChange={(event, newValue) => changeTab(newValue)}
|
||||||
|
variant="standard"
|
||||||
|
>
|
||||||
|
<Tab label="Raw Data" id="simple-tab-0" aria-controls="simple-tabpanel-0" sx={{width: "150px"}} />
|
||||||
|
<Tab label="Data Preview" id="simple-tab-1" aria-controls="simple-tabpanel-1" sx={{width: "150px"}} />
|
||||||
|
</Tabs>
|
||||||
|
</Box>
|
||||||
|
|
||||||
|
<TabPanel index={0} value={selectedTab}>
|
||||||
|
<Grid container>
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<Box display="flex" alignItems="center" gap={2} pb={1} height="40px">
|
||||||
|
<Typography variant="h6" pl={3}>Versions</Typography>
|
||||||
|
</Box>
|
||||||
|
{getVersionsList(versionRecordList, selectedVersionRecord)}
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
<Grid item xs={8}>
|
||||||
|
<Box display="flex" alignItems="center" justifyContent="space-between" gap={2} pb={1} height="40px">
|
||||||
|
{
|
||||||
|
selectedVersionRecord ?
|
||||||
|
<Typography variant="h6">
|
||||||
|
Version {selectedVersionRecord.values.get("sequenceNo")}
|
||||||
|
{
|
||||||
|
currentVersionId === selectedVersionRecord.values.get("id")
|
||||||
|
? (<> (Current)</>)
|
||||||
|
: <></>
|
||||||
|
}
|
||||||
|
</Typography>
|
||||||
|
: <></>
|
||||||
|
}
|
||||||
|
<CustomWidthTooltip title={editButtonTooltip}>
|
||||||
|
<Button sx={{py: 0}} onClick={() => editData(selectedVersionRecord?.values?.get("data"))}>
|
||||||
|
{editButtonText}
|
||||||
|
</Button>
|
||||||
|
</CustomWidthTooltip>
|
||||||
|
</Box>
|
||||||
|
{
|
||||||
|
loadingSelectedVersion.isNotLoading() && selectedVersionRecord && selectedVersionRecord.values.get("data") ? (
|
||||||
|
<>
|
||||||
|
<AceEditor
|
||||||
|
mode="json"
|
||||||
|
theme="github"
|
||||||
|
name={"viewData"}
|
||||||
|
readOnly
|
||||||
|
highlightActiveLine={false}
|
||||||
|
editorProps={{$blockScrolling: true}}
|
||||||
|
width="100%"
|
||||||
|
height="400px"
|
||||||
|
value={selectedVersionRecord?.values?.get("data")}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
) : null
|
||||||
|
}
|
||||||
|
{
|
||||||
|
loadingSelectedVersion.isLoadingSlow() && <Box fontSize="14px" pl={3}>Loading...</Box>
|
||||||
|
}
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</TabPanel>
|
||||||
|
<TabPanel index={1} value={selectedTab}>
|
||||||
|
<Grid container height="440px">
|
||||||
|
<Grid item xs={4}>
|
||||||
|
<Box display="flex" alignItems="center" gap={2} pb={1} height="40px">
|
||||||
|
<Typography variant="h6" pl={3}>Versions</Typography>
|
||||||
|
</Box>
|
||||||
|
{getVersionsList(versionRecordList, selectedVersionRecord)}
|
||||||
|
</Grid>
|
||||||
|
<Grid item xs={8}>
|
||||||
|
<Box display="flex" alignItems="center" gap={2} pb={1} height="40px">
|
||||||
|
<Typography variant="h6" pl={3}>Data Preview (Version {selectedVersionRecord?.values?.get("sequenceNo")})</Typography>
|
||||||
|
</Box>
|
||||||
|
<Box height="400px" overflow="auto" ml={1} fontSize="14px">
|
||||||
|
{loadingSelectedVersion.isNotLoading() && selectedTab == 1 && selectedVersionRecord?.values?.get("data") && <DataBagPreview json={selectedVersionRecord?.values?.get("data")} /> }
|
||||||
|
{loadingSelectedVersion.isLoadingSlow() && <Box pl={3}>Loading...</Box>}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</TabPanel>
|
||||||
|
</>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
|
||||||
|
{
|
||||||
|
editorProps &&
|
||||||
|
<Modal open={editorProps !== null} onClose={(event, reason) => closeEditingScript(event, reason)}>
|
||||||
|
<DataBagDataEditor
|
||||||
|
closeCallback={closeEditingScript}
|
||||||
|
{... editorProps}
|
||||||
|
/>
|
||||||
|
</Modal>
|
||||||
|
}
|
||||||
|
|
||||||
|
</Box>
|
||||||
|
}
|
||||||
|
</Box>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
);
|
||||||
|
}
|
88
src/qqq/models/LoadingState.ts
Normal file
88
src/qqq/models/LoadingState.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
/*
|
||||||
|
* QQQ - Low-code Application Framework for Engineers.
|
||||||
|
* Copyright (C) 2021-2023. Kingsrook, LLC
|
||||||
|
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
|
||||||
|
* contact@kingsrook.com
|
||||||
|
* https://github.com/Kingsrook/
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
|
||||||
|
/*******************************************************************************
|
||||||
|
** Object to represent when data is loading, e.g., from backend, and when you
|
||||||
|
** want to be able to show an empty state at first, but then after a bit, switch
|
||||||
|
** to a "loading..." type UI (the idea being, to not immediately show that "loading"
|
||||||
|
** UI, for cases where that would cause maybe an undesirable flickering.
|
||||||
|
**
|
||||||
|
** To use:
|
||||||
|
** - Add 1 or more LoadingState objects as state variables in your component.
|
||||||
|
** Note that you don't generally need to ever "set" these states.
|
||||||
|
** Provide a "useReducer"/"forceUpdate" function to the constructor, and the initial state (default is "notLoading"), e.g.:
|
||||||
|
** const [loadingSelectedVersion, _] = useState(new LoadingState(forceUpdate, "loading"));
|
||||||
|
** - Before an `await`, call `.setLoading()` on your LoadingState instance.
|
||||||
|
** Internally, that call will set a timeout that will switch to the loadingSlow state.
|
||||||
|
** - After the `await`, call `.setNotLoading()` on your LoadingState instance.
|
||||||
|
** Internally, that call will cancel the timeout that would have put us in loadingSlow.
|
||||||
|
** (Assume you'll also have set some other state based on the data that came back from the await,
|
||||||
|
** and that will trigger the next render - or - do you have make a forceUpdate() call there anyway?)
|
||||||
|
** - In your template, before your "loaded" view, check for `myLoadingState.isNotLoading()`, e.g.
|
||||||
|
** {myLoadingState.isNotLoading() && myData && <Box>...
|
||||||
|
** - In your template, before your "slow loading" view, check for `myLoadingState.isLoadingSlow()`, e.g.
|
||||||
|
** {myLoadingState.isLoadingSlow() && <Spinner />}
|
||||||
|
*******************************************************************************/
|
||||||
|
export class LoadingState
|
||||||
|
{
|
||||||
|
private state: "notLoading" | "loading" | "slow"
|
||||||
|
private slowTimeout: any;
|
||||||
|
private forceUpdate: () => void
|
||||||
|
|
||||||
|
constructor(forceUpdate: () => void, initialState: "notLoading" | "loading" | "slow" = "notLoading")
|
||||||
|
{
|
||||||
|
this.forceUpdate = forceUpdate;
|
||||||
|
this.state = initialState;
|
||||||
|
}
|
||||||
|
|
||||||
|
public setLoading()
|
||||||
|
{
|
||||||
|
this.state = "loading";
|
||||||
|
this.slowTimeout = setTimeout(() =>
|
||||||
|
{
|
||||||
|
this.state = "slow";
|
||||||
|
this.forceUpdate();
|
||||||
|
}, 1000);
|
||||||
|
}
|
||||||
|
|
||||||
|
public setNotLoading()
|
||||||
|
{
|
||||||
|
clearTimeout(this.slowTimeout);
|
||||||
|
this.state = "notLoading";
|
||||||
|
}
|
||||||
|
|
||||||
|
public isLoading(): boolean
|
||||||
|
{
|
||||||
|
return (this.state == "loading");
|
||||||
|
}
|
||||||
|
|
||||||
|
public isLoadingSlow(): boolean
|
||||||
|
{
|
||||||
|
return (this.state == "slow");
|
||||||
|
}
|
||||||
|
|
||||||
|
public isNotLoading(): boolean
|
||||||
|
{
|
||||||
|
return (this.state == "notLoading");
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -311,3 +311,16 @@ input[type="search"]::-webkit-search-results-decoration { display: none; }
|
|||||||
opacity: 1;
|
opacity: 1;
|
||||||
border-right: 0.0625rem solid rgb(222, 226, 230);
|
border-right: 0.0625rem solid rgb(222, 226, 230);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.fieldLabel
|
||||||
|
{
|
||||||
|
color: rgb(52, 71, 103);
|
||||||
|
font-weight: 700;
|
||||||
|
padding-right: .5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.fieldValue
|
||||||
|
{
|
||||||
|
color: rgb(123, 128, 154);
|
||||||
|
font-weight: 400;
|
||||||
|
}
|
@ -23,7 +23,7 @@
|
|||||||
export default class DeveloperModeUtils
|
export default class DeveloperModeUtils
|
||||||
{
|
{
|
||||||
|
|
||||||
public static revToColor = (fieldName: string, recordId: string, rev: number): string =>
|
public static revToColor = (fieldName: string, recordId: string | number, rev: number): string =>
|
||||||
{
|
{
|
||||||
let hash = 0;
|
let hash = 0;
|
||||||
let idFactor = 1;
|
let idFactor = 1;
|
||||||
|
Reference in New Issue
Block a user