Compare commits

...
26 Commits
Author SHA1 Message Date
Darin KelkhoffandClaude Opus 4.6 cd29f99537 fix: null-safe record access in FieldValueAsWidget — record is null in create mode
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:20:54 -05:00
Darin KelkhoffandClaude Opus 4.6 f3ae5c1a4e fix: close actions menu in enterEditMode to prevent menu overlay blocking inputs
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:16:11 -05:00
Darin KelkhoffandClaude Opus 4.6 8ace4f8836 fix: use formFieldsRef to avoid stale formFields closure in switchMode/rebuildFormFieldsBySection
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 11:11:01 -05:00
Darin KelkhoffandClaude Opus 4.6 da3102f0da fix: rebuild formFieldsBySection in switchMode so alternative-section fields render in edit mode
Without this, sections hidden in view mode (via RECORD_VIEW alternative with
isHidden:true) would appear in edit mode but their fields showed "Field not
configured" because formFieldsBySection was only built for view-mode sections.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:08:14 -05:00
Darin KelkhoffandClaude Opus 4.6 58df2d2891 fix: on-load form adjuster runs when entering edit via instant-edit; disabled state clears on cancel
- switchMode("edit") triggers runOnLoadFormAdjuster so form-disabling works
  for view→edit transitions (not just direct /edit URL navigation)
- switchMode("view") clears notAllowedError and re-clones metadata to restore
  field editability after disableAllFields mutated the clone
- runOnLoadFormAdjuster accepts effectiveMode param to avoid stale mode closure
- Test: extended testDisablingForm to verify view→edit→cancel round-trip

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 10:00:40 -05:00
Darin Kelkhoff 45118dbbbc fix(recordScreen): handling of join fields 2026-03-27 16:20:56 -05:00
Darin KelkhoffandClaude Opus 4.6 1fd3de5535 fix: switchMode rebuilds sections synchronously to prevent view-only widget fields from rendering in edit mode
Replaces the useEffect that rebuilt sections on mode change with a switchMode
function that sets mode + rebuilds sections in one React batch. Prevents the
intermediate render where mode=edit but sections still have view-only fields
(e.g., composite widget-adorned virtual fields from RECORD_VIEW alternatives).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-27 14:57:23 -05:00
Darin Kelkhoff 30b082c512 feat(recordScreen): merge changes from dev that did action-placement and sidebar hide/show based on meta-data 2026-03-27 12:34:05 -05:00
Darin Kelkhoff a59b29e378 Merged develop into feature/instant-edit 2026-03-27 10:24:57 -05:00
Darin KelkhoffandClaude Opus 4.6 1614d929aa fix: implement downloadFileFromField action — was a no-op stub, now downloads blob/file fields from menu
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 16:29:51 -05:00
Darin Kelkhoff da729b862c fix: adjust h5 text expected in modal edits 2026-03-26 16:20:08 -05:00
Darin KelkhoffandClaude Opus 4.6 2b6e45abfd fix: match production breadcrumb/header behavior — h3 shows "Edit Table: Label" in edit mode, h5 uses "Edit" not "Editing"
- h3 page header: view = record label, edit = "Edit $table: $label"
- h5 card title: view = "Viewing $table: $label", edit = "Edit $table: $label"
- setPageHeader called on enter/exit edit mode for instant-edit (pushState)
- Updated all test references from "Editing" to "Edit"

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:33:15 -05:00
Darin KelkhoffandClaude Opus 4.6 2fbb052e47 fix: remove pathToLabelMap useEffect — breadcrumb doesn't need record label/ID (already in page header)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 14:03:56 -05:00
Darin KelkhoffandClaude Opus 4.6 3bfaf8cff9 fix: non-editable date-time fields show formatted display value in edit mode; copy icon copies formatted value
Both the non-editable field display and copy handler now use ValueUtils.getDisplayValue
for proper formatting (e.g., date-times), with fallback to plain displayValues/rawValues
when getDisplayValue returns JSX (links, chips, etc.).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:36:39 -05:00
Darin KelkhoffandClaude Opus 4.6 f35fe55741 fix: don't set pathToLabelMap in edit mode — pushState edit shares the view path, overwriting breaks breadcrumb links
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:13:51 -05:00
Darin KelkhoffandClaude Opus 4.6 f75c50a764 fix: use effectiveMode from props in data-load effect to apply section alternatives correctly; handle StaleElementReferenceException in waitForViewScreenFieldValue
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 12:12:16 -05:00
Darin KelkhoffandClaude Opus 4.6 7564a6577f fix: revert "Edit" back to "Editing" in RecordScreen title — tests expect "Editing"
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 11:14:13 -05:00
Darin KelkhoffandClaude Opus 4.6 5444c2283c fix: plumb addSubValidations so widget field validations register with Formik
Chain: useRecordScreen → RecordScreenContext → RecordScreenBody → RecordScreenSection →
FormikAwareDashboardWidgets → DashboardWidgets → CronUIWidget/RowBuilderWidget

Also add always-visible ErrorMessage in CronUIWidget (was only in Basic tab).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:47:16 -05:00
Darin KelkhoffandClaude Opus 4.6 16443d0b1a fix: CI test fixes — field value selectors, breadcrumb labels, CronUI defaults, widget error display
- waitForViewScreenFieldValue: handle both newline and space separators
- RecordScreen: set pathToLabelMap for breadcrumb headers, change "Editing" to "Edit"
- RecordScreenField: add AnimatedFieldError for widget-adorned fields
- DashboardWidgets: pass formik values to CronUIWidget for create-mode defaults
- FormAdjusterIT: expect Dates section in sidebar, split breadcrumb/title assertions
- ChildRecordListWidgetIT: remove waitForever debug call
- useRecordScreen: clarify comment on section filtering

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-26 10:21:49 -05:00
Darin Kelkhoff fa8dbc05e9 test: updating tests for new record screen in ci 2026-03-25 14:44:21 -05:00
Darin Kelkhoff cdbab47b16 test: adjust waitForViewScreenFieldValue and waitForViewScreenFieldValueToNotExist (new dom requires \n not ' ' between label and value) 2026-03-25 11:22:00 -05:00
Darin Kelkhoff b10d2f1460 feat(recordScreen): put more class names on recordScreen (recordEdit, recordView, to help with backward-compat. selectors) 2026-03-25 11:21:32 -05:00
Darin KelkhoffandClaude Opus 4.6 8bc0cb30b0 fix: address code-quality bot findings — remove unused vars/imports, fix redundant null comparisons
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:37:06 -05:00
Darin KelkhoffandClaude Opus 4.6 e044308e6b fix: remove unused useRecordScreen field from MaterialDashboardTableMetaData, merge duplicate import
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:34:47 -05:00
Darin KelkhoffandClaude Opus 4.6 17f0e9881a fix: merge duplicate import from RecordViewMenus
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 12:30:42 -05:00
Darin KelkhoffandClaude Opus 4.6 e0c4d78979 feat(recordScreen): unified RecordScreen replaces RecordView, RecordEdit, RecordCreate, and EntityForm
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-24 09:53:02 -05:00
40 changed files with 6655 additions and 3592 deletions

No files matched your search

+10 -12
View File
@@ -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} />,
});
}
}
@@ -160,6 +160,7 @@ public class MaterialDashboardInstanceMetaData implements QSupplementalInstanceM
/*******************************************************************************
** Getter for recordViewActionsPlacement
*******************************************************************************/
@@ -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
+23 -1
View File
@@ -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>
);
+20 -1
View File
@@ -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
+24 -2
View File
@@ -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(() =>
{
+25 -4
View File
@@ -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":
{
+23 -21
View File
@@ -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;
+970
View File
@@ -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>
);
}
+318
View File
@@ -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;
+449
View File
@@ -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}
/>
);
}
+159
View File
@@ -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;
-53
View File
@@ -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)
{
+40 -3
View File
@@ -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%;
}
}
+31 -1
View File
@@ -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;
+93
View File
@@ -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}}>&nbsp;</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>;
}
@@ -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 + "]");
}
@@ -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
@@ -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 + "!");
}
}
@@ -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"));
}
}
@@ -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));
}
}
@@ -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");
@@ -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");
}
@@ -1 +1 @@
{"type":"pivotTableSetup"}
{"type":"pivotTableSetup","queryParams":{}}