Compare commits
26
Commits
No files matched your search
+10
-12
@@ -47,12 +47,10 @@ import AppHome from "qqq/pages/apps/Home";
|
||||
import NoApps from "qqq/pages/apps/NoApps";
|
||||
import ProcessRun from "qqq/pages/processes/ProcessRun";
|
||||
import ReportRun from "qqq/pages/processes/ReportRun";
|
||||
import EntityCreate from "qqq/pages/records/create/RecordCreate";
|
||||
import RecordScreen from "qqq/pages/records/RecordScreen";
|
||||
import TableDeveloperView from "qqq/pages/records/developer/TableDeveloperView";
|
||||
import EntityEdit from "qqq/pages/records/edit/RecordEdit";
|
||||
import RecordQuery from "qqq/pages/records/query/RecordQuery";
|
||||
import RecordDeveloperView from "qqq/pages/records/view/RecordDeveloperView";
|
||||
import RecordView from "qqq/pages/records/view/RecordView";
|
||||
import RecordViewByUniqueKey from "qqq/pages/records/view/RecordViewByUniqueKey";
|
||||
import {createDynamicTheme} from "qqq/utils/createDynamicTheme";
|
||||
import AnalyticsUtils, {AnalyticsModel} from "qqq/utils/analytics/AnalyticsUtils";
|
||||
@@ -258,7 +256,6 @@ export default function App({authenticationMetaData}: Props)
|
||||
}
|
||||
|
||||
let foundFirstApp = false;
|
||||
|
||||
function addAppToAppRoutesList(metaData: QInstance, app: QAppTreeNode, routeList: any[], parentPath: string, depth: number)
|
||||
{
|
||||
const path = `${parentPath}/${app.name}`;
|
||||
@@ -294,6 +291,7 @@ export default function App({authenticationMetaData}: Props)
|
||||
else if (app.type === QAppNodeType.TABLE)
|
||||
{
|
||||
const table = metaData.tables.get(app.name);
|
||||
|
||||
routeList.push({
|
||||
name: `${app.label}`,
|
||||
key: app.name,
|
||||
@@ -312,7 +310,7 @@ export default function App({authenticationMetaData}: Props)
|
||||
name: `${app.label} Create`,
|
||||
key: `${app.name}.create`,
|
||||
route: `${path}/create`,
|
||||
component: <EntityCreate table={table} />,
|
||||
component: <RecordScreen table={table} mode="create" />,
|
||||
});
|
||||
|
||||
routeList.push({
|
||||
@@ -329,14 +327,14 @@ export default function App({authenticationMetaData}: Props)
|
||||
routeList.push({
|
||||
key: `${app.name}.createChild`,
|
||||
route: `${path}/:id/createChild/:childTableName`,
|
||||
component: <RecordView table={table} />,
|
||||
component: <RecordScreen table={table} mode="view" />,
|
||||
});
|
||||
|
||||
routeList.push({
|
||||
name: `${app.label} View`,
|
||||
key: `${app.name}.view`,
|
||||
route: `${path}/:id`,
|
||||
component: <RecordView table={table} />,
|
||||
component: <RecordScreen table={table} mode="view" />,
|
||||
});
|
||||
|
||||
routeList.push({
|
||||
@@ -350,14 +348,14 @@ export default function App({authenticationMetaData}: Props)
|
||||
name: `${app.label}`,
|
||||
key: `${app.name}.edit`,
|
||||
route: `${path}/:id/edit`,
|
||||
component: <EntityEdit table={table} isCopy={false} />,
|
||||
component: <RecordScreen table={table} mode="edit" />,
|
||||
});
|
||||
|
||||
routeList.push({
|
||||
name: `${app.label}`,
|
||||
key: `${app.name}.copy`,
|
||||
route: `${path}/:id/copy`,
|
||||
component: <EntityEdit table={table} isCopy={true} />,
|
||||
component: <RecordScreen table={table} mode="create" isCopy={true} />,
|
||||
});
|
||||
|
||||
routeList.push({
|
||||
@@ -385,7 +383,7 @@ export default function App({authenticationMetaData}: Props)
|
||||
name: process.label,
|
||||
key: `${app.name}/${process.name}`,
|
||||
route: `${path}/:id/${process.name}`,
|
||||
component: <RecordView table={table} key={`${table.name}-${process.name}`} launchProcess={process} />,
|
||||
component: <RecordScreen table={table} key={`${table.name}-${process.name}`} mode="view" launchProcess={process} />,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -411,7 +409,7 @@ export default function App({authenticationMetaData}: Props)
|
||||
name: process.label,
|
||||
key: `${app.name}/${process.name}`,
|
||||
route: `${path}/:id/${process.name}`,
|
||||
component: <RecordView table={table} launchProcess={process} />,
|
||||
component: <RecordScreen table={table} mode="view" launchProcess={process} />,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -437,7 +435,7 @@ export default function App({authenticationMetaData}: Props)
|
||||
name: process.label,
|
||||
key: `${app.name}/${process.name}`,
|
||||
route: `${path}/:id/${process.name}`,
|
||||
component: <RecordView table={table} launchProcess={process} />,
|
||||
component: <RecordScreen table={table} mode="view" launchProcess={process} />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -160,6 +160,7 @@ public class MaterialDashboardInstanceMetaData implements QSupplementalInstanceM
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Getter for recordViewActionsPlacement
|
||||
*******************************************************************************/
|
||||
|
||||
+1
@@ -418,6 +418,7 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
|
||||
|
||||
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
*
|
||||
***************************************************************************/
|
||||
|
||||
@@ -97,15 +97,17 @@ export function QDeleteButton({onClickHandler, disabled}: QDeleteButtonProps): J
|
||||
);
|
||||
}
|
||||
|
||||
export function QEditButton(): JSX.Element
|
||||
export function QEditButton({onClickHandler}: {onClickHandler?: () => void}): JSX.Element
|
||||
{
|
||||
const button = (
|
||||
<MDButton variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>edit</Icon>} onClick={onClickHandler}>
|
||||
Edit
|
||||
</MDButton>
|
||||
);
|
||||
|
||||
return (
|
||||
<Box ml={standardML} width={standardWidth}>
|
||||
<Link to="edit">
|
||||
<MDButton variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>edit</Icon>}>
|
||||
Edit
|
||||
</MDButton>
|
||||
</Link>
|
||||
{onClickHandler ? button : <Link to="edit">{button}</Link>}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -114,7 +114,11 @@ function BooleanFieldSwitch({name, label, value, isDisabled, onChangeCallback}:
|
||||
const classNullSwitch = (value === null || value == undefined || `${value}` == "") ? "nullSwitch" : "";
|
||||
|
||||
return (
|
||||
<Box bgcolor={isDisabled ? colors.grey[200] : ""}>
|
||||
<Box bgcolor={isDisabled ? colors.grey[200] : ""} sx={{
|
||||
".compactForm & .MuiInputLabel-root": {display: "none"},
|
||||
".compactForm & .MuiStack-root": {height: "26px"},
|
||||
".compactForm & .MuiTypography-root": {fontSize: "0.875rem"},
|
||||
}}>
|
||||
<InputLabel shrink={true}>{label}</InputLabel>
|
||||
<Stack direction="row" spacing={1} alignItems="center" height="37px">
|
||||
<Typography
|
||||
|
||||
@@ -363,7 +363,29 @@ function QDynamicFormField({
|
||||
else
|
||||
{
|
||||
return (
|
||||
<Box mb={1.5}>
|
||||
<Box sx={{
|
||||
mb: 1.5,
|
||||
".compactForm &": {mb: 0},
|
||||
".compactForm & .MuiInputBase-root": {
|
||||
height: "26px",
|
||||
borderRadius: "3px",
|
||||
},
|
||||
".compactForm & .MuiInputBase-input": {
|
||||
padding: "3px 6px",
|
||||
fontSize: "0.875rem",
|
||||
height: "18px",
|
||||
boxSizing: "content-box",
|
||||
},
|
||||
".compactForm & .MuiOutlinedInput-notchedOutline": {
|
||||
borderRadius: "3px",
|
||||
},
|
||||
".compactForm & .MuiInputLabel-root": {
|
||||
display: "none",
|
||||
},
|
||||
".compactForm & .fieldErrorMessage": {
|
||||
display: "none",
|
||||
},
|
||||
}}>
|
||||
{field}
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -92,7 +92,26 @@ export const getAutocompleteOutlinedStyle = (isDisabled: boolean) =>
|
||||
},
|
||||
"& .Mui-disabled .MuiOutlinedInput-notchedOutline": {
|
||||
borderColor: inputBorderColor
|
||||
}
|
||||
},
|
||||
".compactForm &": {
|
||||
margin: 0,
|
||||
},
|
||||
".compactForm & .MuiInputBase-root": {
|
||||
padding: "0 6px",
|
||||
},
|
||||
".compactForm & .MuiOutlinedInput-root": {
|
||||
borderRadius: "3px",
|
||||
minHeight: "26px",
|
||||
},
|
||||
".compactForm & .MuiOutlinedInput-root .MuiAutocomplete-input": {
|
||||
fontSize: "0.875rem",
|
||||
},
|
||||
".compactForm & .MuiAutocomplete-tag": {
|
||||
padding: "0 0.25rem",
|
||||
},
|
||||
".compactForm & .MuiFormControl-root": {
|
||||
margin: 0,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -61,12 +61,34 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
|
||||
const [openMenu, setOpenMenu] = useState<any>(false);
|
||||
const [history, setHistory] = useState<any>([] as HistoryEntry[]);
|
||||
const [autocompleteValue, setAutocompleteValue] = useState<any>(null);
|
||||
const fullPath = useLocation().pathname;
|
||||
const route = useLocation().pathname.split("/").slice(1);
|
||||
const locationPath = useLocation().pathname;
|
||||
const [fullPath, setFullPath] = useState(locationPath);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {pageHeader, pageHeaderRightContent, setDotMenuOpen} = useContext(QContext);
|
||||
|
||||
// Sync fullPath with React Router location changes
|
||||
useEffect(() =>
|
||||
{
|
||||
setFullPath(locationPath);
|
||||
}, [locationPath]);
|
||||
|
||||
// Listen for manual pushState/replaceState URL changes (e.g., entering/leaving edit mode)
|
||||
// and popstate (e.g., history.back() from cancel in edit mode)
|
||||
useEffect(() =>
|
||||
{
|
||||
const handleUrlChanged = () => setFullPath(window.location.pathname);
|
||||
window.addEventListener("urlchanged", handleUrlChanged);
|
||||
window.addEventListener("popstate", handleUrlChanged);
|
||||
return () =>
|
||||
{
|
||||
window.removeEventListener("urlchanged", handleUrlChanged);
|
||||
window.removeEventListener("popstate", handleUrlChanged);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const route = fullPath.split("/").slice(1);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
// Setting the navbar type
|
||||
|
||||
@@ -35,7 +35,7 @@ import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip";
|
||||
import RecordGridWidget, {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget";
|
||||
import {ProcessSummaryLine} from "qqq/models/processes/ProcessSummaryLine";
|
||||
import {renderSectionOfFields} from "qqq/pages/records/view/RecordView";
|
||||
import {renderSectionOfFields} from "qqq/utils/qqq/ViewUtils";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
|
||||
@@ -50,7 +50,7 @@ export function FieldValueAsWidget({field, record}: Props): JSX.Element
|
||||
|
||||
const skeletonWrapperRef = useRef();
|
||||
|
||||
const widgetData = record.values.get(field.name);
|
||||
const widgetData = record?.values?.get(field.name);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
|
||||
@@ -26,15 +26,36 @@ import ListItemIcon from "@mui/material/ListItemIcon";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import MenuItem from "@mui/material/MenuItem";
|
||||
import {Capability} from "@qrunio/qqq-frontend-core/lib/model/metaData/Capability";
|
||||
import {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||
import {QMenu} from "@qrunio/qqq-frontend-core/lib/model/metaData/QMenu";
|
||||
import {QMenuItem} from "@qrunio/qqq-frontend-core/lib/model/metaData/QMenuItem";
|
||||
import {QProcessMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import {QActionsMenuButton} from "qqq/components/buttons/DefaultButtons";
|
||||
import {RecordViewMenuActions} from "qqq/pages/records/view/RecordView";
|
||||
import React, {useEffect, useState} from "react";
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// define an object that is passed into the components of RecordViewMenu.tsx for getting data from //
|
||||
// this component, and for making callbacks into this component to perform actions from menus. //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
export interface RecordViewMenuActions
|
||||
{
|
||||
new: (closeMenu?: () => void) => void;
|
||||
copy: (closeMenu?: () => void) => void;
|
||||
edit: () => void;
|
||||
developerMode: (closeMenu?: () => void) => void;
|
||||
audit: (closeMenu?: () => void) => void;
|
||||
delete: (closeMenu?: () => void) => void;
|
||||
runProcess: (processName: string) => void;
|
||||
downloadFileFromField: (fieldName: string, closeMenu?: () => void) => void;
|
||||
|
||||
getMetaData: () => QInstance;
|
||||
getTableProcesses: () => QProcessMetaData[];
|
||||
getGenericProcesses: () => QProcessMetaData[];
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////
|
||||
// component for the multiple additional menus //
|
||||
/////////////////////////////////////////////////
|
||||
@@ -191,13 +212,13 @@ export function RecordViewMenuItem({tableMetaData: table, menuItem, record, acti
|
||||
case "NEW":
|
||||
{
|
||||
return table?.capabilities.has(Capability.TABLE_INSERT) && table?.insertPermission
|
||||
? itemsShown.addItem(menuItem) && <MenuItem onClick={() => actions.new()} data-qqq-id="menu-item-new">{content(menuItem, "add", "New")}</MenuItem>
|
||||
? itemsShown.addItem(menuItem) && <MenuItem onClick={() => actions.new(closeMenu)} data-qqq-id="menu-item-new">{content(menuItem, "add", "New")}</MenuItem>
|
||||
: <></>;
|
||||
}
|
||||
case "COPY":
|
||||
{
|
||||
return table?.capabilities.has(Capability.TABLE_INSERT) && table?.insertPermission
|
||||
? <MenuItem onClick={() => actions.copy()} data-qqq-id="menu-item-copy">{content(menuItem, "copy", "Copy")}</MenuItem>
|
||||
? <MenuItem onClick={() => actions.copy(closeMenu)} data-qqq-id="menu-item-copy">{content(menuItem, "copy", "Copy")}</MenuItem>
|
||||
: <></>;
|
||||
}
|
||||
case "EDIT":
|
||||
@@ -214,7 +235,7 @@ export function RecordViewMenuItem({tableMetaData: table, menuItem, record, acti
|
||||
}
|
||||
case "DEVELOPER_MODE":
|
||||
{
|
||||
return <MenuItem onClick={() => actions.developerMode()}>{content(menuItem, "code", "Developer Mode")}</MenuItem>;
|
||||
return <MenuItem onClick={() => actions.developerMode(closeMenu)}>{content(menuItem, "code", "Developer Mode")}</MenuItem>;
|
||||
}
|
||||
case "AUDIT":
|
||||
{
|
||||
|
||||
@@ -24,12 +24,11 @@ import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import {Alert, Skeleton} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Modal from "@mui/material/Modal";
|
||||
import Tab from "@mui/material/Tab";
|
||||
import Tabs from "@mui/material/Tabs";
|
||||
import parse from "html-react-parser";
|
||||
import QContext from "QContext";
|
||||
import EntityForm from "qqq/components/forms/EntityForm";
|
||||
import RecordScreenModal from "qqq/pages/records/RecordScreenModal";
|
||||
import MDTypography from "qqq/components/legacy/MDTypography";
|
||||
import TabPanel from "qqq/components/misc/TabPanel";
|
||||
import BarChart from "qqq/components/widgets/charts/barchart/BarChart";
|
||||
@@ -84,6 +83,7 @@ interface Props
|
||||
initialWidgetDataList: any[];
|
||||
values?: { [key: string]: any };
|
||||
screen: WidgetScreenType;
|
||||
addSubValidations?: (name: string, validations: Record<string, any>) => void;
|
||||
}
|
||||
|
||||
DashboardWidgets.defaultProps = {
|
||||
@@ -102,7 +102,7 @@ DashboardWidgets.defaultProps = {
|
||||
screen: "dashboard",
|
||||
};
|
||||
|
||||
function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, record, omitWrappingGridContainer, omitPaddingOnWidget, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels, actionCallback, initialWidgetDataList, values, screen}: Props): JSX.Element
|
||||
function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, record, omitWrappingGridContainer, omitPaddingOnWidget, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels, actionCallback, initialWidgetDataList, values, screen, addSubValidations}: Props): JSX.Element
|
||||
{
|
||||
const [widgetData, setWidgetData] = useState(initialWidgetDataList == null ? [] as any[] : initialWidgetDataList);
|
||||
const [errorsLoading, setErrorsLoading] = useState([] as any[]);
|
||||
@@ -150,7 +150,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
// sure that we keep the dep the same as before for all use-cases (screens) other than "recordView" //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
const widgetMetaDataListJSONString = useMemo(() => JSON.stringify(widgetMetaDataList), [widgetMetaDataList]);
|
||||
const loadWidgetsUseEffectDeps = screen == "recordView" ? [widgetMetaDataListJSONString] : [widgetMetaDataList];
|
||||
const loadWidgetsUseEffectDeps = (screen == "recordView" || screen == "recordEdit") ? [widgetMetaDataListJSONString] : [widgetMetaDataList];
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
@@ -861,7 +861,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
{
|
||||
widgetMetaData.type === "filterAndColumnsSetup" && (
|
||||
widgetData && widgetData[i] &&
|
||||
<FilterAndColumnsSetupWidget isEditable={false} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} recordValues={convertQRecordValuesFromMapToObject(record)} onSaveCallback={(values: { [name: string]: any }) =>
|
||||
<FilterAndColumnsSetupWidget isEditable={screen === "recordEdit"} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} recordValues={Object.keys(values).length > 0 ? values : convertQRecordValuesFromMapToObject(record)} onSaveCallback={(values: { [name: string]: any }) =>
|
||||
{
|
||||
if (actionCallback)
|
||||
{
|
||||
@@ -873,22 +873,26 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
{
|
||||
widgetMetaData.type === "pivotTableSetup" && (
|
||||
widgetData && widgetData[i] && widgetData[i].queryParams &&
|
||||
<PivotTableSetupWidget isEditable={false} widgetMetaData={widgetMetaData} recordValues={convertQRecordValuesFromMapToObject(record)} onSaveCallback={() =>
|
||||
<PivotTableSetupWidget isEditable={screen === "recordEdit"} widgetMetaData={widgetMetaData} recordValues={Object.keys(values).length > 0 ? values : convertQRecordValuesFromMapToObject(record)} onSaveCallback={(values: { [name: string]: any }) =>
|
||||
{
|
||||
if (actionCallback)
|
||||
{
|
||||
actionCallback(values);
|
||||
}
|
||||
}} />
|
||||
)
|
||||
}
|
||||
{
|
||||
widgetMetaData.type === "rowBuilder" && (
|
||||
(widgetData && widgetData[i]) ?
|
||||
<RowBuilderWidget widgetMetaData={widgetMetaData} widgetData={widgetData[i]} screen={screen} onSaveCallback={rowBuilderOnSaveCallback} /> :
|
||||
<RowBuilderWidget widgetMetaData={widgetMetaData} widgetData={widgetData[i]} screen={screen} onSaveCallback={rowBuilderOnSaveCallback} addSubValidations={addSubValidations} /> :
|
||||
<NotLoaded widgetMetaData={widgetMetaData} widgetIndex={i} />
|
||||
)
|
||||
}
|
||||
{
|
||||
widgetMetaData.type === "cronUI" && (
|
||||
(widgetData && widgetData[i]) ?
|
||||
<CronUIWidget screen={screen} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} recordValues={convertQRecordValuesFromMapToObject(record)} recordDisplayValueMap={record.displayValues} onSaveCallback={(values: { [name: string]: any }) =>
|
||||
<CronUIWidget screen={screen} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} recordValues={Object.keys(values).length > 0 ? values : convertQRecordValuesFromMapToObject(record)} recordDisplayValueMap={record?.displayValues} addSubValidations={addSubValidations} onSaveCallback={(values: { [name: string]: any }) =>
|
||||
{
|
||||
if (actionCallback)
|
||||
{
|
||||
@@ -1004,19 +1008,17 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
|
||||
}
|
||||
{
|
||||
showEditChildForm &&
|
||||
<Modal open={showEditChildForm as boolean} onClose={(event, reason) => closeEditChildForm(event, reason)}>
|
||||
<div className="modalEditForm">
|
||||
<EntityForm
|
||||
isModal={true}
|
||||
closeModalHandler={closeEditChildForm}
|
||||
table={showEditChildForm.table}
|
||||
defaultValues={showEditChildForm.defaultValues}
|
||||
disabledFields={showEditChildForm.disabledFields}
|
||||
onSubmitCallback={submitEditChildForm}
|
||||
overrideHeading={`${showEditChildForm.rowIndex != null ? "Editing" : "Creating New"} ${showEditChildForm.table.label}`}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
<RecordScreenModal
|
||||
open={true}
|
||||
onClose={closeEditChildForm}
|
||||
tableName={showEditChildForm.table?.name}
|
||||
defaultValues={showEditChildForm.defaultValues}
|
||||
disabledFields={showEditChildForm.disabledFields}
|
||||
onSubmitCallback={submitEditChildForm}
|
||||
overrideHeading={`${showEditChildForm.rowIndex != null ? "Editing" : "Creating New"} ${showEditChildForm.table?.label}`}
|
||||
saveButtonLabel="OK"
|
||||
saveButtonIcon="check"
|
||||
/>
|
||||
}
|
||||
</>
|
||||
) : null
|
||||
|
||||
@@ -45,7 +45,7 @@ import {WidgetScreenType} from "qqq/components/widgets/DashboardWidgets";
|
||||
import Widget from "qqq/components/widgets/Widget";
|
||||
import {WidgetUtils} from "qqq/components/widgets/WidgetUtils";
|
||||
import {FieldPossibleValueProps} from "qqq/models/fields/FieldPossibleValueProps";
|
||||
import {renderSectionOfFields} from "qqq/pages/records/view/RecordView";
|
||||
import {renderSectionOfFields} from "qqq/utils/qqq/ViewUtils";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import usePossibleValueLabels from "qqq/utils/usePossibleValueLabels";
|
||||
import {useContext, useEffect, useRef, useState} from "react";
|
||||
@@ -661,7 +661,7 @@ export default function CronUIWidget({widgetMetaData, widgetData, screen, record
|
||||
|
||||
setTimeout(() =>
|
||||
{
|
||||
const leftOffset = -9;
|
||||
const leftOffset = -11;
|
||||
let left = leftOffset + (document.getElementById("tapeMeasure")?.clientWidth) - input.scrollLeft;
|
||||
if (left < leftOffset)
|
||||
{
|
||||
@@ -827,7 +827,7 @@ export default function CronUIWidget({widgetMetaData, widgetData, screen, record
|
||||
additionalCallbacks={{onTextSelect: advancedTextSelect, onBlur: advancedTextBlur}} />
|
||||
{
|
||||
(doAdvancedCaratPositionTooltip && advancedCaretPosition) && <Box sx={{
|
||||
transition: "left .1s ease", left: `${caretTipLeft}px`, position: "absolute", top: "89px", width: "66px", lineHeight: "1.2", py: "0.25rem", textAlign: "center", fontSize: "1rem", background: "lightyellow", display: "inline-block", padding: "0.125rem", border: "1px solid rosybrown", borderRadius: "0.5rem",
|
||||
transition: "left .1s ease", left: `${caretTipLeft}px`, position: "absolute", top: "89px", width: "70px", lineHeight: "1.2", py: "0.25rem", textAlign: "center", fontSize: "1rem", background: "lightyellow", display: "inline-block", padding: "0.125rem", border: "1px solid rosybrown", borderRadius: "0.5rem",
|
||||
"&::after": {content: "\"\"", position: "absolute", left: "50%", top: "-10px", transform: "translateX(-50%)", width: "0%", height: "0%", borderLeft: "7px solid transparent", borderRight: "7px solid transparent", borderBottom: "10px solid lightyellow"},
|
||||
"&::before": {content: "\"\"", position: "absolute", left: "50%", top: "-12px", transform: "translateX(-50%)", width: "0%", height: "0%", borderLeft: "8px solid transparent", borderRight: "8px solid transparent", borderBottom: "12px solid rosybrown"},
|
||||
}}>{advancedCaretPosition}</Box>
|
||||
@@ -861,6 +861,12 @@ export default function CronUIWidget({widgetMetaData, widgetData, screen, record
|
||||
{cronDescription ? <Box>{cronDescription}</Box> : <Box color={colors.error.main}>{cronDescriptionError}</Box>}
|
||||
{loadingDescription ? <LinearProgress color="info" /> : <Box height="0.375rem"></Box>}
|
||||
</Box>
|
||||
{
|
||||
cronExpressionField?.isRequired &&
|
||||
<Typography component="div" className="fieldErrorMessage" variant="caption" color="error" fontWeight="regular">
|
||||
<ErrorMessage name={cronExpressionFieldName} render={msg => <span data-field-error={cronExpressionFieldName}>{msg}</span>} />
|
||||
</Typography>
|
||||
}
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -897,7 +903,7 @@ interface SubFormProps
|
||||
displayText: string,
|
||||
selectedValues?: string[],
|
||||
setSelectedValues?: (value: (string[])) => void,
|
||||
preOpen?: () => void
|
||||
preOpen?: () => void,
|
||||
}
|
||||
|
||||
|
||||
@@ -910,6 +916,8 @@ function SubForm({which, option, setOption, displayText, selectedValues, setSele
|
||||
{
|
||||
const [popupAnchor, setPopupAnchor] = useState(null);
|
||||
const inputRef = useRef();
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const isCompact = containerRef.current?.closest(".compactForm") != null;
|
||||
|
||||
const singularLabel = which == "day" ? "Day" : which == "hour" ? "Hour" : "Minute";
|
||||
const pluralLabel = which == "day" ? "Days" : which == "hour" ? "Hours" : "Minutes";
|
||||
@@ -1047,6 +1055,8 @@ function SubForm({which, option, setOption, displayText, selectedValues, setSele
|
||||
"& .MuiOutlinedInput-root": {borderRadius: "0.75rem"},
|
||||
"& .MuiOutlinedInput-input": {cursor: "pointer", fontSize: "1rem", padding: "0.5rem"},
|
||||
"& .MuiIcon-root": {fontSize: "1.5rem !important", fontFamily: "'Material Icons'", color: "rgba(0, 0, 0, 0.54)"},
|
||||
".compactForm & .MuiOutlinedInput-root": {borderRadius: "3px", height: "26px", minHeight: "26px"},
|
||||
".compactForm & .MuiOutlinedInput-input": {fontSize: "0.875rem", padding: "3px 6px"},
|
||||
};
|
||||
|
||||
//@ts-ignore inputRef.current
|
||||
@@ -1058,7 +1068,7 @@ function SubForm({which, option, setOption, displayText, selectedValues, setSele
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box ref={containerRef}>
|
||||
<TextField id={which} inputRef={inputRef} variant="outlined" value={displayText} fullWidth sx={textFieldSx} InputProps={{readOnly: true, endAdornment: (<Icon>{popupAnchor ? "arrow_drop_up" : "arrow_drop_down"}</Icon>)}} onClick={doOpen} style={{marginBottom: "12px"}}></TextField>
|
||||
<Popover
|
||||
anchorEl={popupAnchor}
|
||||
@@ -1066,7 +1076,7 @@ function SubForm({which, option, setOption, displayText, selectedValues, setSele
|
||||
transformOrigin={{vertical: "top", horizontal: "left"}}
|
||||
open={Boolean(popupAnchor)}
|
||||
onClose={() => doClose()}
|
||||
PaperProps={{style: {width: menuWidth, minWidth: 240, backgroundColor: "white", border: "1px solid lightgray", borderRadius: "0.75rem"}}}
|
||||
PaperProps={{className: isCompact ? "compactForm" : "", style: {width: menuWidth, minWidth: 240, backgroundColor: "white", border: "1px solid lightgray", borderRadius: isCompact ? "3px" : "0.75rem"}}}
|
||||
>
|
||||
<Box sx={boxSx}>
|
||||
<RadioGroup value={option} onChange={handleSelectRadioOpton} sx={{px: 2}}>
|
||||
|
||||
@@ -28,7 +28,7 @@ import {FormikContextType, useFormikContext} from "formik";
|
||||
import QDynamicForm from "qqq/components/forms/DynamicForm";
|
||||
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
|
||||
import Widget from "qqq/components/widgets/Widget";
|
||||
import {renderSectionOfFields} from "qqq/pages/records/view/RecordView";
|
||||
import {renderSectionOfFields} from "qqq/utils/qqq/ViewUtils";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import React, {useEffect, useState} from "react";
|
||||
|
||||
|
||||
@@ -221,8 +221,16 @@ function RecordGridWidget({widgetMetaData, data, addNewRecordCallback, disableRo
|
||||
renderCell: ((params: GridRenderCellParams) =>
|
||||
{
|
||||
return <Box>
|
||||
{allowRecordEdit && <IconButton onClick={() => editRecordCallback(params.row.__rowIndex)}><Icon>edit</Icon></IconButton>}
|
||||
{allowRecordDelete && <IconButton onClick={() => deleteRecordCallback(params.row.__rowIndex)}><Icon>delete</Icon></IconButton>}
|
||||
{allowRecordEdit && <IconButton onClick={(e) =>
|
||||
{
|
||||
e.stopPropagation();
|
||||
editRecordCallback(params.row.__rowIndex);
|
||||
}}><Icon>edit</Icon></IconButton>}
|
||||
{allowRecordDelete && <IconButton onClick={(e) =>
|
||||
{
|
||||
e.stopPropagation();
|
||||
deleteRecordCallback(params.row.__rowIndex);
|
||||
}}><Icon>delete</Icon></IconButton>}
|
||||
</Box>;
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
/*
|
||||
* 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 {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import Modal from "@mui/material/Modal";
|
||||
import EntityForm from "qqq/components/forms/EntityForm";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import React, {useEffect, useReducer, useState} from "react";
|
||||
|
||||
|
||||
////////////////////////////////
|
||||
// structure of expected data //
|
||||
////////////////////////////////
|
||||
export interface ModalEditFormData
|
||||
{
|
||||
tableName: string;
|
||||
defaultValues?: { [key: string]: string };
|
||||
disabledFields?: { [key: string]: boolean } | string[];
|
||||
overrideHeading?: string;
|
||||
onSubmitCallback?: (values: any, tableName: String) => void;
|
||||
initialShowModalValue?: boolean;
|
||||
}
|
||||
|
||||
const qController = Client.getInstance();
|
||||
|
||||
function ModalEditForm({tableName, defaultValues, disabledFields, overrideHeading, onSubmitCallback, initialShowModalValue}: ModalEditFormData,): JSX.Element
|
||||
{
|
||||
const [showModal, setShowModal] = useState(initialShowModalValue);
|
||||
const [table, setTable] = useState(null as QTableMetaData);
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (!tableName)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
(async () =>
|
||||
{
|
||||
const tableMetaData = await qController.loadTableMetaData(tableName);
|
||||
setTable(tableMetaData);
|
||||
forceUpdate();
|
||||
})();
|
||||
}, [tableName]);
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
const closeEditChildForm = (event: object, reason: string) =>
|
||||
{
|
||||
if (reason === "backdropClick" || reason === "escapeKeyDown")
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
setShowModal(null);
|
||||
};
|
||||
|
||||
return (
|
||||
table && showModal &&
|
||||
<Modal open={showModal as boolean} onClose={(event, reason) => closeEditChildForm(event, reason)}>
|
||||
<div className="modalEditForm">
|
||||
<EntityForm
|
||||
isModal={true}
|
||||
closeModalHandler={closeEditChildForm}
|
||||
table={table}
|
||||
defaultValues={defaultValues}
|
||||
disabledFields={disabledFields}
|
||||
onSubmitCallback={onSubmitCallback}
|
||||
overrideHeading={overrideHeading}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export default ModalEditForm;
|
||||
@@ -0,0 +1,970 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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 {Typography} from "@mui/material";
|
||||
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 Card from "@mui/material/Card";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Dialog from "@mui/material/Dialog";
|
||||
import DialogActions from "@mui/material/DialogActions";
|
||||
import DialogContent from "@mui/material/DialogContent";
|
||||
import DialogContentText from "@mui/material/DialogContentText";
|
||||
import DialogTitle from "@mui/material/DialogTitle";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import Menu from "@mui/material/Menu";
|
||||
import Modal from "@mui/material/Modal";
|
||||
import {Capability} from "@qrunio/qqq-frontend-core/lib/model/metaData/Capability";
|
||||
import {QProcessMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
|
||||
import {QMenu} from "@qrunio/qqq-frontend-core/lib/model/metaData/QMenu";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import QContext from "QContext";
|
||||
import {preferredColorNameInfoOrPrimary} from "qqq/assets/theme/functions/preferInfoColorToPrimaryColor";
|
||||
import {QActionsMenuButton, QCancelButton, QDeleteButton, QEditButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
|
||||
import MDButton from "qqq/components/legacy/MDButton";
|
||||
import {GotoRecordButton} from "qqq/components/misc/GotoRecordDialog";
|
||||
import {RecordViewAdditionalMenus, RecordViewMenuItem, ItemsShownInMenu, RecordViewMenuActions} from "qqq/components/view/RecordViewMenus";
|
||||
import BaseLayout from "qqq/layouts/BaseLayout";
|
||||
import ProcessRun from "qqq/pages/processes/ProcessRun";
|
||||
import RecordScreenBody from "qqq/pages/records/RecordScreenBody";
|
||||
import {RecordScreenMode} from "qqq/pages/records/RecordScreenContext";
|
||||
import RecordScreenSection from "qqq/pages/records/RecordScreenSection";
|
||||
import {useRecordScreen} from "qqq/pages/records/useRecordScreen";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import HtmlUtils from "qqq/utils/HtmlUtils";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
import {QFieldType} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldType";
|
||||
import {sanitizeId} from "qqq/utils/qqqIdUtils";
|
||||
import React, {useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState} from "react";
|
||||
import {useLocation, useNavigate, useParams} from "react-router-dom";
|
||||
import RecordScreenModal from "qqq/pages/records/RecordScreenModal";
|
||||
import AuditBody from "qqq/components/audits/AuditBody";
|
||||
import ShareModal from "qqq/components/sharing/ShareModal";
|
||||
|
||||
|
||||
interface Props
|
||||
{
|
||||
table: QTableMetaData;
|
||||
mode?: RecordScreenMode;
|
||||
isCopy?: boolean;
|
||||
launchProcess?: QProcessMetaData;
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Unified Record Screen: view, edit, and create modes in one component.
|
||||
** Fields stay in the same grid positions across mode transitions.
|
||||
***************************************************************************/
|
||||
export default function RecordScreen({table, mode: propMode, isCopy, launchProcess}: Props): JSX.Element
|
||||
{
|
||||
const {id} = useParams();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const {accentColor, dotMenuOpen, keyboardHelpOpen, modalStack, tableProcesses, setPageHeader, setPageHeaderRightContent} = useContext(QContext);
|
||||
|
||||
// scroll correction: stores field name + Y position before mode switch
|
||||
const scrollCorrectionRef = useRef<{fieldName: string; yBefore: number} | null>(null);
|
||||
|
||||
const screen = useRecordScreen(table.name, id, propMode, isCopy, {scrollCorrectionRef});
|
||||
const {
|
||||
mode, setMode, record, tableMetaData, metaData,
|
||||
t1Section, formFieldsBySection,
|
||||
loading, notFoundMessage, notAllowedError,
|
||||
saveRecord, deleteRecord,
|
||||
allTableProcesses, tableVariant,
|
||||
setSuccessMessage, setWarningMessage, setErrorMessage,
|
||||
showEditChildForm: hookShowEditChildForm, setShowEditChildForm: hookSetShowEditChildForm,
|
||||
submitEditChildForm,
|
||||
} = screen;
|
||||
|
||||
// If the user navigates via a React Router <Link> while in pushState-edit-mode,
|
||||
// React Router processes a navigation (location.key changes) but propMode stays "view"
|
||||
// because React Router never knew about /edit. Detect this and exit edit mode.
|
||||
useEffect(() =>
|
||||
{
|
||||
if (mode === "edit" && propMode === "view" && !location.pathname.endsWith("/edit"))
|
||||
{
|
||||
setMode("view");
|
||||
}
|
||||
}, [location.key]);
|
||||
|
||||
// Listen for browser back/forward (popstate) to exit edit mode when URL no longer ends with /edit
|
||||
// (covers history.back() from cancel, or user pressing browser back button)
|
||||
useEffect(() =>
|
||||
{
|
||||
const handlePopState = () =>
|
||||
{
|
||||
if (!window.location.pathname.endsWith("/edit") && mode === "edit" && propMode === "view")
|
||||
{
|
||||
setMode("view");
|
||||
}
|
||||
};
|
||||
window.addEventListener("popstate", handlePopState);
|
||||
return () => window.removeEventListener("popstate", handlePopState);
|
||||
}, [mode, propMode, setMode]);
|
||||
|
||||
// local UI state
|
||||
const [deleteConfirmationOpen, setDeleteConfirmationOpen] = useState(false);
|
||||
const [isDeleteSubmitting, setIsDeleteSubmitting] = useState(false);
|
||||
const [actionsMenuAnchorElement, setActionsMenuAnchorElement] = useState(null);
|
||||
const [activeModalProcess, setActiveModalProcess] = useState<QProcessMetaData>(null);
|
||||
// showEditChildForm comes from hook for edit-mode, or local for view-mode hash navigation.
|
||||
// Use hook's state as the canonical source; alias for convenience.
|
||||
const showEditChildForm = hookShowEditChildForm;
|
||||
const setShowEditChildForm = hookSetShowEditChildForm;
|
||||
const [showAudit, setShowAudit] = useState(false);
|
||||
const [showShareModal, setShowShareModal] = useState(false);
|
||||
const [actionMenu, setActionMenu] = useState(null);
|
||||
const [widgetReloadCounter, setWidgetReloadCounter] = useState(0);
|
||||
|
||||
useLayoutEffect(() =>
|
||||
{
|
||||
if (scrollCorrectionRef.current)
|
||||
{
|
||||
const {fieldName: corrFieldName, yBefore} = scrollCorrectionRef.current;
|
||||
const fieldElAfter = document.querySelector(`[data-field-name='${corrFieldName}']`);
|
||||
if (fieldElAfter)
|
||||
{
|
||||
const yAfter = fieldElAfter.getBoundingClientRect().top;
|
||||
const drift = yAfter - yBefore;
|
||||
if (Math.abs(drift) > 1)
|
||||
{
|
||||
// temporarily override smooth scrolling so the correction is instant
|
||||
const htmlEl = document.documentElement;
|
||||
const prevBehavior = htmlEl.style.scrollBehavior;
|
||||
htmlEl.style.scrollBehavior = "auto";
|
||||
window.scrollBy(0, drift);
|
||||
htmlEl.style.scrollBehavior = prevBehavior;
|
||||
}
|
||||
}
|
||||
scrollCorrectionRef.current = null;
|
||||
}
|
||||
}, [mode]);
|
||||
|
||||
// ref to the formik submit function for Ctrl+S
|
||||
const formikSubmitRef = useRef<() => void>(() =>
|
||||
{
|
||||
});
|
||||
|
||||
const openActionsMenu = (event: any) => setActionsMenuAnchorElement(event.currentTarget);
|
||||
const closeActionsMenu = () => setActionsMenuAnchorElement(null);
|
||||
|
||||
const pathParts = location.pathname.replace(/\/+$/, "").split("/");
|
||||
const tableNameForId = tableMetaData ? sanitizeId(tableMetaData.name) : "";
|
||||
const isEditing = mode === "edit" || mode === "create";
|
||||
|
||||
// keep h3 page header in sync with mode
|
||||
useEffect(() =>
|
||||
{
|
||||
if (mode === "edit" && tableMetaData && record)
|
||||
{
|
||||
setPageHeader(`Edit ${tableMetaData.label}: ${record.recordLabel ?? id}`);
|
||||
}
|
||||
else if (mode === "view" && record)
|
||||
{
|
||||
setPageHeader(record.recordLabel ?? id);
|
||||
}
|
||||
}, [mode, tableMetaData, record]);
|
||||
|
||||
/////////////////////////
|
||||
// Handle cancel click //
|
||||
/////////////////////////
|
||||
const handleCancelClicked = () =>
|
||||
{
|
||||
if (isCopy)
|
||||
{
|
||||
navigate(location.pathname.replace(/\/copy$/, ""), {replace: true});
|
||||
}
|
||||
else if (id)
|
||||
{
|
||||
// capture scroll position before switching back to view mode
|
||||
const fieldSections = Array.from(document.querySelectorAll("[data-field-name]"));
|
||||
for (const el of fieldSections)
|
||||
{
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (rect.top >= 0 && rect.top < window.innerHeight)
|
||||
{
|
||||
scrollCorrectionRef.current = {fieldName: el.getAttribute("data-field-name"), yBefore: rect.top};
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
setMode("view");
|
||||
if (propMode === "view")
|
||||
{
|
||||
// edit was entered via pushState — pop the entry cleanly
|
||||
window.history.back();
|
||||
}
|
||||
else
|
||||
{
|
||||
// direct navigation to /edit URL — replace in place
|
||||
window.history.replaceState(null, "", location.pathname.replace(/\/edit$/, ""));
|
||||
window.dispatchEvent(new Event("urlchanged"));
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
navigate(location.pathname.replace(/\/create$/, ""), {replace: true});
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
///////////////////////
|
||||
// Keyboard handling //
|
||||
///////////////////////
|
||||
useEffect(() =>
|
||||
{
|
||||
const down = (e: KeyboardEvent) =>
|
||||
{
|
||||
const type = (e.target as any).type;
|
||||
const validType = (type !== "text" && type !== "textarea" && type !== "input" && type !== "search");
|
||||
|
||||
// Ctrl+S / Cmd+S to save in edit mode (works even when focused on inputs)
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "s" && isEditing)
|
||||
{
|
||||
e.preventDefault();
|
||||
formikSubmitRef.current?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (validType && !dotMenuOpen && !keyboardHelpOpen && !showAudit && !showEditChildForm && (!modalStack || modalStack.length === 0))
|
||||
{
|
||||
if (mode === "view")
|
||||
{
|
||||
if (!e.metaKey && !e.ctrlKey && e.key === "n" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission)
|
||||
{
|
||||
e.preventDefault();
|
||||
navigate(pathParts.slice(0, -1).join("/") + "/create");
|
||||
}
|
||||
else if (!e.metaKey && !e.ctrlKey && e.key === "e" && table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission)
|
||||
{
|
||||
e.preventDefault();
|
||||
enterEditMode();
|
||||
}
|
||||
else if (!e.metaKey && !e.ctrlKey && e.key === "c" && table.capabilities.has(Capability.TABLE_INSERT) && table.insertPermission)
|
||||
{
|
||||
e.preventDefault();
|
||||
navigate("copy");
|
||||
}
|
||||
else if (!e.metaKey && !e.ctrlKey && e.key === "d" && table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission)
|
||||
{
|
||||
e.preventDefault();
|
||||
setDeleteConfirmationOpen(true);
|
||||
}
|
||||
else if (!e.metaKey && !e.ctrlKey && e.key === "a" && metaData?.tables?.has("audit"))
|
||||
{
|
||||
e.preventDefault();
|
||||
navigate("#audit");
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("keydown", down);
|
||||
return () => document.removeEventListener("keydown", down);
|
||||
}, [mode, isEditing, dotMenuOpen, keyboardHelpOpen, modalStack, showEditChildForm, showAudit, metaData, location, deleteConfirmationOpen]);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** process URL hash for audit/createChild/process modals
|
||||
***************************************************************************/
|
||||
const qController = Client.getInstance();
|
||||
const CREATE_CHILD_KEY = "createChild";
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const hashParts = location.hash.split("/");
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// look for a createChild specification in the hash //
|
||||
// e.g., person/42#/createChild=address/defaultValues={...}/disabledFields={} //
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
let foundCreateChild = false;
|
||||
for (let i = 0; i < hashParts.length; i++)
|
||||
{
|
||||
const parts = hashParts[i].split("=");
|
||||
if (parts.length > 1 && parts[0] === CREATE_CHILD_KEY)
|
||||
{
|
||||
foundCreateChild = true;
|
||||
const childTableName = parts[1];
|
||||
|
||||
// parse optional defaultValues and disabledFields from subsequent hash parts
|
||||
let defaultValues: any = null;
|
||||
let disabledFields: any = null;
|
||||
for (let j = i + 1; j < hashParts.length; j++)
|
||||
{
|
||||
const subParts = hashParts[j].split("=");
|
||||
if (subParts.length > 1)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (subParts[0] === "defaultValues")
|
||||
{
|
||||
defaultValues = JSON.parse(decodeURIComponent(subParts.slice(1).join("=")));
|
||||
}
|
||||
else if (subParts[0] === "disabledFields")
|
||||
{
|
||||
disabledFields = JSON.parse(decodeURIComponent(subParts.slice(1).join("=")));
|
||||
}
|
||||
}
|
||||
catch (e)
|
||||
{
|
||||
console.warn("Error parsing hash param", subParts[0], e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(async () =>
|
||||
{
|
||||
const childTable = await qController.loadTableMetaData(childTableName);
|
||||
setShowEditChildForm({table: childTable, id: null, defaultValues, disabledFields});
|
||||
})();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundCreateChild)
|
||||
{
|
||||
// close any open child form if hash no longer has createChild
|
||||
if (showEditChildForm) setShowEditChildForm(null);
|
||||
}
|
||||
|
||||
if (hashParts[0] === "#audit")
|
||||
{
|
||||
setShowAudit(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
setShowAudit(false);
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// look for a launchProcess specification in the hash //
|
||||
// e.g., person/42#launchProcess=bulkInsert //
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
for (let i = 0; i < hashParts.length; i++)
|
||||
{
|
||||
const parts = hashParts[i].split("=");
|
||||
if (parts.length > 1 && parts[0].replace("#", "") === "launchProcess")
|
||||
{
|
||||
const processName = parts[1];
|
||||
(async () =>
|
||||
{
|
||||
const processMetaData = await qController.loadProcessMetaData(processName);
|
||||
setActiveModalProcess(processMetaData);
|
||||
})();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}, [location.hash]);
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** process URL path for modal processes and path-based createChild
|
||||
***************************************************************************/
|
||||
useEffect(() =>
|
||||
{
|
||||
if (mode !== "view") return;
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// path-based createChild: e.g., person/42/createChild/address //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (pathParts.length >= 4 && pathParts[pathParts.length - 4] === table.name && pathParts[pathParts.length - 2] === CREATE_CHILD_KEY)
|
||||
{
|
||||
(async () =>
|
||||
{
|
||||
const childTable = await qController.loadTableMetaData(pathParts[pathParts.length - 1]);
|
||||
setShowEditChildForm({table: childTable, id: null, defaultValues: null, disabledFields: null});
|
||||
})();
|
||||
return;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// modal process: e.g., person/42/someProcess //
|
||||
////////////////////////////////////////////////////////////////////////////////////////////
|
||||
if (allTableProcesses.length > 0 && pathParts.length >= 3)
|
||||
{
|
||||
if (pathParts[pathParts.length - 3] === table.name)
|
||||
{
|
||||
const processName = pathParts[pathParts.length - 1];
|
||||
const processList = allTableProcesses.filter(p => p.name.endsWith(processName));
|
||||
if (processList.length > 0)
|
||||
{
|
||||
setActiveModalProcess(processList[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}, [location.pathname, allTableProcesses]);
|
||||
|
||||
|
||||
/////////////////
|
||||
// Delete flow //
|
||||
/////////////////
|
||||
const handleDelete = async () =>
|
||||
{
|
||||
setIsDeleteSubmitting(true);
|
||||
try
|
||||
{
|
||||
await deleteRecord();
|
||||
}
|
||||
catch (error: any)
|
||||
{
|
||||
setIsDeleteSubmitting(false);
|
||||
setDeleteConfirmationOpen(false);
|
||||
if (error.message?.toLowerCase().startsWith("warning"))
|
||||
{
|
||||
const path = pathParts.slice(0, -1).join("/");
|
||||
navigate(path, {state: {deleteSuccess: true, warning: error.message}});
|
||||
}
|
||||
else
|
||||
{
|
||||
setErrorMessage(error.message);
|
||||
HtmlUtils.autoScroll(0);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
///////////////////////
|
||||
// Actions menu data //
|
||||
///////////////////////
|
||||
const recordViewMenuActions: RecordViewMenuActions = {
|
||||
new: (closeMenu?: () => void) =>
|
||||
{
|
||||
closeMenu?.();
|
||||
navigate(pathParts.slice(0, -1).join("/") + "/create");
|
||||
},
|
||||
copy: (closeMenu?: () => void) =>
|
||||
{
|
||||
closeMenu?.();
|
||||
navigate("copy");
|
||||
},
|
||||
edit: (closeMenu?: () => void) =>
|
||||
{
|
||||
closeMenu?.();
|
||||
enterEditMode();
|
||||
},
|
||||
delete: (closeMenu?: () => void) =>
|
||||
{
|
||||
closeMenu?.();
|
||||
setDeleteConfirmationOpen(true);
|
||||
setIsDeleteSubmitting(false);
|
||||
},
|
||||
developerMode: (closeMenu?: () => void) =>
|
||||
{
|
||||
closeMenu?.();
|
||||
navigate("dev");
|
||||
},
|
||||
audit: (closeMenu?: () => void) =>
|
||||
{
|
||||
closeMenu?.();
|
||||
navigate("#audit");
|
||||
},
|
||||
runProcess: (processName: string) =>
|
||||
{
|
||||
const process = metaData?.processes?.get(processName);
|
||||
if (process)
|
||||
{
|
||||
navigate(process.name);
|
||||
closeActionsMenu();
|
||||
}
|
||||
},
|
||||
downloadFileFromField: (fieldName: string, closeMenu?: () => void) =>
|
||||
{
|
||||
const fieldValue = record?.values?.get(fieldName);
|
||||
if (fieldName && fieldValue)
|
||||
{
|
||||
const field = tableMetaData.fields.get(fieldName);
|
||||
const url = ValueUtils.getUrlFromBlobOrFileDownloadField(fieldValue, tableVariant, field, record, fieldName);
|
||||
|
||||
if (field.type == QFieldType.BLOB)
|
||||
{
|
||||
const fileName = record?.displayValues?.get(fieldName) ?? fieldName;
|
||||
HtmlUtils.downloadUrlViaIFrame(field, url, fileName);
|
||||
}
|
||||
else
|
||||
{
|
||||
window.open(url);
|
||||
}
|
||||
}
|
||||
|
||||
if (closeMenu)
|
||||
{
|
||||
closeMenu();
|
||||
}
|
||||
},
|
||||
getMetaData: () => metaData,
|
||||
getTableProcesses: () => tableProcesses,
|
||||
getGenericProcesses: () => [],
|
||||
};
|
||||
|
||||
const itemsShownInActionsMenu = new ItemsShownInMenu();
|
||||
|
||||
|
||||
///////////////////////////////////////////
|
||||
// Actions placement & sidebar metadata //
|
||||
///////////////////////////////////////////
|
||||
const materialDashboardTableMetaData = tableMetaData?.supplementalTableMetaData?.get("materialDashboard");
|
||||
const materialDashboardInstanceMetaData = metaData?.supplementalInstanceMetaData?.get("materialDashboard");
|
||||
const showRecordSidebar = materialDashboardTableMetaData?.showRecordSidebar !== false;
|
||||
const recordViewActionsPlacement = materialDashboardInstanceMetaData?.recordViewActionsPlacement ?? materialDashboardTableMetaData?.recordViewActionsPlacement ?? "IN_IDENTITY_SECTION";
|
||||
const showRecordViewActionsInlineWithPageTitle = recordViewActionsPlacement === "INLINE_WITH_PAGE_TITLE";
|
||||
|
||||
const renderRecordViewActions = (boxProps: any = {}) =>
|
||||
{
|
||||
return (
|
||||
<>
|
||||
<Box display="flex" {...boxProps}>
|
||||
<GotoRecordButton metaData={metaData} tableMetaData={tableMetaData} />
|
||||
{tableMetaData?.shareableTableMetaData && (
|
||||
<Box mr={2}>
|
||||
<MDButton type="button" color={preferredColorNameInfoOrPrimary()} size="small" onClick={() => setShowShareModal(true)} fullWidth startIcon={<Icon>group_add</Icon>}>
|
||||
Share
|
||||
</MDButton>
|
||||
</Box>
|
||||
)}
|
||||
{metaData && tableMetaData && <RecordViewAdditionalMenus tableMetaData={tableMetaData} record={record} actions={recordViewMenuActions} />}
|
||||
{metaData && tableMetaData && <QActionsMenuButton isOpen={actionsMenuAnchorElement} onClickHandler={openActionsMenu} />}
|
||||
</Box>
|
||||
<Menu
|
||||
anchorEl={actionsMenuAnchorElement}
|
||||
anchorOrigin={{vertical: "bottom", horizontal: "right"}}
|
||||
transformOrigin={{vertical: "top", horizontal: "right"}}
|
||||
open={Boolean(actionsMenuAnchorElement)}
|
||||
onClose={closeActionsMenu}
|
||||
keepMounted
|
||||
data-qqq-id="record-view-actions-menu"
|
||||
>
|
||||
{actionMenu?.items?.map((item: any, index: number) => (
|
||||
<RecordViewMenuItem key={index} tableMetaData={tableMetaData} menuItem={item} record={record} actions={recordViewMenuActions} closeMenu={closeActionsMenu} itemsShown={itemsShownInActionsMenu} />
|
||||
))}
|
||||
</Menu>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const pageHeaderRecordViewActions = useMemo(() =>
|
||||
{
|
||||
if (!showRecordViewActionsInlineWithPageTitle)
|
||||
{
|
||||
return (null);
|
||||
}
|
||||
|
||||
return (
|
||||
<Box display="flex" pb="1rem" pr="0.5rem" sx={{visibility: mode === "view" ? "visible" : "hidden"}}>
|
||||
{renderRecordViewActions()}
|
||||
</Box>
|
||||
);
|
||||
}, [showRecordViewActionsInlineWithPageTitle, mode, metaData, tableMetaData, record, actionsMenuAnchorElement, actionMenu]);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
setPageHeaderRightContent?.(pageHeaderRecordViewActions);
|
||||
|
||||
return () => setPageHeaderRightContent?.(null);
|
||||
}, [setPageHeaderRightContent, pageHeaderRecordViewActions]);
|
||||
|
||||
/////////////////////
|
||||
// Modal callbacks //
|
||||
/////////////////////
|
||||
const closeModalProcess = (event: object, reason: string) =>
|
||||
{
|
||||
if (reason === "backdropClick" || reason === "escapeKeyDown") return;
|
||||
|
||||
if (location.hash)
|
||||
{
|
||||
navigate(location.pathname);
|
||||
}
|
||||
else
|
||||
{
|
||||
const newPath = location.pathname.split("/");
|
||||
newPath.pop();
|
||||
navigate(newPath.join("/"));
|
||||
}
|
||||
setActiveModalProcess(null);
|
||||
};
|
||||
|
||||
const closeAudit = (event: object, reason: string) =>
|
||||
{
|
||||
if (reason === "backdropClick") return;
|
||||
setShowAudit(false);
|
||||
if (location.hash) navigate(location.pathname);
|
||||
};
|
||||
|
||||
const closeEditChildForm = (event: object, reason: string) =>
|
||||
{
|
||||
if (reason === "backdropClick" || reason === "escapeKeyDown") return;
|
||||
setShowEditChildForm(null);
|
||||
if (location.hash) navigate(location.pathname);
|
||||
|
||||
// trigger widget reload when a view-mode child record was created or updated (server-persisted)
|
||||
if ((reason === "recordCreated" || reason === "recordUpdated") && mode === "view")
|
||||
{
|
||||
setWidgetReloadCounter(c => c + 1);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
// when the tableMetaData changes, grab the action menu out of it (or build a default one) //
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////
|
||||
useEffect(() =>
|
||||
{
|
||||
let menu: QMenu = null;
|
||||
if (metaData && tableMetaData)
|
||||
{
|
||||
menu = (tableMetaData.menus ?? []).find(m => m.slot == "VIEW_SCREEN_ACTIONS");
|
||||
|
||||
if (!menu)
|
||||
{
|
||||
menu = new QMenu(
|
||||
{
|
||||
label: "Actions",
|
||||
icon: {name: "game"},
|
||||
slot: "VIEW_SCREEN_ACTIONS",
|
||||
items: [
|
||||
{
|
||||
itemType: "SUB_LIST", values: {
|
||||
items: [
|
||||
{itemType: "BUILT_IN", values: {option: "NEW"}},
|
||||
{itemType: "BUILT_IN", values: {option: "COPY"}},
|
||||
{itemType: "BUILT_IN", values: {option: "EDIT"}},
|
||||
{itemType: "BUILT_IN", values: {option: "DELETE"}}
|
||||
]
|
||||
}
|
||||
},
|
||||
{itemType: "DIVIDER"},
|
||||
{itemType: "BUILT_IN", values: {option: "THIS_TABLE_PROCESS_LIST"}},
|
||||
{itemType: "DIVIDER"},
|
||||
{
|
||||
itemType: "SUB_LIST", values: {
|
||||
items: [
|
||||
{itemType: "BUILT_IN", values: {option: "ALL_TABLES_PROCESS_LIST"}},
|
||||
{itemType: "BUILT_IN", values: {option: "DEVELOPER_MODE"}},
|
||||
{itemType: "BUILT_IN", values: {option: "AUDIT"}}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
setActionMenu(menu);
|
||||
}, [metaData, tableMetaData]);
|
||||
|
||||
|
||||
/////////////////////////
|
||||
// Handle mode switch //
|
||||
/////////////////////////
|
||||
const enterEditMode = useCallback((fieldName?: string) =>
|
||||
{
|
||||
if (table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission)
|
||||
{
|
||||
setSuccessMessage(null);
|
||||
setWarningMessage(null);
|
||||
setErrorMessage(null);
|
||||
closeActionsMenu();
|
||||
|
||||
// capture the clicked field's screen position before mode switch
|
||||
if (fieldName)
|
||||
{
|
||||
const fieldEl = document.querySelector(`[data-field-name='${fieldName}']`);
|
||||
if (fieldEl)
|
||||
{
|
||||
scrollCorrectionRef.current = {fieldName, yBefore: fieldEl.getBoundingClientRect().top};
|
||||
}
|
||||
}
|
||||
|
||||
setMode("edit");
|
||||
window.history.pushState(null, "", location.pathname.replace(/\/(edit)?\/?$/, "") + "/edit");
|
||||
window.dispatchEvent(new Event("urlchanged"));
|
||||
|
||||
if (fieldName)
|
||||
{
|
||||
// focus the input after React renders
|
||||
setTimeout(() =>
|
||||
{
|
||||
const input = document.querySelector(`input[name='${fieldName}']`) as HTMLInputElement;
|
||||
if (input)
|
||||
{
|
||||
input.focus();
|
||||
}
|
||||
else
|
||||
{
|
||||
setTimeout(() =>
|
||||
{
|
||||
const retryInput = document.querySelector(`input[name='${fieldName}']`) as HTMLInputElement;
|
||||
retryInput?.focus();
|
||||
}, 100);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
}
|
||||
}, [table, setMode, location.pathname]);
|
||||
|
||||
|
||||
/////////////////
|
||||
// RENDER //
|
||||
/////////////////
|
||||
if (loading)
|
||||
{
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Box display="flex" justifyContent="center" alignItems="center" minHeight="300px">
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (notFoundMessage)
|
||||
{
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Box mb={3}>
|
||||
<Alert color="error" sx={{mb: 3}} icon={<Icon>warning</Icon>}>{notFoundMessage}</Alert>
|
||||
</Box>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (notAllowedError && !isEditing)
|
||||
{
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Box mb={3}>
|
||||
<Alert severity="error">{notAllowedError}</Alert>
|
||||
</Box>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
const handleFormikSubmit = async (values: any, actions: any) =>
|
||||
{
|
||||
actions.setSubmitting(true);
|
||||
try
|
||||
{
|
||||
await saveRecord(values);
|
||||
}
|
||||
catch (error: any)
|
||||
{
|
||||
if (error.message?.toLowerCase().startsWith("warning"))
|
||||
{
|
||||
const path = location.pathname.replace(/\/edit$/, "");
|
||||
navigate(path, {state: {updateSuccess: true, warning: error.message}});
|
||||
}
|
||||
else
|
||||
{
|
||||
setErrorMessage(error.message);
|
||||
HtmlUtils.autoScroll(0);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
actions.setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
////////////////
|
||||
// Main body //
|
||||
////////////////
|
||||
const renderT1Section = (formikProps?: any) =>
|
||||
{
|
||||
const titleText = mode === "view"
|
||||
? (tableMetaData && record ? `Viewing ${tableMetaData?.label}: ${record?.recordLabel || ""}` : "")
|
||||
: mode === "create"
|
||||
? (isCopy ? `Copy ${tableMetaData?.label}: ${record?.recordLabel || ""}` : `Creating New ${tableMetaData?.label}`)
|
||||
: `Edit ${tableMetaData?.label}: ${record?.recordLabel || ""}`;
|
||||
|
||||
return (
|
||||
<Card id={t1Section?.name} className="recordScreenFieldSection" sx={{scrollMarginTop: "100px", minHeight: "88px", overflow: "visible"}} data-qqq-id={`record-screen-header-${tableNameForId}`}>
|
||||
<Box display="flex" p={3} pb={1}>
|
||||
<Box mr={1.5}>
|
||||
<Avatar sx={{bgcolor: accentColor}}>
|
||||
<Icon>{tableMetaData?.iconName}</Icon>
|
||||
</Avatar>
|
||||
</Box>
|
||||
<Box display="flex" justifyContent="space-between" width="100%" alignItems="flex-start" flexWrap={{xs: "wrap", md: "nowrap"}}>
|
||||
<Typography variant="h5" mb="0.5rem" data-qqq-id={`record-screen-title-${tableNameForId}`}>
|
||||
{titleText}
|
||||
</Typography>
|
||||
{!showRecordViewActionsInlineWithPageTitle && (
|
||||
<Box sx={{ml: "auto", visibility: mode === "view" ? "visible" : "hidden"}}>
|
||||
{renderRecordViewActions()}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
{t1Section && (
|
||||
<Box p={0} pt={1}>
|
||||
<RecordScreenSection
|
||||
section={t1Section}
|
||||
mode={mode}
|
||||
record={record}
|
||||
formFieldsBySection={formFieldsBySection}
|
||||
tableVariant={tableVariant}
|
||||
onEditIconClick={enterEditMode}
|
||||
isT1={true}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
const renderViewModeBottomBar = () =>
|
||||
{
|
||||
if (mode !== "view" || !tableMetaData || !record) return null;
|
||||
|
||||
const canDelete = table.capabilities.has(Capability.TABLE_DELETE) && table.deletePermission;
|
||||
const canEdit = table.capabilities.has(Capability.TABLE_UPDATE) && table.editPermission;
|
||||
|
||||
if (!canDelete && !canEdit) return null;
|
||||
|
||||
return (
|
||||
<Box component="div" p={3} className="stickyBottomButtonBar" data-qqq-id={`record-screen-button-bar-${tableNameForId}`}>
|
||||
<Grid container justifyContent="flex-end" spacing={3}>
|
||||
{canDelete && <QDeleteButton onClickHandler={() =>
|
||||
{
|
||||
setDeleteConfirmationOpen(true);
|
||||
setIsDeleteSubmitting(false);
|
||||
}} />}
|
||||
{canEdit && <QEditButton onClickHandler={() => enterEditMode()} />}
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
const renderEditModeBottomBar = (isSubmitting: boolean) => (
|
||||
<Box component="div" p={3} className="stickyBottomButtonBar" data-qqq-id={`record-screen-button-bar-${tableNameForId}`}>
|
||||
<Grid container justifyContent="flex-end" spacing={3}>
|
||||
<QCancelButton onClickHandler={handleCancelClicked} disabled={isSubmitting} />
|
||||
<QSaveButton disabled={isSubmitting || !!notAllowedError} label="Save" iconName="save" />
|
||||
</Grid>
|
||||
</Box>
|
||||
);
|
||||
|
||||
const renderDeleteDialog = () => (
|
||||
<Dialog open={deleteConfirmationOpen} onClose={() => setDeleteConfirmationOpen(false)}>
|
||||
<DialogTitle>Confirm Deletion</DialogTitle>
|
||||
<DialogContent>
|
||||
<DialogContentText>Are you sure you want to delete this record?</DialogContentText>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteConfirmationOpen(false)}>No</Button>
|
||||
<Button onClick={handleDelete} autoFocus disabled={isDeleteSubmitting}>Yes</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
const renderModals = () => (
|
||||
<>
|
||||
{activeModalProcess && (
|
||||
<Modal open={true} onClose={closeModalProcess}>
|
||||
<div className="modalProcess">
|
||||
<ProcessRun process={activeModalProcess} isModal={true} table={tableMetaData} recordIds={[id]} closeModalHandler={closeModalProcess} />
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showEditChildForm && (
|
||||
<RecordScreenModal
|
||||
open={true}
|
||||
onClose={closeEditChildForm}
|
||||
tableName={showEditChildForm.table?.name}
|
||||
recordId={showEditChildForm.id}
|
||||
defaultValues={showEditChildForm.defaultValues}
|
||||
disabledFields={showEditChildForm.disabledFields}
|
||||
onSubmitCallback={showEditChildForm.widgetName ? submitEditChildForm : null}
|
||||
overrideHeading={showEditChildForm.widgetName ? `${showEditChildForm.rowIndex != null ? "Editing" : "Creating New"} ${showEditChildForm.table?.label}` : null}
|
||||
saveButtonLabel={showEditChildForm.widgetName ? "OK" : "Save"}
|
||||
saveButtonIcon={showEditChildForm.widgetName ? "check" : "save"}
|
||||
/>
|
||||
)}
|
||||
|
||||
{showAudit && tableMetaData && record && (
|
||||
<Modal open={showAudit} onClose={closeAudit}>
|
||||
<div className="audit">
|
||||
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}}>
|
||||
<Card sx={{my: 5, mx: "auto", pb: 0, maxWidth: "1024px"}}>
|
||||
<Box component="div">
|
||||
<AuditBody recordId={id} record={record} tableMetaData={tableMetaData} />
|
||||
<Box p={3} display="flex" flexDirection="row" justifyContent="flex-end">
|
||||
<QCancelButton label="Close" onClickHandler={() => closeAudit(null, null)} disabled={false} />
|
||||
</Box>
|
||||
</Box>
|
||||
</Card>
|
||||
</Box>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{showShareModal && tableMetaData && record && (
|
||||
<ShareModal open={showShareModal} onClose={() => setShowShareModal(false)} tableMetaData={tableMetaData} record={record} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
const renderBottomBar = (isSubmitting: boolean): JSX.Element => (
|
||||
<>
|
||||
{mode === "view" && renderViewModeBottomBar()}
|
||||
{isEditing && renderEditModeBottomBar(isSubmitting)}
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
/////////////////////////////////////
|
||||
// MAIN RENDER - Formik wraps all //
|
||||
/////////////////////////////////////
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Box className={`recordScreen${mode === "view" ? " recordView" : " recordEdit"}${mode === "create" ? " recordCreate" : ""}${isEditing ? " compactForm" : ""}`} data-qqq-id={`record-screen-${tableNameForId}`}>
|
||||
<Grid container>
|
||||
<Grid item xs={12}>
|
||||
<Box mb={3}>
|
||||
<Box pb={3}>
|
||||
<RecordScreenBody
|
||||
screen={screen}
|
||||
onSubmit={handleFormikSubmit}
|
||||
formikSubmitRef={formikSubmitRef}
|
||||
renderT1Card={renderT1Section}
|
||||
renderBottomBar={renderBottomBar}
|
||||
showSidebar={showRecordSidebar}
|
||||
widgetReloadCounter={widgetReloadCounter}
|
||||
enterEditMode={enterEditMode}
|
||||
/>
|
||||
|
||||
{renderDeleteDialog()}
|
||||
{renderModals()}
|
||||
</Box>
|
||||
</Box>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Box>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,318 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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 {Alert} from "@mui/material";
|
||||
import Box from "@mui/material/Box";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import {QTableSection} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableSection";
|
||||
import {Form, Formik, useFormikContext} from "formik";
|
||||
import QRecordSidebar from "qqq/components/misc/RecordSidebar";
|
||||
import RecordScreenContext from "qqq/pages/records/RecordScreenContext";
|
||||
import RecordScreenSection from "qqq/pages/records/RecordScreenSection";
|
||||
import {UseRecordScreenResult} from "qqq/pages/records/useRecordScreen";
|
||||
import {sanitizeId} from "qqq/utils/qqqIdUtils";
|
||||
import React, {useCallback, useEffect, useRef, useMemo} from "react";
|
||||
import * as Yup from "yup";
|
||||
|
||||
|
||||
interface RecordScreenBodyProps
|
||||
{
|
||||
screen: UseRecordScreenResult;
|
||||
formId?: string;
|
||||
onSubmit: (values: any, actions: any) => Promise<void>;
|
||||
formikSubmitRef?: React.MutableRefObject<() => void>;
|
||||
renderT1Card?: () => JSX.Element;
|
||||
renderBottomBar?: (isSubmitting: boolean) => JSX.Element;
|
||||
showSidebar?: boolean;
|
||||
widgetReloadCounter?: number;
|
||||
enterEditMode?: (fieldName?: string) => void;
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Shared Formik-wrapped body used by both full-page RecordScreen and
|
||||
** RecordScreenModal. Handles context, value tracking, sections, alerts.
|
||||
***************************************************************************/
|
||||
export default function RecordScreenBody({screen, formId, onSubmit, formikSubmitRef, renderT1Card, renderBottomBar, showSidebar = true, widgetReloadCounter = 0, enterEditMode}: RecordScreenBodyProps): JSX.Element
|
||||
{
|
||||
const {
|
||||
mode, record, tableMetaData, metaData,
|
||||
tableSections, t1Section, nonT1Sections,
|
||||
formFieldsBySection, initialValues, formValidations,
|
||||
fieldRules,
|
||||
errorMessage, successMessage, warningMessage, notAllowedError,
|
||||
handleFieldChange, handleFieldBlur, reloadWidget,
|
||||
collapsibleSectionOpenStates, toggleCollapsibleSectionOpenState, openCollapsedSectionsWithErrorFields,
|
||||
setSuccessMessage, setWarningMessage, setErrorMessage,
|
||||
setFieldValueRef, formValuesRef,
|
||||
childListWidgetData,
|
||||
openAddChildRecord, openEditChildRecord, deleteChildRecord,
|
||||
tableVariant,
|
||||
sectionVisibility,
|
||||
addSubValidations,
|
||||
} = screen;
|
||||
|
||||
const isEditing = mode === "edit" || mode === "create";
|
||||
const tableNameForId = tableMetaData ? sanitizeId(tableMetaData.name) : "";
|
||||
|
||||
// formik JSON ref for change detection
|
||||
const formValuesJSONRef = useRef<string>("");
|
||||
|
||||
// Stable wrapper for setFieldValue
|
||||
const stableSetFieldValue = useCallback((name: string, value: any, shouldValidate?: boolean) =>
|
||||
{
|
||||
setFieldValueRef.current?.(name, value, shouldValidate);
|
||||
}, []);
|
||||
|
||||
const isFormDisabled = !!notAllowedError;
|
||||
|
||||
const contextValue = useMemo(() => ({
|
||||
mode,
|
||||
tableMetaData,
|
||||
metaData,
|
||||
record,
|
||||
setFieldValue: stableSetFieldValue,
|
||||
handleFieldBlur,
|
||||
reloadWidget,
|
||||
fieldRules,
|
||||
isFormDisabled,
|
||||
addSubValidations,
|
||||
childListWidgetData,
|
||||
openAddChildRecord,
|
||||
openEditChildRecord,
|
||||
deleteChildRecord,
|
||||
}), [mode, tableMetaData, metaData, record, stableSetFieldValue, reloadWidget, fieldRules, isFormDisabled, addSubValidations, childListWidgetData]);
|
||||
|
||||
|
||||
//////////////////////////////////////////////
|
||||
// Formik value-change tracker component //
|
||||
//////////////////////////////////////////////
|
||||
const FormikValueTracker = (): JSX.Element =>
|
||||
{
|
||||
const {values, dirty, setFieldValue} = useFormikContext<any>();
|
||||
|
||||
setFieldValueRef.current = setFieldValue;
|
||||
|
||||
if (values)
|
||||
{
|
||||
const newJSON = JSON.stringify(values);
|
||||
if (formValuesJSONRef.current !== newJSON)
|
||||
{
|
||||
const valueChangesToMake: { [fieldName: string]: any } = {};
|
||||
|
||||
if (dirty)
|
||||
{
|
||||
for (let fieldName in values)
|
||||
{
|
||||
if (formValuesRef.current[fieldName] !== values[fieldName])
|
||||
{
|
||||
handleFieldChange(fieldName, formValuesRef.current[fieldName], values[fieldName], valueChangesToMake);
|
||||
}
|
||||
formValuesRef.current[fieldName] = values[fieldName];
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
for (let fieldName in values)
|
||||
{
|
||||
formValuesRef.current[fieldName] = values[fieldName];
|
||||
}
|
||||
}
|
||||
|
||||
for (let fieldName in valueChangesToMake)
|
||||
{
|
||||
formValuesRef.current[fieldName] = valueChangesToMake[fieldName];
|
||||
setFieldValue(fieldName, valueChangesToMake[fieldName], false);
|
||||
}
|
||||
|
||||
formValuesJSONRef.current = JSON.stringify(values);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
|
||||
const renderAlerts = () => (
|
||||
<>
|
||||
{successMessage && (
|
||||
<Alert color="success" sx={{mb: 3, zIndex: 99, position: "sticky", top: "12px"}} onClose={() => setSuccessMessage(null)}>
|
||||
{successMessage}
|
||||
</Alert>
|
||||
)}
|
||||
{warningMessage && (
|
||||
<Alert color="warning" sx={{mb: 3}} icon={<Icon>warning</Icon>} onClose={() => setWarningMessage(null)}>
|
||||
{warningMessage}
|
||||
</Alert>
|
||||
)}
|
||||
{errorMessage && (
|
||||
<Alert color="error" sx={{mb: 3}} onClose={() => setErrorMessage(null)}>
|
||||
{errorMessage}
|
||||
</Alert>
|
||||
)}
|
||||
{notAllowedError && isEditing && (
|
||||
<Alert severity="error" sx={{mb: 3}}>
|
||||
{notAllowedError}
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
const renderNonT1Sections = (includeT1?: boolean) =>
|
||||
{
|
||||
const sectionsToRender = includeT1 && t1Section ? [t1Section, ...nonT1Sections] : nonT1Sections;
|
||||
return (
|
||||
<Grid container spacing={3} pb={4}>
|
||||
{sectionsToRender.map((section: QTableSection) =>
|
||||
{
|
||||
const open = collapsibleSectionOpenStates[section.name];
|
||||
const gridCols = section.widgetName
|
||||
? (metaData?.widgets?.get(section.widgetName)?.gridColumns ?? 12)
|
||||
: (section.gridColumns ?? 12);
|
||||
|
||||
const isSectionVisible = sectionVisibility[section.name] !== false;
|
||||
|
||||
return (
|
||||
<Grid
|
||||
id={section.name}
|
||||
key={section.name}
|
||||
item
|
||||
lg={gridCols}
|
||||
xs={12}
|
||||
className={`form-section-wrapper ${isSectionVisible ? "is-visible" : "is-hidden"}`}
|
||||
sx={{display: "flex", alignItems: "stretch", scrollMarginTop: "100px"}}
|
||||
>
|
||||
<Box width="100%" height={open !== false ? "100%" : "auto"}>
|
||||
<RecordScreenSection
|
||||
section={section}
|
||||
mode={mode}
|
||||
record={record}
|
||||
formFieldsBySection={formFieldsBySection}
|
||||
isOpen={open}
|
||||
onToggleCollapse={() => toggleCollapsibleSectionOpenState(section.name)}
|
||||
tableVariant={tableVariant}
|
||||
onEditIconClick={enterEditMode}
|
||||
widgetReloadCounter={widgetReloadCounter}
|
||||
/>
|
||||
</Box>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
};
|
||||
|
||||
const validationSchema = Yup.object().shape(formValidations);
|
||||
|
||||
const effectiveFormId = formId || `record-screen-form-${tableNameForId}`;
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderAlerts()}
|
||||
|
||||
<Formik
|
||||
initialValues={initialValues}
|
||||
validationSchema={validationSchema}
|
||||
onSubmit={onSubmit}
|
||||
enableReinitialize
|
||||
>
|
||||
{({isSubmitting, setFieldValue, submitForm}) =>
|
||||
{
|
||||
setFieldValueRef.current = setFieldValue;
|
||||
if (formikSubmitRef)
|
||||
{
|
||||
formikSubmitRef.current = submitForm;
|
||||
}
|
||||
|
||||
const FormWrapper = isEditing ? Form : "div";
|
||||
|
||||
return (
|
||||
<RecordScreenContext.Provider value={contextValue}>
|
||||
<FormWrapper id={effectiveFormId} autoComplete="off">
|
||||
<FormikValueTracker />
|
||||
{isEditing && <ScrollToFirstError openCollapsedSectionsWithErrorFields={openCollapsedSectionsWithErrorFields} />}
|
||||
|
||||
{showSidebar ? (
|
||||
<Grid container spacing={3} flexWrap={{md: "nowrap"}}>
|
||||
<Grid item xs={12} lg={3} className="recordSidebar">
|
||||
<QRecordSidebar tableSections={tableSections} sectionVisibility={sectionVisibility} />
|
||||
</Grid>
|
||||
<Grid item xs={12} lg={9} className="recordWithSidebar">
|
||||
{renderT1Card && (
|
||||
<Grid container spacing={3}>
|
||||
<Grid item xs={12} mb={3}>
|
||||
{renderT1Card()}
|
||||
</Grid>
|
||||
</Grid>
|
||||
)}
|
||||
|
||||
{renderNonT1Sections()}
|
||||
|
||||
{renderBottomBar?.(isSubmitting)}
|
||||
</Grid>
|
||||
</Grid>
|
||||
) : (
|
||||
<>
|
||||
{renderT1Card && <Box mb={3}>{renderT1Card()}</Box>}
|
||||
{renderNonT1Sections(!renderT1Card)}
|
||||
{renderBottomBar?.(isSubmitting)}
|
||||
</>
|
||||
)}
|
||||
</FormWrapper>
|
||||
</RecordScreenContext.Provider>
|
||||
);
|
||||
}}
|
||||
</Formik>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Scroll to the first validation error on submit.
|
||||
***************************************************************************/
|
||||
function ScrollToFirstError({openCollapsedSectionsWithErrorFields}: {openCollapsedSectionsWithErrorFields: (errorFieldNames: string[]) => void}): JSX.Element
|
||||
{
|
||||
const {submitCount, isValid, errors} = useFormikContext<any>();
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (submitCount === 0 || isValid) return;
|
||||
|
||||
const errorFieldNames = Object.keys(errors);
|
||||
if (errorFieldNames.length > 0)
|
||||
{
|
||||
openCollapsedSectionsWithErrorFields(errorFieldNames);
|
||||
}
|
||||
|
||||
setTimeout(() =>
|
||||
{
|
||||
const firstError = document.querySelector("[data-field-error]");
|
||||
if (firstError)
|
||||
{
|
||||
firstError.scrollIntoView({block: "center"});
|
||||
}
|
||||
}, 150);
|
||||
}, [submitCount, isValid]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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 {QInstance} from "@qrunio/qqq-frontend-core/lib/model/metaData/QInstance";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget";
|
||||
import {FieldRule} from "qqq/models/fields/FieldRules";
|
||||
import {createContext} from "react";
|
||||
|
||||
export type RecordScreenMode = "view" | "edit" | "create";
|
||||
|
||||
export interface RecordScreenContextValue
|
||||
{
|
||||
mode: RecordScreenMode;
|
||||
tableMetaData: QTableMetaData;
|
||||
metaData: QInstance;
|
||||
record: QRecord;
|
||||
setFieldValue: (name: string, value: any, shouldValidate?: boolean) => void;
|
||||
handleFieldBlur: (fieldName: string, value: any) => void;
|
||||
reloadWidget: (widgetName: string, additionalParams?: { [key: string]: any }) => void;
|
||||
fieldRules: FieldRule[];
|
||||
isFormDisabled: boolean;
|
||||
|
||||
// widget sub-validations
|
||||
addSubValidations: (name: string, validations: Record<string, any>) => void;
|
||||
|
||||
// child record management (edit mode)
|
||||
childListWidgetData: { [name: string]: ChildRecordListData };
|
||||
openAddChildRecord: (widgetName: string, widgetData: ChildRecordListData) => void;
|
||||
openEditChildRecord: (widgetName: string, widgetData: ChildRecordListData, rowIndex: number) => void;
|
||||
deleteChildRecord: (widgetName: string, rowIndex: number) => void;
|
||||
}
|
||||
|
||||
const RecordScreenContext = createContext<RecordScreenContextValue>({
|
||||
mode: "view",
|
||||
tableMetaData: null,
|
||||
metaData: null,
|
||||
record: null,
|
||||
setFieldValue: () =>
|
||||
{
|
||||
},
|
||||
handleFieldBlur: () =>
|
||||
{
|
||||
},
|
||||
reloadWidget: () =>
|
||||
{
|
||||
},
|
||||
fieldRules: [],
|
||||
isFormDisabled: false,
|
||||
addSubValidations: () =>
|
||||
{
|
||||
},
|
||||
childListWidgetData: {},
|
||||
openAddChildRecord: () =>
|
||||
{
|
||||
},
|
||||
openEditChildRecord: () =>
|
||||
{
|
||||
},
|
||||
deleteChildRecord: () =>
|
||||
{
|
||||
},
|
||||
});
|
||||
|
||||
export default RecordScreenContext;
|
||||
@@ -0,0 +1,449 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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 IconButton from "@mui/material/IconButton";
|
||||
import OutlinedInput from "@mui/material/OutlinedInput";
|
||||
import Tooltip from "@mui/material/Tooltip/Tooltip";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {AdornmentType} from "@qrunio/qqq-frontend-core/lib/model/metaData/AdornmentType";
|
||||
import {QFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QTableVariant} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableVariant";
|
||||
import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import {Field, useFormikContext} from "formik";
|
||||
import {flushSync} from "react-dom";
|
||||
import DynamicFormUtils, {DynamicFormFieldDefinition} from "qqq/components/forms/DynamicFormUtils";
|
||||
import DynamicFormFieldAsWidget from "qqq/components/forms/DynamicFormFieldAsWidget";
|
||||
import DynamicSelect from "qqq/components/forms/DynamicSelect";
|
||||
import BooleanFieldSwitch from "qqq/components/forms/BooleanFieldSwitch";
|
||||
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
|
||||
import {FieldValueAsWidget} from "qqq/components/view/FieldValueAsWidget";
|
||||
import QContext from "QContext";
|
||||
import RecordScreenContext, {RecordScreenMode} from "qqq/pages/records/RecordScreenContext";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
import React, {useCallback, useContext, useRef, useState, useEffect} from "react";
|
||||
|
||||
// target height for all input controls: 26px
|
||||
const INPUT_HEIGHT = "26px";
|
||||
const INPUT_FONT_SIZE = "0.875rem"; // 14px
|
||||
const ERROR_FONT_SIZE = "0.75rem"; // 12px
|
||||
|
||||
// ghost border for view mode values to match edit-mode sizing
|
||||
const viewValueSx = {
|
||||
padding: "3px 6px",
|
||||
border: "1px solid transparent",
|
||||
borderRadius: "3px",
|
||||
fontSize: INPUT_FONT_SIZE,
|
||||
transition: "border-color 150ms",
|
||||
display: "inline-block",
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
whiteSpace: "nowrap" as const,
|
||||
maxWidth: "100%",
|
||||
lineHeight: "18px",
|
||||
boxSizing: "border-box" as const,
|
||||
height: INPUT_HEIGHT,
|
||||
// move chips up 3px so they fit in the box height
|
||||
"& .MuiChip-root": {
|
||||
position: "relative",
|
||||
top: "-3px"
|
||||
}
|
||||
};
|
||||
|
||||
// non-editable field styling during edit mode
|
||||
// TODO: these colors are hardcoded — consider integrating with the theme system later
|
||||
const nonEditableFieldSx = {
|
||||
...viewValueSx,
|
||||
color: "#616161",
|
||||
backgroundColor: "#F5F5F5",
|
||||
border: "1px solid #E0E0E0",
|
||||
maxWidth: {xs: "100%", lg: "286px"},
|
||||
width: "100%",
|
||||
};
|
||||
|
||||
|
||||
const rowSx = {
|
||||
py: "3px",
|
||||
minHeight: "36px",
|
||||
display: "flex",
|
||||
alignItems: "center"
|
||||
}
|
||||
|
||||
|
||||
interface RecordScreenFieldProps
|
||||
{
|
||||
field: QFieldMetaData;
|
||||
fieldName: string;
|
||||
mode: RecordScreenMode;
|
||||
record?: QRecord;
|
||||
formFieldDef?: DynamicFormFieldDefinition;
|
||||
tableMetaData?: QTableMetaData;
|
||||
tableVariant?: QTableVariant;
|
||||
onEditIconClick?: (fieldName?: string) => void;
|
||||
}
|
||||
|
||||
/***************************************************************************
|
||||
** A single field that renders in either view or edit mode.
|
||||
** In view mode: label on left, display value on right (with ghost border).
|
||||
** In edit mode: label on left, input control on right.
|
||||
***************************************************************************/
|
||||
export default function RecordScreenField({field, fieldName, mode, record, formFieldDef, tableMetaData, tableVariant, onEditIconClick}: RecordScreenFieldProps): JSX.Element
|
||||
{
|
||||
const {setFieldValue, handleFieldBlur} = useContext(RecordScreenContext);
|
||||
const {helpHelpActive} = useContext(QContext);
|
||||
const {values: formikValues} = useFormikContext<any>();
|
||||
const isEditing = mode === "edit" || mode === "create";
|
||||
const isEditable = field.isEditable && isEditing && (formFieldDef?.isEditable !== false);
|
||||
const [showCopied, setShowCopied] = useState(false);
|
||||
|
||||
const handleCopyValue = useCallback((e: React.MouseEvent) =>
|
||||
{
|
||||
e.stopPropagation();
|
||||
// use getDisplayValue for proper formatting (e.g., date-times), but fall back
|
||||
// to plain display/raw values since getDisplayValue can return JSX for some types
|
||||
let formattedValue = record ? ValueUtils.getDisplayValue(field, record, "view", fieldName, tableVariant) : null;
|
||||
if (formattedValue != null && typeof formattedValue === "object")
|
||||
{
|
||||
// getDisplayValue returned JSX — fall back to display value or raw value
|
||||
formattedValue = record?.displayValues?.get(fieldName) ?? record?.values?.get(fieldName);
|
||||
}
|
||||
const textToCopy = formattedValue != null && formattedValue !== "" ? String(formattedValue) : "";
|
||||
if (textToCopy)
|
||||
{
|
||||
navigator.clipboard.writeText(textToCopy);
|
||||
setShowCopied(true);
|
||||
setTimeout(() => setShowCopied(false), 1500);
|
||||
}
|
||||
}, [record, field, fieldName, tableVariant]);
|
||||
const label = field.label;
|
||||
|
||||
const helpRoles = isEditing
|
||||
? [(mode === "create" ? "INSERT_SCREEN" : "EDIT_SCREEN"), "WRITE_SCREENS", "ALL_SCREENS"]
|
||||
: ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
|
||||
const showHelp = helpHelpActive || hasHelpContent(field.helpContents, helpRoles);
|
||||
const helpContent = <HelpContent helpContents={field.helpContents} roles={helpRoles} heading={isEditing ? undefined : label} helpContentKey={`table:${tableMetaData?.name};field:${fieldName}`} />;
|
||||
|
||||
// widget-adorned fields render specially
|
||||
if (field.hasAdornment(AdornmentType.WIDGET))
|
||||
{
|
||||
if (isEditing)
|
||||
{
|
||||
return (
|
||||
<>
|
||||
<DynamicFormFieldAsWidget
|
||||
name={fieldName}
|
||||
fieldMetaData={field}
|
||||
setValueCallback={(name: string, value: any) => setFieldValue(name, value)}
|
||||
otherValues={formikValues}
|
||||
/>
|
||||
<AnimatedFieldError fieldName={fieldName} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
return <FieldValueAsWidget field={field} record={record} />;
|
||||
}
|
||||
|
||||
const labelElement = (
|
||||
<Typography
|
||||
className="fieldLabel"
|
||||
variant="button"
|
||||
textTransform="none"
|
||||
fontWeight="bold"
|
||||
pr={1}
|
||||
color="rgb(52, 71, 103)"
|
||||
{...(isEditable ? {component: "label", htmlFor: fieldName} : {})}
|
||||
sx={{
|
||||
cursor: isEditable ? "pointer" : "default",
|
||||
flexShrink: 0,
|
||||
fontSize: INPUT_FONT_SIZE,
|
||||
lineHeight: 1.2,
|
||||
maxWidth: {xs: "174px", lg: "none"},
|
||||
}}
|
||||
>
|
||||
{label}{isEditing && field.isRequired && <span style={{marginLeft: "1px"}}>*</span>}:
|
||||
</Typography>
|
||||
);
|
||||
|
||||
const wrappedLabel = showHelp && helpContent && !isEditing
|
||||
? <Tooltip title={helpContent}>{labelElement}</Tooltip>
|
||||
: labelElement;
|
||||
|
||||
/////////////////////////
|
||||
// VIEW MODE RENDERING //
|
||||
/////////////////////////
|
||||
if (!isEditable)
|
||||
{
|
||||
const valueSx = isEditing ? nonEditableFieldSx : viewValueSx;
|
||||
// in edit mode, show plain text (no links) for non-editable fields
|
||||
let displayValue: any;
|
||||
if (isEditing)
|
||||
{
|
||||
// for PV fields, use the initialDisplayValue from the form field def
|
||||
// (which was looked up during form data setup); otherwise use
|
||||
// ValueUtils.getDisplayValue for proper formatting (e.g., date-times),
|
||||
// falling back to plain display/raw values if it returns JSX (links, chips, etc.)
|
||||
displayValue = formFieldDef?.possibleValueProps?.initialDisplayValue;
|
||||
if (displayValue == null)
|
||||
{
|
||||
displayValue = record ? ValueUtils.getDisplayValue(field, record, "view", fieldName, tableVariant) : null;
|
||||
if (displayValue != null && typeof displayValue === "object")
|
||||
{
|
||||
displayValue = record?.displayValues?.get(fieldName) ?? record?.values?.get(fieldName);
|
||||
}
|
||||
}
|
||||
displayValue = displayValue ?? formikValues?.[fieldName] ?? "";
|
||||
}
|
||||
else
|
||||
{
|
||||
displayValue = record ? ValueUtils.getDisplayValue(field, record, "view", fieldName, tableVariant) : "";
|
||||
}
|
||||
const rawValue = record?.values?.get(fieldName) ?? formikValues?.[fieldName];
|
||||
const hasValue = rawValue != null && String(rawValue) !== "";
|
||||
|
||||
return (
|
||||
<Box sx={rowSx} data-field-name={fieldName}>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
sx={{
|
||||
minWidth: 0,
|
||||
flex: 1,
|
||||
"&:hover .field-action-icons": {opacity: 1},
|
||||
}}
|
||||
>
|
||||
{wrappedLabel}
|
||||
<Typography
|
||||
variant="button"
|
||||
textTransform="none"
|
||||
fontWeight="regular"
|
||||
color={isEditing ? "#616161" : "rgb(123, 128, 154)"}
|
||||
sx={valueSx}
|
||||
>
|
||||
{displayValue}
|
||||
</Typography>
|
||||
{mode === "view" && (
|
||||
<Box className="field-action-icons" sx={{opacity: showCopied ? 1 : 0, transition: "opacity 150ms", display: "flex", alignItems: "center", flexShrink: 0}}>
|
||||
{hasValue && (
|
||||
<Tooltip title={showCopied ? "Copied" : "Copy value"} open={showCopied || undefined} placement="top" enterDelay={500} enterNextDelay={300}>
|
||||
<IconButton size="small" onClick={handleCopyValue} sx={{ml: 0.5}}>
|
||||
<Icon fontSize="small">content_copy</Icon>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
{field.isEditable && !fieldName.includes(".") && onEditIconClick && (
|
||||
<Tooltip title="Edit" placement="top" enterDelay={500} enterNextDelay={300}>
|
||||
<IconButton size="small" onClick={() => onEditIconClick(fieldName)} sx={{ml: 0.25}}>
|
||||
<Icon fontSize="small">edit</Icon>
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
/////////////////////////
|
||||
// EDIT MODE RENDERING //
|
||||
/////////////////////////
|
||||
return (
|
||||
<Box data-field-name={fieldName}>
|
||||
<Box sx={rowSx}>
|
||||
{wrappedLabel}
|
||||
<Box sx={{maxWidth: {xs: "none", lg: "286px"}, flex: 1, minWidth: 0, lineHeight: 1}}>
|
||||
{renderEditControl(field, fieldName, formFieldDef, formikValues, setFieldValue, handleFieldBlur)}
|
||||
</Box>
|
||||
</Box>
|
||||
{showHelp && <Box color="#757575" fontSize="0.875rem" mt="-0.25rem" pl="2rem">{helpContent}</Box>}
|
||||
<AnimatedFieldError fieldName={fieldName} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Animated error message that slides open/closed.
|
||||
** Stays mounted so the close animation can play when the error clears.
|
||||
***************************************************************************/
|
||||
function AnimatedFieldError({fieldName}: {fieldName: string}): JSX.Element
|
||||
{
|
||||
const {getFieldMeta, submitCount} = useFormikContext<any>();
|
||||
const meta = getFieldMeta(fieldName);
|
||||
const error = (meta.touched || submitCount > 0) && meta.error ? meta.error : undefined;
|
||||
const [displayedError, setDisplayedError] = useState<string | undefined>(undefined);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const [height, setHeight] = useState(0);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
if (error)
|
||||
{
|
||||
// Show the new error text immediately so we can measure it
|
||||
setDisplayedError(error);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Keep displayed text during close animation, clear after transition
|
||||
const timer = setTimeout(() => setDisplayedError(undefined), 200);
|
||||
setHeight(0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [error]);
|
||||
|
||||
// Measure after displayedError updates the DOM
|
||||
useEffect(() =>
|
||||
{
|
||||
if (displayedError && contentRef.current)
|
||||
{
|
||||
setHeight(contentRef.current.scrollHeight);
|
||||
}
|
||||
}, [displayedError]);
|
||||
|
||||
return (
|
||||
<Box sx={{
|
||||
height: `${height}px`,
|
||||
overflow: "hidden",
|
||||
transition: "height 200ms ease-in-out",
|
||||
fontSize: ERROR_FONT_SIZE,
|
||||
paddingBottom: displayedError ? "2px" : "0",
|
||||
}}>
|
||||
<Box ref={contentRef}>
|
||||
{displayedError && <Typography variant="caption" color="error" data-field-error={fieldName}>{displayedError}</Typography>}
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** render the appropriate edit control for a field
|
||||
***************************************************************************/
|
||||
function renderEditControl(field: QFieldMetaData, fieldName: string, formFieldDef: DynamicFormFieldDefinition, formikValues?: any, setFieldValue?: (name: string, value: any) => void, handleFieldBlur?: (fieldName: string, value: any) => void): JSX.Element
|
||||
{
|
||||
if (!formFieldDef)
|
||||
{
|
||||
return <Typography variant="body2" color="text.secondary">Field not configured</Typography>;
|
||||
}
|
||||
|
||||
// possible value / dropdown
|
||||
if (formFieldDef.possibleValueProps)
|
||||
{
|
||||
return (
|
||||
<DynamicSelect
|
||||
fieldPossibleValueProps={formFieldDef.possibleValueProps}
|
||||
name={fieldName}
|
||||
isEditable={formFieldDef.isEditable}
|
||||
fieldLabel=""
|
||||
useCase="form"
|
||||
inForm={false}
|
||||
initialValue={formikValues?.[fieldName]}
|
||||
onChange={(value: any) => setFieldValue(fieldName, value ? value.id : null)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// boolean / checkbox
|
||||
if (formFieldDef.type === "checkbox")
|
||||
{
|
||||
return <BooleanFieldSwitch name={fieldName} label="" value={formikValues?.[fieldName] ?? false} isDisabled={!formFieldDef.isEditable} />;
|
||||
}
|
||||
|
||||
// default: text/number/datetime/etc via Formik Field
|
||||
const isToUpperCase = DynamicFormUtils.isToUpperCase(field);
|
||||
const isToLowerCase = DynamicFormUtils.isToLowerCase(field);
|
||||
|
||||
let caseChangeOnChange: any = {};
|
||||
if (isToUpperCase || isToLowerCase)
|
||||
{
|
||||
caseChangeOnChange.onChange = (e: any) =>
|
||||
{
|
||||
const beforeStart = e.target.selectionStart;
|
||||
const beforeEnd = e.target.selectionEnd;
|
||||
|
||||
flushSync(() =>
|
||||
{
|
||||
let newValue = e.currentTarget.value;
|
||||
if (isToUpperCase)
|
||||
{
|
||||
newValue = newValue.toUpperCase();
|
||||
}
|
||||
if (isToLowerCase)
|
||||
{
|
||||
newValue = newValue.toLowerCase();
|
||||
}
|
||||
setFieldValue(fieldName, newValue);
|
||||
});
|
||||
|
||||
const input = document.getElementById(fieldName) as HTMLInputElement;
|
||||
if (input)
|
||||
{
|
||||
input.setSelectionRange(beforeStart, beforeEnd);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<Field
|
||||
name={fieldName}
|
||||
id={fieldName}
|
||||
type={formFieldDef.type}
|
||||
as={OutlinedInput}
|
||||
size="small"
|
||||
fullWidth
|
||||
sx={{
|
||||
".compactForm &": {
|
||||
height: INPUT_HEIGHT,
|
||||
},
|
||||
".compactForm & .MuiOutlinedInput-input": {
|
||||
padding: "3px 6px",
|
||||
fontSize: INPUT_FONT_SIZE,
|
||||
height: "18px",
|
||||
boxSizing: "content-box",
|
||||
},
|
||||
".compactForm & .MuiOutlinedInput-notchedOutline": {
|
||||
borderRadius: "3px",
|
||||
},
|
||||
}}
|
||||
inputProps={{
|
||||
id: fieldName,
|
||||
style: {
|
||||
overflow: "hidden",
|
||||
textOverflow: "ellipsis",
|
||||
},
|
||||
}}
|
||||
onKeyDown={(e: React.KeyboardEvent) =>
|
||||
{
|
||||
if (e.key === "Enter")
|
||||
{
|
||||
e.preventDefault();
|
||||
}
|
||||
}}
|
||||
onBlur={(e: React.FocusEvent<HTMLInputElement>) =>
|
||||
{
|
||||
handleFieldBlur?.(fieldName, e.target.value);
|
||||
}}
|
||||
{...caseChangeOnChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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 "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import Box from "@mui/material/Box";
|
||||
import Card from "@mui/material/Card";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Modal from "@mui/material/Modal";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
|
||||
import RecordScreenBody from "qqq/pages/records/RecordScreenBody";
|
||||
import {useRecordScreen, UseRecordScreenOptions} from "qqq/pages/records/useRecordScreen";
|
||||
import React, {useEffect, useRef, useState} from "react";
|
||||
|
||||
|
||||
interface RecordScreenModalProps
|
||||
{
|
||||
open: boolean;
|
||||
onClose: (event: object, reason: string) => void;
|
||||
tableName: string;
|
||||
recordId?: string;
|
||||
defaultValues?: { [key: string]: any };
|
||||
disabledFields?: { [key: string]: boolean } | string[];
|
||||
overrideTableMetaData?: QTableMetaData;
|
||||
onSubmitCallback?: (values: any, tableName: string) => void;
|
||||
overrideHeading?: string;
|
||||
saveButtonLabel?: string;
|
||||
saveButtonIcon?: string;
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Modal wrapper that renders RecordScreenBody inside a MUI Modal + Card.
|
||||
** Used for child record create/edit modals.
|
||||
***************************************************************************/
|
||||
export default function RecordScreenModal({
|
||||
open,
|
||||
onClose,
|
||||
tableName,
|
||||
recordId,
|
||||
defaultValues,
|
||||
disabledFields,
|
||||
overrideTableMetaData,
|
||||
onSubmitCallback,
|
||||
overrideHeading,
|
||||
saveButtonLabel = "Save",
|
||||
saveButtonIcon = "save",
|
||||
}: RecordScreenModalProps): JSX.Element
|
||||
{
|
||||
const formikSubmitRef = useRef<() => void>(null);
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const sentinelRef = useRef<HTMLDivElement>(null);
|
||||
const [isSticking, setIsSticking] = useState(false);
|
||||
|
||||
const options: UseRecordScreenOptions = {
|
||||
skipNavigation: true,
|
||||
defaultValues,
|
||||
disabledFields,
|
||||
overrideTableMetaData,
|
||||
onSubmitCallback,
|
||||
};
|
||||
|
||||
const initialMode = recordId ? "edit" : "create";
|
||||
const screen = useRecordScreen(tableName, recordId, initialMode, false, options);
|
||||
|
||||
useEffect(() =>
|
||||
{
|
||||
const sentinel = sentinelRef.current;
|
||||
const root = scrollContainerRef.current;
|
||||
if (!sentinel || !root) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => setIsSticking(!entry.isIntersecting),
|
||||
{root, threshold: 0}
|
||||
);
|
||||
observer.observe(sentinel);
|
||||
return () => observer.disconnect();
|
||||
}, [screen.loading]);
|
||||
|
||||
const heading = overrideHeading
|
||||
?? (recordId
|
||||
? `Editing ${screen.tableMetaData?.label ?? ""}`
|
||||
: `Creating New ${screen.tableMetaData?.label ?? ""}`);
|
||||
|
||||
const handleModalClose = (event: object, reason: string) =>
|
||||
{
|
||||
if (reason === "backdropClick" || reason === "escapeKeyDown")
|
||||
{
|
||||
return;
|
||||
}
|
||||
onClose(event, reason);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any) =>
|
||||
{
|
||||
await screen.saveRecord(values);
|
||||
onClose(null, recordId ? "recordUpdated" : "recordCreated");
|
||||
};
|
||||
|
||||
const handleCancel = () =>
|
||||
{
|
||||
onClose(null, "cancelled");
|
||||
};
|
||||
|
||||
const renderBottomBar = (isSubmitting: boolean) => (
|
||||
<>
|
||||
<Box component="div" p={3} className={`stickyBottomButtonBar modalBottomButtonBar${isSticking ? " sticking" : ""}`}>
|
||||
<Grid container justifyContent="flex-end" spacing={3}>
|
||||
<QCancelButton onClickHandler={handleCancel} disabled={isSubmitting} />
|
||||
<QSaveButton disabled={isSubmitting} label={saveButtonLabel} iconName={saveButtonIcon} />
|
||||
</Grid>
|
||||
</Box>
|
||||
<div ref={sentinelRef} style={{height: "1px"}} />
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal open={open} onClose={handleModalClose}>
|
||||
<Box ref={scrollContainerRef} className="modalEditForm compactForm" sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%", "& form > .MuiGrid-container": {pb: "16px"}}}>
|
||||
<Card sx={{my: 5, mx: "auto", p: 4, pb: 0, maxWidth: "1024px"}}>
|
||||
<Box mb={2}>
|
||||
<Typography variant="h5" fontWeight={500}>
|
||||
{heading}
|
||||
</Typography>
|
||||
</Box>
|
||||
|
||||
{!screen.loading && (
|
||||
<RecordScreenBody
|
||||
screen={screen}
|
||||
formId="record-screen-modal-form"
|
||||
onSubmit={handleSubmit}
|
||||
formikSubmitRef={formikSubmitRef}
|
||||
renderBottomBar={renderBottomBar}
|
||||
showSidebar={false}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Box>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,335 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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 Card from "@mui/material/Card";
|
||||
import CircularProgress from "@mui/material/CircularProgress";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Icon from "@mui/material/Icon";
|
||||
import IconButton from "@mui/material/IconButton";
|
||||
import Typography from "@mui/material/Typography";
|
||||
import {QTableSection} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableSection";
|
||||
import {QTableVariant} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableVariant";
|
||||
import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import {DynamicFormFieldDefinition} from "qqq/components/forms/DynamicFormUtils";
|
||||
import HelpContent from "qqq/components/misc/HelpContent";
|
||||
import DashboardWidgets from "qqq/components/widgets/DashboardWidgets";
|
||||
import RecordGridWidget from "qqq/components/widgets/misc/RecordGridWidget";
|
||||
import RecordScreenContext, {RecordScreenMode} from "qqq/pages/records/RecordScreenContext";
|
||||
import RecordScreenField from "qqq/pages/records/RecordScreenField";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
import {useFormikContext} from "formik";
|
||||
import React, {useContext, memo} from "react";
|
||||
|
||||
|
||||
interface RecordScreenSectionProps
|
||||
{
|
||||
section: QTableSection;
|
||||
mode: RecordScreenMode;
|
||||
record?: QRecord;
|
||||
formFieldsBySection?: Map<string, DynamicFormFieldDefinition[]>;
|
||||
isOpen?: boolean;
|
||||
isT1?: boolean;
|
||||
onToggleCollapse?: () => void;
|
||||
onEditIconClick?: (fieldName?: string) => void;
|
||||
tableVariant?: QTableVariant;
|
||||
widgetReloadCounter?: number;
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Wrapper that reads Formik values and passes them to DashboardWidgets.
|
||||
** This is NOT memoized, so it re-renders when Formik values change —
|
||||
** which is needed for widgets like FilterAndColumnsSetup that depend on
|
||||
** form field values (e.g., tableName).
|
||||
***************************************************************************/
|
||||
function FormikAwareDashboardWidgets({sectionName, mode, recordId, tableMetaDataName, widgetMetaDataList, record, primaryKeyField, widgetReloadCounter, setFieldValue, addSubValidations}: {
|
||||
sectionName: string;
|
||||
mode: string;
|
||||
recordId: any;
|
||||
tableMetaDataName: string;
|
||||
widgetMetaDataList: any[];
|
||||
record: QRecord;
|
||||
primaryKeyField: string;
|
||||
widgetReloadCounter: number;
|
||||
setFieldValue: (name: string, value: any) => void;
|
||||
addSubValidations?: (name: string, validations: Record<string, any>) => void;
|
||||
}): JSX.Element
|
||||
{
|
||||
const {values: formikValues} = useFormikContext<any>();
|
||||
|
||||
return (
|
||||
<DashboardWidgets
|
||||
key={`${sectionName}-${mode}-${recordId ?? "new"}-${widgetReloadCounter}`}
|
||||
tableName={tableMetaDataName}
|
||||
widgetMetaDataList={widgetMetaDataList}
|
||||
record={record}
|
||||
entityPrimaryKey={record?.values?.get(primaryKeyField)}
|
||||
omitWrappingGridContainer={true}
|
||||
screen="recordEdit"
|
||||
values={formikValues ?? {}}
|
||||
addSubValidations={addSubValidations}
|
||||
actionCallback={(data: any) =>
|
||||
{
|
||||
if (data && typeof data === "object")
|
||||
{
|
||||
for (const [key, value] of Object.entries(data))
|
||||
{
|
||||
setFieldValue(key, value);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Renders a single section (card) on the RecordScreen.
|
||||
** Handles both field-based and widget-based sections.
|
||||
***************************************************************************/
|
||||
function RecordScreenSection({section, mode, record, formFieldsBySection, isOpen = true, isT1 = false, onToggleCollapse, onEditIconClick, tableVariant, widgetReloadCounter = 0}: RecordScreenSectionProps): JSX.Element
|
||||
{
|
||||
const {tableMetaData, metaData, childListWidgetData, openAddChildRecord, openEditChildRecord, deleteChildRecord, setFieldValue, isFormDisabled, addSubValidations} = useContext(RecordScreenContext);
|
||||
|
||||
if (!section || section.isHidden)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const isCollapsible = !isT1 && section.collapsible?.isCollapsible;
|
||||
const moreHeaderProps = isCollapsible ? {sx: {cursor: "pointer"}, onClick: onToggleCollapse} : {};
|
||||
|
||||
// widget section
|
||||
if (section.widgetName && metaData)
|
||||
{
|
||||
const widgetMetaData = metaData.widgets.get(section.widgetName);
|
||||
if (!widgetMetaData)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (section.collapsible)
|
||||
{
|
||||
widgetMetaData.collapsible = section.collapsible;
|
||||
}
|
||||
|
||||
// In edit mode, managed-association childRecordList widgets render directly
|
||||
// as RecordGridWidget with in-memory CRUD callbacks (like EntityForm does).
|
||||
const isManagedChildRecordList = mode !== "view"
|
||||
&& widgetMetaData.type === "childRecordList"
|
||||
&& widgetMetaData.defaultValues?.has("manageAssociationName");
|
||||
|
||||
if (isManagedChildRecordList)
|
||||
{
|
||||
if (!childListWidgetData[section.widgetName])
|
||||
{
|
||||
// data is loading — show a placeholder instead of DashboardWidgets
|
||||
return (
|
||||
<Box width="100%" flexGrow={1} alignItems="stretch" display="flex" justifyContent="center" p={3}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const widgetData = childListWidgetData[section.widgetName];
|
||||
widgetData.viewAllLink = null;
|
||||
if (!isFormDisabled)
|
||||
{
|
||||
widgetData.canAddChildRecord = true;
|
||||
}
|
||||
widgetMetaData.showExportButton = false;
|
||||
|
||||
return (
|
||||
<Box width="100%" flexGrow={1} alignItems="stretch" sx={{"& .widget.inCard": {padding: "24px"}}}>
|
||||
<RecordGridWidget
|
||||
widgetMetaData={widgetMetaData}
|
||||
data={widgetData}
|
||||
disableRowClick
|
||||
allowRecordEdit={!isFormDisabled}
|
||||
allowRecordDelete={!isFormDisabled}
|
||||
addNewRecordCallback={() => openAddChildRecord(section.widgetName, widgetData)}
|
||||
editRecordCallback={(rowIndex) => openEditChildRecord(section.widgetName, widgetData, rowIndex)}
|
||||
deleteRecordCallback={(rowIndex) => deleteChildRecord(section.widgetName, rowIndex)}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
const isEditing = mode === "edit" || mode === "create";
|
||||
|
||||
return (
|
||||
<Box width="100%" height="100%" flexGrow={1} alignItems="stretch" sx={{"& .widget.inCard": {padding: "24px"}}}>
|
||||
{isEditing ? (
|
||||
<FormikAwareDashboardWidgets
|
||||
sectionName={section.name}
|
||||
mode={mode}
|
||||
recordId={record?.values?.get("id")}
|
||||
tableMetaDataName={tableMetaData.name}
|
||||
widgetMetaDataList={[widgetMetaData]}
|
||||
record={record}
|
||||
primaryKeyField={tableMetaData.primaryKeyField}
|
||||
widgetReloadCounter={widgetReloadCounter}
|
||||
setFieldValue={setFieldValue}
|
||||
addSubValidations={addSubValidations}
|
||||
/>
|
||||
) : (
|
||||
<DashboardWidgets
|
||||
key={`${section.name}-${mode}-${record?.values?.get("id") ?? "new"}-${widgetReloadCounter}`}
|
||||
tableName={tableMetaData.name}
|
||||
widgetMetaDataList={[widgetMetaData]}
|
||||
record={record}
|
||||
entityPrimaryKey={record?.values?.get(tableMetaData.primaryKeyField)}
|
||||
omitWrappingGridContainer={true}
|
||||
screen="recordView"
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
|
||||
// field section
|
||||
if (!section.fieldNames || section.fieldNames.length === 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const helpRoles = (mode === "edit" || mode === "create")
|
||||
? [(mode === "create" ? "INSERT_SCREEN" : "EDIT_SCREEN"), "WRITE_SCREENS", "ALL_SCREENS"]
|
||||
: ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
|
||||
const sectionHelp = <HelpContent helpContents={section.helpContents} roles={helpRoles} helpContentKey={`table:${tableMetaData?.name};section:${section.name}`} />;
|
||||
|
||||
const sectionFormFields = formFieldsBySection?.get(section.name);
|
||||
|
||||
// build a lookup for form field defs by field name
|
||||
const formFieldDefMap: { [key: string]: DynamicFormFieldDefinition } = {};
|
||||
if (sectionFormFields)
|
||||
{
|
||||
for (let ff of sectionFormFields)
|
||||
{
|
||||
formFieldDefMap[ff.name] = ff;
|
||||
}
|
||||
}
|
||||
|
||||
const fieldsContent = (
|
||||
<>
|
||||
{sectionHelp && (
|
||||
<Box px="1.5rem" pb="0.5rem" fontSize="0.875rem">
|
||||
{sectionHelp}
|
||||
</Box>
|
||||
)}
|
||||
<Box p={3} pt={0} flexDirection="column">
|
||||
<Grid container display="flex" spacing={0}>
|
||||
{section.fieldNames.map((fieldName: string) =>
|
||||
{
|
||||
let [field] = tableMetaData ? TableUtils.getFieldAndTable(tableMetaData, fieldName) : [null, null];
|
||||
|
||||
if (field == null && tableMetaData?.virtualFields?.has(fieldName))
|
||||
{
|
||||
field = tableMetaData.virtualFields.get(fieldName);
|
||||
}
|
||||
|
||||
if (field == null || field.isHidden)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// skip join fields in edit/create mode — they aren't editable from this table
|
||||
if ((mode === "edit" || mode === "create") && fieldName.includes("."))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
const gridColumns = (field.gridColumns && field.gridColumns > 0) ? field.gridColumns : 12;
|
||||
|
||||
return (
|
||||
<Grid item key={fieldName} lg={gridColumns} xs={12} pt={0}>
|
||||
<RecordScreenField
|
||||
field={field}
|
||||
fieldName={fieldName}
|
||||
mode={mode}
|
||||
record={record}
|
||||
formFieldDef={formFieldDefMap[fieldName]}
|
||||
tableMetaData={tableMetaData}
|
||||
tableVariant={tableVariant}
|
||||
onEditIconClick={onEditIconClick}
|
||||
/>
|
||||
</Grid>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Box>
|
||||
</>
|
||||
);
|
||||
|
||||
// T1 sections render without a Card wrapper (parent already provides one)
|
||||
if (isT1)
|
||||
{
|
||||
return fieldsContent;
|
||||
}
|
||||
|
||||
return (
|
||||
<Card id={section.name} className="recordScreenFieldSection" sx={{overflow: "visible", scrollMarginTop: "100px", height: isOpen ? "100%" : "auto"}}>
|
||||
<Box display="flex" justifyContent="space-between" alignItems="center" pb={isOpen ? 0 : 1.5} {...moreHeaderProps}>
|
||||
<Typography variant="h6" p={3} pb={1}>
|
||||
{section.label}
|
||||
</Typography>
|
||||
{isCollapsible && (
|
||||
<Box p="0.75rem 0.25rem 0">
|
||||
<IconButton onClick={(e) =>
|
||||
{
|
||||
e.stopPropagation();
|
||||
onToggleCollapse?.();
|
||||
}}>
|
||||
<Icon fontSize="large">{isOpen ? "expand_less" : "expand_more"}</Icon>
|
||||
</IconButton>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
<Box style={{display: isOpen ? "block" : "none"}}>
|
||||
{fieldsContent}
|
||||
</Box>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/***************************************************************************
|
||||
** Memoized export — prevents widget sections from re-rendering on every
|
||||
** Formik keystroke. Only re-renders when meaningful props change.
|
||||
** Callback props (onToggleCollapse, onEditIconClick) are excluded from
|
||||
** comparison since they're recreated each render but functionally stable.
|
||||
***************************************************************************/
|
||||
export default memo(RecordScreenSection, (prev, next) =>
|
||||
{
|
||||
return (
|
||||
prev.section === next.section
|
||||
&& prev.mode === next.mode
|
||||
&& prev.record === next.record
|
||||
&& prev.formFieldsBySection === next.formFieldsBySection
|
||||
&& prev.isOpen === next.isOpen
|
||||
&& prev.isT1 === next.isT1
|
||||
&& prev.tableVariant === next.tableVariant
|
||||
&& prev.widgetReloadCounter === next.widgetReloadCounter
|
||||
// Note: childListWidgetData changes are detected via context, not props
|
||||
);
|
||||
});
|
||||
@@ -1,48 +0,0 @@
|
||||
/*
|
||||
* 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 {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import Box from "@mui/material/Box";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import EntityForm from "qqq/components/forms/EntityForm";
|
||||
import BaseLayout from "qqq/layouts/BaseLayout";
|
||||
|
||||
interface Props
|
||||
{
|
||||
table?: QTableMetaData;
|
||||
}
|
||||
|
||||
function EntityCreate({table}: Props): JSX.Element
|
||||
{
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Box mb={3}>
|
||||
<EntityForm table={table} />
|
||||
</Box>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
EntityCreate.defaultProps = {
|
||||
table: null,
|
||||
};
|
||||
|
||||
export default EntityCreate;
|
||||
@@ -1,53 +0,0 @@
|
||||
/*
|
||||
* 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 {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import Box from "@mui/material/Box";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import {useParams} from "react-router-dom";
|
||||
import EntityForm from "qqq/components/forms/EntityForm";
|
||||
import BaseLayout from "qqq/layouts/BaseLayout";
|
||||
|
||||
interface Props
|
||||
{
|
||||
table?: QTableMetaData;
|
||||
isCopy?: boolean
|
||||
}
|
||||
|
||||
EntityEdit.defaultProps = {
|
||||
table: null,
|
||||
isCopy: false
|
||||
};
|
||||
|
||||
function EntityEdit({table, isCopy}: Props): JSX.Element
|
||||
{
|
||||
const {id} = useParams();
|
||||
|
||||
return (
|
||||
<BaseLayout>
|
||||
<Box mb={3}>
|
||||
<EntityForm table={table} id={id} isCopy={isCopy} />
|
||||
</Box>
|
||||
</BaseLayout>
|
||||
);
|
||||
}
|
||||
|
||||
export default EntityEdit;
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -29,28 +29,27 @@ import {QueryJoin} from "@qrunio/qqq-frontend-core/lib/model/query/QueryJoin";
|
||||
import {Alert, Box} from "@mui/material";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import BaseLayout from "qqq/layouts/BaseLayout";
|
||||
import RecordView, {getVisibleJoinTables} from "qqq/pages/records/view/RecordView";
|
||||
import Client from "qqq/utils/qqq/Client";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import {useSearchParams} from "react-router-dom";
|
||||
import {useLocation, useNavigate, useSearchParams} from "react-router-dom";
|
||||
|
||||
interface RecordViewByUniqueKeyProps
|
||||
{
|
||||
table: QTableMetaData;
|
||||
}
|
||||
|
||||
RecordViewByUniqueKey.defaultProps = {};
|
||||
|
||||
const qController = Client.getInstance();
|
||||
|
||||
/***************************************************************************
|
||||
** Wrapper around RecordView, that reads a unique key from the query string,
|
||||
** looks for a record matching that key, and shows that record.
|
||||
** Wrapper that reads a unique key from the query string, looks for a
|
||||
** matching record, and redirects to the standard view URL.
|
||||
***************************************************************************/
|
||||
export default function RecordViewByUniqueKey({table}: RecordViewByUniqueKeyProps): JSX.Element
|
||||
{
|
||||
const tableName = table.name;
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
|
||||
const [asyncLoadInited, setAsyncLoadInited] = useState(false);
|
||||
const [tableMetaData, setTableMetaData] = useState(null as QTableMetaData);
|
||||
@@ -82,7 +81,7 @@ export default function RecordViewByUniqueKey({table}: RecordViewByUniqueKeyProp
|
||||
}
|
||||
|
||||
let queryJoins: QueryJoin[] = null;
|
||||
const visibleJoinTables = getVisibleJoinTables(tableMetaData);
|
||||
const visibleJoinTables = TableUtils.getVisibleJoinTables(tableMetaData);
|
||||
if (visibleJoinTables.size > 0)
|
||||
{
|
||||
queryJoins = TableUtils.getQueryJoins(tableMetaData, visibleJoinTables);
|
||||
@@ -136,13 +135,28 @@ export default function RecordViewByUniqueKey({table}: RecordViewByUniqueKeyProp
|
||||
}
|
||||
}, [queryParams]);
|
||||
|
||||
// redirect to the standard view URL once we have the record ID
|
||||
useEffect(() =>
|
||||
{
|
||||
if (doneLoading && record)
|
||||
{
|
||||
const recordId = record.values.get(tableMetaData?.primaryKeyField);
|
||||
if (recordId)
|
||||
{
|
||||
const basePath = location.pathname.replace(/\/key$/, "");
|
||||
navigate(`${basePath}/${recordId}`, {replace: true});
|
||||
}
|
||||
}
|
||||
}, [doneLoading, record]);
|
||||
|
||||
if (!doneLoading)
|
||||
{
|
||||
return (<div>Loading...</div>);
|
||||
}
|
||||
else if (record)
|
||||
{
|
||||
return (<RecordView table={table} record={record} />);
|
||||
// will redirect via the useEffect above
|
||||
return (<div>Loading...</div>);
|
||||
}
|
||||
else if (errorMessage)
|
||||
{
|
||||
|
||||
@@ -733,7 +733,8 @@ The .widgetIsCollapsed is a 2-level deep descendant of .dashboardWidgetContainer
|
||||
(and we don't want to find deeper levels, due to parent widgets maybe having an
|
||||
inner child collapsed, which is why the '> * >' is used in the :has()).
|
||||
Limit this to the record-view screen, until it's found to be wanted elsewhere. */
|
||||
.recordView .dashboardWidgetContainer:has(> * > .widgetIsCollapsed)
|
||||
.recordView .dashboardWidgetContainer:has(> * > .widgetIsCollapsed),
|
||||
.recordScreen .dashboardWidgetContainer:has(> * > .widgetIsCollapsed)
|
||||
{
|
||||
height: auto;
|
||||
}
|
||||
@@ -894,8 +895,24 @@ Limit this to the record-view screen, until it's found to be wanted elsewhere. *
|
||||
|
||||
.modalBottomButtonBar
|
||||
{
|
||||
padding-bottom: 0 !important;
|
||||
padding-right: 0 !important;
|
||||
padding-bottom: 1.5rem !important;
|
||||
padding-right: 2rem !important;
|
||||
padding-left: 2rem !important;
|
||||
margin-left: -2rem !important;
|
||||
margin-right: -2rem !important;
|
||||
margin-bottom: 0 !important;
|
||||
margin-top: 0 !important;
|
||||
background: #ffffff;
|
||||
transition: box-shadow 200ms ease;
|
||||
border-radius: 0 0 0.75rem 0.75rem;
|
||||
padding-top: 1.25rem !important;
|
||||
padding-bottom: 1.5rem !important;
|
||||
bottom: -8px;
|
||||
}
|
||||
|
||||
.modalBottomButtonBar.sticking
|
||||
{
|
||||
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
.stickyBottomButtonBar>.MuiGrid-container
|
||||
@@ -1137,3 +1154,23 @@ Limit this to the record-view screen, until it's found to be wanted elsewhere. *
|
||||
border: none;
|
||||
}
|
||||
|
||||
/* Container query: section cards become size containers so fields
|
||||
can adapt to narrow sections (e.g., gridColumns=6) */
|
||||
.recordScreenFieldSection
|
||||
{
|
||||
container-type: inline-size;
|
||||
}
|
||||
|
||||
/* When section is narrow, labels shrink to min-content to avoid
|
||||
a gap from wrapping, and long words break to stay within bounds */
|
||||
@container (max-width: 400px)
|
||||
{
|
||||
.recordScreenFieldSection .fieldLabel
|
||||
{
|
||||
flex-basis: min-content;
|
||||
overflow-wrap: break-word;
|
||||
min-width: 130px;
|
||||
max-width: 40%;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -100,7 +100,6 @@ class TableUtils
|
||||
}
|
||||
}
|
||||
|
||||
console.log("allowedSections length: " + allowedSections.length);
|
||||
return (allowedSections);
|
||||
}
|
||||
|
||||
@@ -241,6 +240,37 @@ class TableUtils
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** For a table, get the set of join table names that are visible in any
|
||||
** non-hidden section's field list.
|
||||
*******************************************************************************/
|
||||
public static getVisibleJoinTables(tableMetaData: QTableMetaData): Set<string>
|
||||
{
|
||||
const visibleJoinTables = new Set<string>();
|
||||
|
||||
for (let i = 0; i < tableMetaData?.sections.length; i++)
|
||||
{
|
||||
const section = tableMetaData?.sections[i];
|
||||
if (section.isHidden || !section.fieldNames || !section.fieldNames.length)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
section.fieldNames.forEach((fieldName) =>
|
||||
{
|
||||
const [field, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
|
||||
if (tableForField && tableForField.name != tableMetaData.name)
|
||||
{
|
||||
visibleJoinTables.add(tableForField.name);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return (visibleJoinTables);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
export default TableUtils;
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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 {Typography} from "@mui/material";
|
||||
import Grid from "@mui/material/Grid";
|
||||
import Tooltip from "@mui/material/Tooltip/Tooltip";
|
||||
import {SxProps} from "@mui/system";
|
||||
import {AdornmentType} from "@qrunio/qqq-frontend-core/lib/model/metaData/AdornmentType";
|
||||
import {QFieldMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
|
||||
import {QTableMetaData} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableMetaData";
|
||||
import {QTableVariant} from "@qrunio/qqq-frontend-core/lib/model/metaData/QTableVariant";
|
||||
import {QRecord} from "@qrunio/qqq-frontend-core/lib/model/QRecord";
|
||||
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
|
||||
import {FieldValueAsWidget} from "qqq/components/view/FieldValueAsWidget";
|
||||
import TableUtils from "qqq/utils/qqq/TableUtils";
|
||||
import ValueUtils from "qqq/utils/qqq/ValueUtils";
|
||||
import React from "react";
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Render a grid of view-mode fields (label + display value).
|
||||
** Extracted from RecordView for reuse by CronUIWidget, DynamicFormWidget, ValidationReview.
|
||||
*******************************************************************************/
|
||||
export function renderSectionOfFields(key: string, fieldNames: string[], tableMetaData: QTableMetaData, helpHelpActive: boolean, record: QRecord, fieldMap?: { [name: string]: QFieldMetaData }, styleOverrides?: { label?: SxProps, value?: SxProps }, tableVariant?: QTableVariant)
|
||||
{
|
||||
return <Grid container lg={12} key={key} display="flex" py={1} pr={2}>
|
||||
{
|
||||
fieldNames.map((fieldName: string) =>
|
||||
{
|
||||
let [field, tableForField] = tableMetaData ? TableUtils.getFieldAndTable(tableMetaData, fieldName) : fieldMap ? [fieldMap[fieldName], null] : [null, null];
|
||||
|
||||
if(field == null)
|
||||
{
|
||||
if(tableMetaData?.virtualFields?.has(fieldName))
|
||||
{
|
||||
field = tableMetaData.virtualFields.get(fieldName);
|
||||
}
|
||||
}
|
||||
|
||||
if (field != null)
|
||||
{
|
||||
let label = field.label;
|
||||
let gridColumns = (field.gridColumns && field.gridColumns > 0) ? field.gridColumns : 12;
|
||||
|
||||
const helpRoles = ["VIEW_SCREEN", "READ_SCREENS", "ALL_SCREENS"];
|
||||
const showHelp = helpHelpActive || hasHelpContent(field.helpContents, helpRoles);
|
||||
const formattedHelpContent = <HelpContent helpContents={field.helpContents} roles={helpRoles} heading={label} helpContentKey={`table:${tableMetaData?.name};field:${fieldName}`} />;
|
||||
|
||||
const labelElement = <Typography variant="button" textTransform="none" fontWeight="bold" pr={1} color="rgb(52, 71, 103)" sx={{cursor: "default", ...(styleOverrides?.label ?? {})}}>{label}:</Typography>;
|
||||
|
||||
if(field.hasAdornment(AdornmentType.WIDGET))
|
||||
{
|
||||
return (<Grid item key={fieldName} lg={gridColumns} flexDirection="column" pr={2}>
|
||||
<FieldValueAsWidget field={field} record={record} />
|
||||
</Grid>);
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid item key={fieldName} lg={gridColumns} flexDirection="column" pr={2}>
|
||||
<>
|
||||
{
|
||||
showHelp && formattedHelpContent ? <Tooltip title={formattedHelpContent}>{labelElement}</Tooltip> : labelElement
|
||||
}
|
||||
<div style={{display: "inline-block", width: 0}}> </div>
|
||||
<Typography variant="button" textTransform="none" fontWeight="regular" color="rgb(123, 128, 154)" sx={{...(styleOverrides?.value ?? {})}}>
|
||||
{ValueUtils.getDisplayValue(field, record, "view", fieldName, tableVariant)}
|
||||
</Typography>
|
||||
</>
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
})
|
||||
}
|
||||
</Grid>;
|
||||
}
|
||||
+24
-2
@@ -159,7 +159,25 @@ public class QFMDSeleniumLib
|
||||
***************************************************************************/
|
||||
public void waitForViewScreenFieldValue(String fieldLabel, String value)
|
||||
{
|
||||
qSeleniumLib.waitForSelectorContaining(".MuiGrid-item", fieldLabel + ": " + value);
|
||||
// RecordScreenField renders label and value as separate block elements (getText returns "Label:\nValue")
|
||||
// while renderSectionOfFields (used by widgets in view mode) renders inline (getText returns "Label: Value")
|
||||
// Try both separators — space and newline.
|
||||
// Catch StaleElementReferenceException in the predicate (page may re-render between findElements and getText).
|
||||
String withSpace = fieldLabel + ": " + value;
|
||||
String withNewline = fieldLabel + ":\n" + value;
|
||||
qSeleniumLib.waitForSelectorAllSatisfyingPredicate(".MuiGrid-item",
|
||||
elements ->
|
||||
{
|
||||
try
|
||||
{
|
||||
return elements.stream().anyMatch(e -> e.getText().contains(withSpace) || e.getText().contains(withNewline));
|
||||
}
|
||||
catch(org.openqa.selenium.StaleElementReferenceException e)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
},
|
||||
"containing [" + fieldLabel + ": " + value + "]");
|
||||
}
|
||||
|
||||
|
||||
@@ -168,7 +186,11 @@ public class QFMDSeleniumLib
|
||||
***************************************************************************/
|
||||
public void waitForViewScreenFieldValueToNotExist(String fieldLabel, String value)
|
||||
{
|
||||
qSeleniumLib.waitForSelectorContainingToNotExist(".MuiGrid-item", fieldLabel + ": " + value);
|
||||
String withSpace = fieldLabel + ": " + value;
|
||||
String withNewline = fieldLabel + ":\n" + value;
|
||||
qSeleniumLib.waitForSelectorAllSatisfyingPredicate(".MuiGrid-item",
|
||||
elements -> elements.stream().noneMatch(e -> e.getText().contains(withSpace) || e.getText().contains(withNewline)),
|
||||
"not containing [" + fieldLabel + ": " + value + "]");
|
||||
}
|
||||
|
||||
|
||||
|
||||
+8
-3
@@ -22,6 +22,7 @@
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.selenium.tests;
|
||||
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QBaseSeleniumTest;
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.selenium.lib.QSeleniumLib;
|
||||
@@ -31,6 +32,8 @@ import org.junit.jupiter.api.Test;
|
||||
import org.openqa.selenium.By;
|
||||
import org.openqa.selenium.Keys;
|
||||
import org.openqa.selenium.WebElement;
|
||||
import org.openqa.selenium.support.ui.ExpectedConditions;
|
||||
import org.openqa.selenium.support.ui.WebDriverWait;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||
@@ -114,9 +117,11 @@ public class SavedReportIT extends QBaseSeleniumTest
|
||||
qSeleniumLib.waitForSelectorContaining(".advancedQueryString", "First Name");
|
||||
qSeleniumLib.waitForSelectorContaining(".advancedQueryString", "contains");
|
||||
qSeleniumLib.waitForSelectorContaining(".advancedQueryString", "Darin");
|
||||
List<WebElement> columns = qSeleniumLib.waitForSelectorContaining("h5", "Columns")
|
||||
.findElement(QSeleniumLib.PARENT)
|
||||
.findElements(By.cssSelector("DIV"));
|
||||
// Use exact text match for <h5>Columns</h5> inside the widget
|
||||
// (not the section header "Filters and Columns" which also matches "contains Columns")
|
||||
WebElement columnsH5 = new WebDriverWait(qSeleniumLib.driver, Duration.ofSeconds(10))
|
||||
.until(ExpectedConditions.presenceOfElementLocated(By.xpath("//h5[text()='Columns']")));
|
||||
List<WebElement> columns = columnsH5.findElement(QSeleniumLib.PARENT).findElements(By.cssSelector("DIV"));
|
||||
|
||||
assertThat(columns)
|
||||
.hasSizeGreaterThanOrEqualTo(5) // at least this many
|
||||
|
||||
+49
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.seleniumwithqapplication.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.processes.BackendStep;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepInput;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.processes.RunBackendStepOutput;
|
||||
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
// Simple backend step that echoes the input as a greeting. //
|
||||
// Used by the PersonGreeterProcess to test process widgets on RecordScreen. //
|
||||
///////////////////////////////////////////////////////////////////////////////
|
||||
public class PersonGreeterBackendStep implements BackendStep
|
||||
{
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public void run(RunBackendStepInput input, RunBackendStepOutput output) throws QException
|
||||
{
|
||||
String greetingName = input.getValueString("greetingName");
|
||||
output.addValue("showResults", true);
|
||||
output.addValue("greetingResult", "Hello, " + greetingName + "!");
|
||||
}
|
||||
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.seleniumwithqapplication.metadata;
|
||||
|
||||
|
||||
import java.util.List;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.fields.QFieldType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.NoCodeWidgetFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QBackendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QComponentType;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendComponentMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFrontendStepMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QFunctionInputMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.processes.QProcessMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.nocode.WidgetHtmlLine;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QCriteriaOperator;
|
||||
import com.kingsrook.qqq.backend.core.model.actions.tables.query.QFilterCriteria;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Meta Data Producer for PersonGreeterProcess — a simple process used to test
|
||||
** process-type widgets on the RecordScreen.
|
||||
*******************************************************************************/
|
||||
public class PersonGreeterProcessProducer extends MetaDataProducer<QProcessMetaData>
|
||||
{
|
||||
public static final String NAME = "personGreeterProcess";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QProcessMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QProcessMetaData()
|
||||
.withName(NAME)
|
||||
.withLabel("Person Greeter")
|
||||
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
// Step 1: Frontend — input form + output display (NoCode widget) //
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
.withStep(new QFrontendStepMetaData()
|
||||
.withName("enterInputs")
|
||||
.withLabel("Person Greeter")
|
||||
.withComponent(new QFrontendComponentMetaData()
|
||||
.withType(QComponentType.EDIT_FORM))
|
||||
.withFormField(new QFieldMetaData("greetingName", QFieldType.STRING)
|
||||
.withIsRequired(true))
|
||||
.withComponent(new NoCodeWidgetFrontendComponentMetaData()
|
||||
.withOutput(new WidgetHtmlLine()
|
||||
.withCondition(new QFilterCriteria("showResults", QCriteriaOperator.EQUALS, true))
|
||||
.withVelocityTemplate("<b>Greeting:</b> $greetingResult"))
|
||||
)
|
||||
)
|
||||
|
||||
////////////////////////////////////////
|
||||
// Step 2: Backend — runs the greeting //
|
||||
////////////////////////////////////////
|
||||
.withStep(new QBackendStepMetaData()
|
||||
.withName("runGreeter")
|
||||
.withCode(new QCodeReference(PersonGreeterBackendStep.class))
|
||||
.withInputData(new QFunctionInputMetaData()
|
||||
.withFieldList(List.of(
|
||||
new QFieldMetaData("greetingName", QFieldType.STRING)
|
||||
)))
|
||||
)
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
// Step 3: Frontend — empty preview step (required by process widget pattern) //
|
||||
//////////////////////////////////////////////////////////////////////////////////
|
||||
.withStep(new QFrontendStepMetaData()
|
||||
.withName("preview"));
|
||||
}
|
||||
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
/*
|
||||
* QQQ - Low-code Application Framework for Engineers.
|
||||
* Copyright (C) 2021-2026. 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/>.
|
||||
*/
|
||||
|
||||
package com.kingsrook.qqq.frontend.materialdashboard.seleniumwithqapplication.metadata;
|
||||
|
||||
|
||||
import com.kingsrook.qqq.backend.core.actions.dashboard.widgets.ProcessWidgetRenderer;
|
||||
import com.kingsrook.qqq.backend.core.exceptions.QException;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.MetaDataProducer;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.code.QCodeReference;
|
||||
import com.kingsrook.qqq.backend.core.model.metadata.dashboard.QWidgetMetaData;
|
||||
import com.kingsrook.qqq.backend.core.model.dashboard.widgets.WidgetType;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
** Meta Data Producer for PersonGreeterWidget — a process-type widget that
|
||||
** renders the PersonGreeterProcess inline on the record view screen.
|
||||
*******************************************************************************/
|
||||
public class PersonGreeterWidgetProducer extends MetaDataProducer<QWidgetMetaData>
|
||||
{
|
||||
public static final String NAME = "PersonGreeterWidget";
|
||||
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
**
|
||||
*******************************************************************************/
|
||||
@Override
|
||||
public QWidgetMetaData produce(QInstance qInstance) throws QException
|
||||
{
|
||||
return new QWidgetMetaData()
|
||||
.withType(WidgetType.PROCESS.getType())
|
||||
.withIsCard(true)
|
||||
.withName(NAME)
|
||||
.withLabel("Person Greeter")
|
||||
.withGridColumns(12)
|
||||
.withDefaultValue(ProcessWidgetRenderer.WIDGET_PROCESS_NAME, PersonGreeterProcessProducer.NAME)
|
||||
.withCodeReference(new QCodeReference(ProcessWidgetRenderer.class));
|
||||
}
|
||||
|
||||
}
|
||||
+10
-7
@@ -37,9 +37,11 @@ import com.kingsrook.qqq.frontend.materialdashboard.seleniumwithqapplication.met
|
||||
import com.kingsrook.qqq.frontend.materialdashboard.seleniumwithqapplication.metadata.PetTableProducer;
|
||||
import org.junit.jupiter.api.DisplayName;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.openqa.selenium.By;
|
||||
import org.openqa.selenium.WebElement;
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
|
||||
/*******************************************************************************
|
||||
@@ -144,7 +146,6 @@ public class ChildRecordListWidgetIT extends QBaseSeleniumWithQApplicationTest
|
||||
qSeleniumLib.waitForSelectorContainingToNotExist(".MuiDataGrid-rowCount", "of");
|
||||
|
||||
qSeleniumLib.waitForSelector("[aria-label=\"Export\"] button").click();
|
||||
qSeleniumLib.waitForever();
|
||||
|
||||
////////////////////////////
|
||||
// validate view all link //
|
||||
@@ -256,15 +257,17 @@ public class ChildRecordListWidgetIT extends QBaseSeleniumWithQApplicationTest
|
||||
qSeleniumLib.waitForSelectorContaining("button", "add new").click();
|
||||
qSeleniumLib.waitForSelectorContaining("h5", "Creating New Pet");
|
||||
|
||||
////////////////////////////////////////////////////
|
||||
// make sure parent foreign key input is disabled //
|
||||
////////////////////////////////////////////////////
|
||||
assertFalse(qSeleniumLib.waitForSelector("input#ownerPersonId").isEnabled());
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
// make sure parent foreign key field is present but not an input //
|
||||
// (RecordScreen renders non-editable fields as plain text, not inputs) //
|
||||
//////////////////////////////////////////////////////////////////////////
|
||||
WebElement ownerField = qSeleniumLib.waitForSelector("[data-field-name='ownerPersonId']");
|
||||
assertTrue(ownerField.findElements(By.tagName("input")).isEmpty());
|
||||
|
||||
qfmdSeleniumLib.inputTextField("name", "Stompy");
|
||||
qfmdSeleniumLib.inputDynamicSelectOption("speciesId", "Elephant");
|
||||
|
||||
qSeleniumLib.waitForSelectorContaining(".entityForm button", "Save").click();
|
||||
qSeleniumLib.waitForSelectorContaining(".modalEditForm button", "Save").click();
|
||||
|
||||
qSeleniumLib.waitForSelectorContaining(WIDGET_CELL, "Stompy");
|
||||
qSeleniumLib.waitForSelectorContaining(WIDGET_CELL, "Elephant");
|
||||
|
||||
+24
-7
@@ -213,20 +213,20 @@ public class FormAdjusterIT extends QBaseSeleniumWithQApplicationTest
|
||||
/////////////////////////////////////////////////////////////////////
|
||||
qSeleniumLib.waitForSelectorContainingToNotExist("h6", "Data");
|
||||
qSeleniumLib.waitForSelectorContainingToNotExist("h6", "Filter");
|
||||
assertSidebar(List.of("Identity"));
|
||||
assertSidebar(List.of("Identity", "Dates"));
|
||||
|
||||
qfmdSeleniumLib.inputTextField("name", "filter");
|
||||
qSeleniumLib.waitForSelectorContaining("h5", "Creating New").click(); // blur the text field to trigger the on-change
|
||||
qfmdSeleniumLib.blurByClickingBlankSpace();
|
||||
qSeleniumLib.waitForSelectorContainingToNotExist("h6", "Data");
|
||||
qSeleniumLib.waitForSelectorContaining("h6", "Filter");
|
||||
assertSidebar(List.of("Identity", "Filter"));
|
||||
assertSidebar(List.of("Identity", "Filter", "Dates"));
|
||||
|
||||
qfmdSeleniumLib.inputTextField("name", " type");
|
||||
qfmdSeleniumLib.blurByClickingBlankSpace();
|
||||
qSeleniumLib.waitForSelectorContaining("h6", "Data");
|
||||
qSeleniumLib.waitForSelectorContaining("h6", "Filter");
|
||||
assertSidebar(List.of("Identity", "Data", "Filter"));
|
||||
assertSidebar(List.of("Identity", "Data", "Filter", "Dates"));
|
||||
|
||||
////////////////////////////////////////////////////////////
|
||||
// put a value in the 'type' field. then make it go away //
|
||||
@@ -254,14 +254,31 @@ public class FormAdjusterIT extends QBaseSeleniumWithQApplicationTest
|
||||
@Test
|
||||
void testDisablingForm()
|
||||
{
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/" + TABLE_NAME + "/" + MAY_NOT_EDIT_WITH_CUSTOM_MESSAGE_ID + "/edit",
|
||||
"Edit Table With Form Adjuster: " + MAY_NOT_EDIT_WITH_CUSTOM_MESSAGE_ID);
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/" + TABLE_NAME + "/" + MAY_NOT_EDIT_WITH_CUSTOM_MESSAGE_ID + "/edit", "" + MAY_NOT_EDIT_WITH_CUSTOM_MESSAGE_ID);
|
||||
qSeleniumLib.waitForSelectorContaining("h5", "Edit Table With Form Adjuster: " + MAY_NOT_EDIT_WITH_CUSTOM_MESSAGE_ID);
|
||||
qfmdSeleniumLib.waitForAlert("No soup for you.");
|
||||
assertThatThrownBy(() -> qfmdSeleniumLib.inputTextField("size", "XL"));
|
||||
qSeleniumLib.waitForSelectorContaining("button[disabled]", "save");
|
||||
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/" + TABLE_NAME + "/" + MAY_NOT_EDIT_WITH_DEFAULT_MESSAGE_ID + "/edit",
|
||||
"Edit Table With Form Adjuster: " + MAY_NOT_EDIT_WITH_DEFAULT_MESSAGE_ID);
|
||||
qSeleniumLib.gotoAndWaitForBreadcrumbHeaderToContain("/peopleApp/greetingsApp/" + TABLE_NAME + "/" + MAY_NOT_EDIT_WITH_DEFAULT_MESSAGE_ID + "/edit", "" + MAY_NOT_EDIT_WITH_DEFAULT_MESSAGE_ID);
|
||||
qSeleniumLib.waitForSelectorContaining("h5", "Edit Table With Form Adjuster: " + MAY_NOT_EDIT_WITH_DEFAULT_MESSAGE_ID);
|
||||
qfmdSeleniumLib.waitForAlert("You are not allowed to edit this record");
|
||||
qSeleniumLib.waitForSelectorContaining("button[disabled]", "save");
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// cancel from disabled edit mode — alert should disappear and view fields should show //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
qSeleniumLib.waitForSelectorContaining("button", "Cancel").click();
|
||||
qSeleniumLib.waitForSelectorContaining("h5", "Viewing Table With Form Adjuster: " + MAY_NOT_EDIT_WITH_DEFAULT_MESSAGE_ID);
|
||||
qfmdSeleniumLib.waitForAlertToNotExist("You are not allowed to edit this record");
|
||||
qfmdSeleniumLib.waitForViewScreenFieldValue("Name", MAY_NOT_EDIT_WITH_DEFAULT_MESSAGE_NAME);
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
// from view screen, enter edit mode via instant-edit — form adjuster should still //
|
||||
// disable the form (on-load adjuster must run when entering edit via pushState too) //
|
||||
////////////////////////////////////////////////////////////////////////////////////////
|
||||
qSeleniumLib.waitForSelectorContaining("button", "Edit").click();
|
||||
qSeleniumLib.waitForSelectorContaining("h5", "Edit Table With Form Adjuster: " + MAY_NOT_EDIT_WITH_DEFAULT_MESSAGE_ID);
|
||||
qfmdSeleniumLib.waitForAlert("You are not allowed to edit this record");
|
||||
qSeleniumLib.waitForSelectorContaining("button[disabled]", "save");
|
||||
}
|
||||
|
||||
+2232
File diff suppressed because it is too large.
Load diff
@@ -1 +1 @@
|
||||
{"type":"pivotTableSetup"}
|
||||
{"type":"pivotTableSetup","queryParams":{}}
|
||||
Reference in new issue
Block a user