diff --git a/src/qqq/components/databags/DataBagDataEditor.tsx b/src/qqq/components/databags/DataBagDataEditor.tsx new file mode 100644 index 0000000..8b7b115 --- /dev/null +++ b/src/qqq/components/databags/DataBagDataEditor.tsx @@ -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 . + */ + +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, 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) => + { + setCommitMessage(event.target.value); + } + + return ( + + + + + { + if (reason === "clickaway") + { + return; + } + setErrorAlert("") + }} anchorOrigin={{vertical: "top", horizontal: "center"}}> + setErrorAlert("")}> + {errorAlert} + + + + + + {title} + + + + + Tools: + + + Preview + + + + + + + + + { + openTool && + + { + openTool == "preview" && + + + + } + + } + + + + + + + + + + + + + + + ); +} + +export default DataBagDataEditor; diff --git a/src/qqq/components/databags/DataBagPreview.tsx b/src/qqq/components/databags/DataBagPreview.tsx new file mode 100644 index 0000000..e0f3a04 --- /dev/null +++ b/src/qqq/components/databags/DataBagPreview.tsx @@ -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 . + */ + + +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) + 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 ( + <> + + { + Object.keys(object).map((key: any, index: any) => + { + const divId = `${path}.${key}` + const childIsObject = (typeof object[key] == "object"); + return ( + + + { + childIsObject + ? togglePreviewDiv(divId)} style={{cursor: "pointer"}}>{openPreviewDivs.has(divId) ? "expand_more" : "chevron_right"} + : + } + { + childIsObject + ? togglePreviewDiv(divId)} style={{"cursor": "pointer"}}>{key}: + : {key}: + } + { + childIsObject && openPreviewDivs.has(divId) && {previewObject(object[key], `${path}.${key}`)} + } + { + !childIsObject && {object[key]} + } + + + ); + }) + } + + + ) + } + else + { + return (<>{object}) + } + } + + const getDataBagPreview = (data: any): JSX.Element=> + { + console.log("getDataBagPreview:") + return previewObject(data, ""); + } + + return ( + <> + {errorMessage == null && dataToRender && getDataBagPreview(dataToRender)} + {errorMessage && {errorMessage}} + + ) +} + +export default DataBagPreview; diff --git a/src/qqq/components/widgets/DashboardWidgets.tsx b/src/qqq/components/widgets/DashboardWidgets.tsx index b64e2e8..df3d149 100644 --- a/src/qqq/components/widgets/DashboardWidgets.tsx +++ b/src/qqq/components/widgets/DashboardWidgets.tsx @@ -36,6 +36,7 @@ import DefaultLineChart from "qqq/components/widgets/charts/linechart/DefaultLin import SmallLineChart from "qqq/components/widgets/charts/linechart/SmallLineChart"; import PieChart from "qqq/components/widgets/charts/piechart/PieChart"; 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 FieldValueListWidget from "qqq/components/widgets/misc/FieldValueListWidget"; 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 && + + + + ) + } ); } diff --git a/src/qqq/components/widgets/misc/DataBagViewer.tsx b/src/qqq/components/widgets/misc/DataBagViewer.tsx new file mode 100644 index 0000000..cdcc604 --- /dev/null +++ b/src/qqq/components/widgets/misc/DataBagViewer.tsx @@ -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 . + */ + +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 + { + (versionRecordList == null || versionRecordList.length == 0) ? + + There are not any versions of this data bag. + + : <> + } + { + versionRecordList?.map((version: any) => ( + + selectVersion(version)}> + + {`${version.values.get("sequenceNo")}`} + + + {currentVersionId == version?.values?.get("id") && } + {version.values.get("commitMessage")} + + } + secondary={ + <> + {ValueUtils.formatDateTime(version.values.get("createDate"))} +
+ {version.values.get("author")} + + } + /> +
+ +
+ )) + } +
; + } + + 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 ( + + + + { + notFoundMessage + ? + {notFoundMessage} + : + + { + successText ? ( + setSuccessText(null)} anchorOrigin={{vertical: "top", horizontal: "center"}}> + setSuccessText(null)}> + {successText} + + + ) : ("") + } + { + failText ? ( + setFailText(null)} anchorOrigin={{vertical: "top", horizontal: "center"}}> + setFailText(null)}> + {failText} + + + ) : ("") + } + + + + <> + + + changeTab(newValue)} + variant="standard" + > + + + + + + + + + + Versions + + {getVersionsList(versionRecordList, selectedVersionRecord)} + + + + + { + selectedVersionRecord ? + + Version {selectedVersionRecord.values.get("sequenceNo")} + { + currentVersionId === selectedVersionRecord.values.get("id") + ? (<> (Current)) + : <> + } + + : <> + } + + + + + { + loadingSelectedVersion.isNotLoading() && selectedVersionRecord && selectedVersionRecord.values.get("data") ? ( + <> + + + ) : null + } + { + loadingSelectedVersion.isLoadingSlow() && Loading... + } + + + + + + + + Versions + + {getVersionsList(versionRecordList, selectedVersionRecord)} + + + + Data Preview (Version {selectedVersionRecord?.values?.get("sequenceNo")}) + + + {loadingSelectedVersion.isNotLoading() && selectedTab == 1 && selectedVersionRecord?.values?.get("data") && } + {loadingSelectedVersion.isLoadingSlow() && Loading...} + + + + + + + + + { + editorProps && + closeEditingScript(event, reason)}> + + + } + + + } + + + + ); +} diff --git a/src/qqq/models/LoadingState.ts b/src/qqq/models/LoadingState.ts new file mode 100644 index 0000000..39d7d27 --- /dev/null +++ b/src/qqq/models/LoadingState.ts @@ -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 . + */ + + +/******************************************************************************* + ** 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 && ... + ** - In your template, before your "slow loading" view, check for `myLoadingState.isLoadingSlow()`, e.g. + ** {myLoadingState.isLoadingSlow() && } + *******************************************************************************/ +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"); + } + +} \ No newline at end of file diff --git a/src/qqq/styles/qqq-override-styles.css b/src/qqq/styles/qqq-override-styles.css index 590a38b..26f903c 100644 --- a/src/qqq/styles/qqq-override-styles.css +++ b/src/qqq/styles/qqq-override-styles.css @@ -310,4 +310,17 @@ input[type="search"]::-webkit-search-results-decoration { display: none; } z-index: 1; opacity: 1; 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; } \ No newline at end of file diff --git a/src/qqq/utils/DeveloperModeUtils.tsx b/src/qqq/utils/DeveloperModeUtils.tsx index e8f25b8..cbb1534 100644 --- a/src/qqq/utils/DeveloperModeUtils.tsx +++ b/src/qqq/utils/DeveloperModeUtils.tsx @@ -23,7 +23,7 @@ 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 idFactor = 1;