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 */}
+
+
+