diff --git a/src/qqq/components/sharing/ShareModal.tsx b/src/qqq/components/sharing/ShareModal.tsx new file mode 100644 index 0000000..f8450de --- /dev/null +++ b/src/qqq/components/sharing/ShareModal.tsx @@ -0,0 +1,481 @@ +/* + * QQQ - Low-code Application Framework for Engineers. + * Copyright (C) 2021-2024. 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 {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; +import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete"; +import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError"; +import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; +import {Alert} from "@mui/material"; +import Autocomplete from "@mui/material/Autocomplete"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Card from "@mui/material/Card"; +import Icon from "@mui/material/Icon"; +import Modal from "@mui/material/Modal"; +import TextField from "@mui/material/TextField"; +import Tooltip from "@mui/material/Tooltip/Tooltip"; +import Typography from "@mui/material/Typography"; +import FormData from "form-data"; +import colors from "qqq/assets/theme/base/colors"; +import {QCancelButton} from "qqq/components/buttons/DefaultButtons"; +import Client from "qqq/utils/qqq/Client"; +import React, {useEffect, useReducer, useState} from "react"; + +interface ShareModalProps +{ + open: boolean; + onClose: () => void; + tableMetaData: QTableMetaData; + record: QRecord; +} + +ShareModal.defaultProps = {}; + + +interface CurrentShare +{ + shareId: any; + scopeId: string; + audienceType: string; + audienceId: any; + audienceLabel: string; +} + +interface Scope +{ + id: string; + label: string; +} + +const scopeOptions: Scope[] = [ + {id: "READ_ONLY", label: "Read-Only"}, + {id: "READ_WRITE", label: "Read and Edit"} +]; + +const defaultScope = scopeOptions[0]; + +const qController = Client.getInstance(); + +/******************************************************************************* + ** component containing a Modal dialog for sharing records + *******************************************************************************/ +export default function ShareModal({open, onClose, tableMetaData, record}: ShareModalProps): JSX.Element +{ + const [statusString, setStatusString] = useState("Loading..."); + const [alert, setAlert] = useState(null as string); + + const [selectedAudienceType, setSelectedAudienceType] = useState(null); + const [selectedAudienceId, setSelectedAudienceId] = useState(null); + const [selectedScopeId, setSelectedScopeId] = useState(defaultScope.id); + const [submitting, setSubmitting] = useState(false); + + const [currentShares, setCurrentShares] = useState([] as CurrentShare[]) + const [needToLoadCurrentShares, setNeedToLoadCurrentShares] = useState(true); + const [everLoadedCurrentShares, setEverLoadedCurrentShares] = useState(false); + + const [, forceUpdate] = useReducer((x) => x + 1, 0); + + + ///////////////////////////////////////////////////////// + // trigger initial load, and post any changes, re-load // + ///////////////////////////////////////////////////////// + useEffect(() => + { + if(needToLoadCurrentShares) + { + loadShares(); + } + }, [needToLoadCurrentShares]); + + + /******************************************************************************* + ** + *******************************************************************************/ + function close(event: object, reason: string) + { + if (reason === "backdropClick" || reason === "escapeKeyDown") + { + return; + } + + onClose(); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function handleAudienceChange(event: React.SyntheticEvent, value: any | any[], reason: string) + { + if(value) + { + const [audienceType, audienceId] = value.id.split(":"); + setSelectedAudienceType(audienceType); + setSelectedAudienceId(audienceId); + } + else + { + setSelectedAudienceType(null); + setSelectedAudienceId(null); + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function handleScopeChange(event: React.SyntheticEvent, value: any | any[], reason: string) + { + if(value) + { + setSelectedScopeId(value.id); + } + else + { + setSelectedScopeId(null); + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + async function editingExistingShareScope(shareId: number, value: any | any[]) + { + setStatusString("Saving..."); + setAlert(null); + + const formData = new FormData(); + formData.append("tableName", tableMetaData.name); + formData.append("recordId", record.values.get(tableMetaData.primaryKeyField)); + formData.append("shareId", shareId); + formData.append("scopeId", value.id); + + const processResult = await qController.processRun("editSharedRecord", formData, null, true); + + if (processResult instanceof QJobError) + { + const jobError = processResult as QJobError; + setStatusString(null); + setAlert("Error editing shared record: " + jobError.error); + setSubmitting(false) + } + else + { + const result = processResult as QJobComplete; + setStatusString(null); + setAlert(null); + setNeedToLoadCurrentShares(true); + setSubmitting(false) + } + } + + + + /******************************************************************************* + ** + *******************************************************************************/ + async function loadShares() + { + setNeedToLoadCurrentShares(false); + + const formData = new FormData(); + formData.append("tableName", tableMetaData.name); + formData.append("recordId", record.values.get(tableMetaData.primaryKeyField)); + const processResult = await qController.processRun("getSharedRecords", formData, null, true); + + setStatusString("Loading..."); + setAlert(null) + + if (processResult instanceof QJobError) + { + const jobError = processResult as QJobError; + setStatusString(null); + setAlert("Error loading: " + jobError.error); + } + else + { + const result = processResult as QJobComplete; + + const newCurrentShares: CurrentShare[] = []; + for (let i in result.values["resultList"]) + { + newCurrentShares.push(result.values["resultList"][i].values); + } + setCurrentShares(newCurrentShares); + setEverLoadedCurrentShares(true); + + setStatusString(null); + setAlert(null); + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + async function saveNewShare() + { + setSubmitting(true) + setStatusString("Saving..."); + setAlert(null); + + const formData = new FormData(); + formData.append("tableName", tableMetaData.name); + formData.append("recordId", record.values.get(tableMetaData.primaryKeyField)); + formData.append("audienceType", selectedAudienceType); + formData.append("audienceId", selectedAudienceId); + formData.append("scopeId", selectedScopeId); + + const processResult = await qController.processRun("insertSharedRecord", formData, null, true); + + if (processResult instanceof QJobError) + { + const jobError = processResult as QJobError; + setStatusString(null); + setAlert("Error sharing record: " + jobError.error); + setSubmitting(false) + } + else + { + const result = processResult as QJobComplete; + setStatusString(null); + setAlert(null); + setNeedToLoadCurrentShares(true); + setSubmitting(false) + } + } + + + /******************************************************************************* + ** + *******************************************************************************/ + async function removeShare(shareId: number) + { + setStatusString("Deleting..."); + setAlert(null); + + const formData = new FormData(); + formData.append("tableName", tableMetaData.name); + formData.append("recordId", record.values.get(tableMetaData.primaryKeyField)); + formData.append("shareId", shareId); + + const processResult = await qController.processRun("deleteSharedRecord", formData, null, true); + + if (processResult instanceof QJobError) + { + const jobError = processResult as QJobError; + setStatusString(null); + setAlert("Error deleting share: " + jobError.error); + } + else + { + const result = processResult as QJobComplete; + setNeedToLoadCurrentShares(true); + setStatusString(null); + setAlert(null); + } + } + + + // todo - need this to be real + const audienceOptions = [ + {id: "user:1", label: "Darin Kelkhoff"}, + {id: "user:2", label: "Tom Chutterloin"}, + {id: "user:3", label: "Tylers Ample"}, + {id: "user:4", label: "Mames Mames"}, + {id: "group:2", label: "Cold Track Engineering"} + ]; + + + /******************************************************************************* + ** + *******************************************************************************/ + function getScopeOption(scopeId: string): Scope + { + for (let scopeOption of scopeOptions) + { + if(scopeOption.id == scopeId) + { + return (scopeOption); + } + } + + return (null); + } + + + /******************************************************************************* + ** + *******************************************************************************/ + function renderScopeDropdown(id: string, defaultValue: Scope, onChange: (event: React.SyntheticEvent, value: any | any[], reason: string) => void) + { + return ( + ()} + options={scopeOptions} + // @ts-ignore + defaultValue={defaultValue} + onChange={onChange} + isOptionEqualToValue={(option, value) => option.id === value.id} + // @ts-ignore Property label does not exist on string | {thing with label} + getOptionLabel={(option) => option.label} + autoSelect={true} + autoHighlight={true} + disableClearable + fullWidth + sx={autocompleteSX} + /> + ); + } + + ////////////////////// + // render the modal // + ////////////////////// + return ( +
+ + + + {/* header */} + + + Share {tableMetaData.label}: {record?.recordLabel ?? record?.values?.get(tableMetaData.primaryKeyField) ?? "Unknown"} + + {/* todo move to helpContent (what do we attach the meta-data too??) */} + Select a user or a group to share this record with. + You can choose if they should only be able to Read the record, or also make Edits to it. + + + {alert && setAlert(null)}>{alert}} + {statusString}  + + + + + {/* body */} + + {/* row for adding a new share */} + + + ()} + options={audienceOptions} + onChange={handleAudienceChange} + isOptionEqualToValue={(option, value) => option.id === value.id} + // @ts-ignore Property label does not exist on string | {thing with label} + getOptionLabel={(option) => option.label} + autoSelect={true} + autoHighlight={true} + disableClearable + fullWidth + sx={autocompleteSX} + /> + + + {renderScopeDropdown("new-share-scope", defaultScope, handleScopeChange)} + + + + + + + + + + + {/* row showing existing shares */} + + +
Current Shares + { + everLoadedCurrentShares ? <> ({currentShares.length}) : <> + } +
+
+ + { + currentShares.map((share) => ( + + + {share.audienceLabel} + {renderScopeDropdown(`scope-${share.shareId}`, getScopeOption(share.scopeId), (event: React.SyntheticEvent, value: any | any[], reason: string) => editingExistingShareScope(share.shareId, value))} + + + + + + )) + } + +
+ +
+ + {/* footer */} + + close(null, null)} disabled={false} /> + +
+
+
+
); + +} + +const autocompleteSX = + { + "& .MuiAutocomplete-input": {padding: "0.125rem 0.5rem !important"}, + "& .MuiOutlinedInput-root": {borderRadius: "0.75rem !important"} + }; + +const iconButtonSX = + { + border: `1px solid ${colors.grayLines.main} !important`, + borderRadius: "0.75rem", + textTransform: "none", + fontSize: "1rem", + fontWeight: "400", + width: "40px", + minWidth: "40px", + paddingLeft: 0, + paddingRight: 0, + color: colors.secondary.main, + "&:hover": {color: colors.secondary.main}, + "&:focus": {color: colors.secondary.main}, + "&:focus:not(:hover)": {color: colors.secondary.main}, + }; + +const redIconButton = + { + color: colors.error.main, + "&:hover": {color: colors.error.main}, + "&:focus": {color: colors.error.main}, + "&:focus:not(:hover)": {color: colors.error.main}, + }; +