Files
qqq-frontend-material-dashb…/src/qqq/components/sharing/ShareModal.tsx

488 lines
18 KiB
TypeScript

/*
* 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 <https://www.gnu.org/licenses/>.
*/
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, Box} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
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 DynamicSelect, {getAutocompleteOutlinedStyle} from "qqq/components/forms/DynamicSelect";
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();
interface ShareableTableMetaData
{
sharedRecordTableName: string;
assetIdFieldName: string;
scopeFieldName: string;
audienceTypesPossibleValueSourceName: string;
audiencePossibleValueSourceName: string;
thisTableOwnerIdFieldName: string;
audienceTypes: {[name: string]: any}; // values here are: ShareableAudienceType
}
/*******************************************************************************
** 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 [selectedAudienceOption, setSelectedAudienceOption] = useState(null as {id: string, label: 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 shareableTableMetaData = tableMetaData.shareableTableMetaData as ShareableTableMetaData;
const [, forceUpdate] = useReducer((x) => x + 1, 0);
if(!shareableTableMetaData)
{
console.error(`Did not find a shareableTableMetaData on table ${tableMetaData.name}`);
}
/////////////////////////////////////////////////////////
// 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(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);
setSelectedAudienceOption(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);
}
}
/*******************************************************************************
**
*******************************************************************************/
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)
{
const isDisabled = (id == "new-share-scope" && submitting);
return (
<Autocomplete
id={id}
disabled={isDisabled}
renderInput={(params) => (<TextField {...params} label="Scope" variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
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={getAutocompleteOutlinedStyle(isDisabled)}
/>
);
}
//////////////////////
// render the modal //
//////////////////////
return (<Modal open={open} onClose={close}>
<div className="share">
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%", display: "flex", height: "100%", flexDirection: "column", justifyContent: "center"}}>
<Card sx={{my: 5, mx: "auto", p: 3}}>
{/* header */}
<Box display="flex" flexDirection="row" justifyContent="space-between" alignItems="flex-start" maxWidth="590px">
<Typography variant="h4" pb={1} fontWeight="600">
Share {tableMetaData.label}: {record?.recordLabel ?? record?.values?.get(tableMetaData.primaryKeyField) ?? "Unknown"}
<Box color={colors.gray.main} pb={"0.5rem"} fontSize={"0.875rem"} fontWeight="400">
{/* 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.*/}
</Box>
<Box fontSize={14} pb={1} fontWeight="300">
{alert && <Alert color="error" onClose={() => setAlert(null)}>{alert}</Alert>}
{statusString}
{!alert && !statusString && (<>&nbsp;</>)}
</Box>
</Typography>
</Box>
{/* body */}
<Box pb={3} display="flex" flexDirection="column">
{/* row for adding a new share */}
<Box display="flex" flexDirection="row" alignItems="center">
<Box width="550px" pr={2} mb={-1.5}>
<DynamicSelect
possibleValueSourceName={shareableTableMetaData.audiencePossibleValueSourceName}
fieldLabel="User or Group" // todo should come from shareableTableMetaData
initialValue={selectedAudienceOption?.id}
initialDisplayValue={selectedAudienceOption?.label}
inForm={false}
onChange={handleAudienceChange}
/>
</Box>
{/*
when turning scope back on, change width of audience box to 350px
<Box width="180px" pr={2}>
{renderScopeDropdown("new-share-scope", defaultScope, handleScopeChange)}
</Box>
*/}
<Box>
<Tooltip title={selectedAudienceId == null ? "Select a user or group to share with." : null}>
<span>
<Button disabled={submitting || selectedAudienceId == null} sx={iconButtonSX} onClick={() => saveNewShare()}>
<Icon color={selectedAudienceId == null ? "secondary" : "info"}>save</Icon>
</Button>
</span>
</Tooltip>
</Box>
</Box>
{/* row showing existing shares */}
<Box pt={3}>
<Box pb="0.25rem">
<h5 style={{fontWeight: "600"}}>Current Shares
{
everLoadedCurrentShares ? <>&nbsp;({currentShares.length})</> : <></>
}
</h5>
</Box>
<Box sx={{border: `1px solid ${colors.grayLines.main}`, borderRadius: "1rem", overflow: "hidden"}}>
<Box sx={{overflow: "auto"}} height="210px" pt="0.75rem">
{
currentShares.map((share) => (
<Box key={share.shareId} display="flex" justifyContent="space-between" alignItems="center" p="0.25rem" pb="0.75rem" fontSize="1rem">
<Box display="flex" alignItems="center">
<Box width="490px" pl="1rem">{share.audienceLabel}</Box>
{/*
when turning scope back on, change width of audience box to 310px
<Box width="160px">{renderScopeDropdown(`scope-${share.shareId}`, getScopeOption(share.scopeId), (event: React.SyntheticEvent, value: any | any[], reason: string) => editingExistingShareScope(share.shareId, value))}</Box>
*/}
</Box>
<Box pr="1rem">
<Button sx={{...iconButtonSX, ...redIconButton}} onClick={() => removeShare(share.shareId)}><Icon>clear</Icon></Button>
</Box>
</Box>
))
}
</Box>
</Box>
</Box>
</Box>
{/* footer */}
<Box display="flex" flexDirection="row" justifyContent="flex-end">
<QCancelButton label="Done" iconName="check" onClickHandler={() => close(null, null)} disabled={false} />
</Box>
</Card>
</Box>
</div>
</Modal>);
}
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},
};