Compare commits

..

1 Commits

194 changed files with 13731 additions and 51111 deletions

View File

@ -2,12 +2,12 @@ version: 2.1
orbs:
node: circleci/node@5.1.0
browser-tools: circleci/browser-tools@1.4.7
browser-tools: circleci/browser-tools@1.4.5
executors:
java17:
docker:
- image: 'cimg/openjdk:17.0.9'
- image: 'cimg/openjdk:17.0'
commands:
install_java17:
@ -115,7 +115,7 @@ workflows:
context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ]
filters:
branches:
ignore: /(main|dev|integration.*)/
ignore: /main/
tags:
ignore: /(version|snapshot)-.*/
deploy:
@ -124,7 +124,7 @@ workflows:
context: [ qqq-maven-registry-credentials, kingsrook-slack, build-qqq-sample-app ]
filters:
branches:
only: /(main|dev|integration.*)/
only: /main/
tags:
only: /(version|snapshot)-.*/

View File

@ -28,7 +28,8 @@
},
"plugins": [
"react",
"@typescript-eslint"
"@typescript-eslint",
"import"
],
"rules": {
"brace-style": [
@ -42,6 +43,41 @@
"SwitchCase": 1
}
],
"import/extensions": [
"error",
"ignorePackages",
{
"ts": "never",
"tsx": "never",
"js": "never"
}
],
"import/no-extraneous-dependencies": [
"error",
{
"devDependencies": true
}
],
"import/order": [
"error",
{
"groups": [
"builtin", // Built-in imports (come from NodeJS native) go first
"external", // <- External imports
"internal", // <- Absolute imports
["sibling", "parent"], // <- Relative imports, the sibling and parent types they can be mingled together
"index", // <- index imports
"unknown"
],
"newlines-between": "never",
"alphabetize": {
/* sort in ascending order. Options: ["ignore", "asc", "desc"] */
"order": "asc",
/* ignore case. Options: [true, false] */
"caseInsensitive": true
}
}
],
"jsx-one-expression-per-line": "off",
"max-len": "off",
"no-console": "off",
@ -78,6 +114,15 @@
"quotes": [
"error",
"double"
],
"sort-imports": [
"error",
{
"ignoreCase": false,
"ignoreDeclarationSort": true,
"ignoreMemberSort": true,
"allowSeparatedGroups": false
}
]
},
"settings": {

12
cypress.config.ts Normal file
View File

@ -0,0 +1,12 @@
import {defineConfig} from "cypress";
export default defineConfig({
e2e: {
viewportHeight: 1000,
viewportWidth: 1200,
setupNodeEvents(on, config)
{
// implement node event listeners here
},
},
});

29187
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -6,14 +6,13 @@
"@auth0/auth0-react": "1.10.2",
"@emotion/react": "11.7.1",
"@emotion/styled": "11.6.0",
"@kingsrook/qqq-frontend-core": "1.0.114",
"@kingsrook/qqq-frontend-core": "1.0.82",
"@mui/icons-material": "5.4.1",
"@mui/material": "5.11.1",
"@mui/styles": "5.11.1",
"@mui/system": "5.11.1",
"@mui/x-data-grid": "5.17.23",
"@mui/x-data-grid-pro": "5.17.23",
"@mui/x-date-pickers": "7.1.1",
"@mui/x-license-pro": "5.12.3",
"@react-jvectormap/core": "1.0.1",
"@react-jvectormap/unitedstates": "1.0.1",
@ -27,7 +26,6 @@
"chroma-js": "2.4.2",
"cmdk": "0.2.0",
"datejs": "1.0.0-rc3",
"dayjs": "1.11.10",
"downshift": "3.2.10",
"faker": "5.5.3",
"form-data": "4.0.0",
@ -41,14 +39,9 @@
"react-ace": "10.1.0",
"react-chartjs-2": "3.0.4",
"react-cookie": "4.1.1",
"react-dnd": "16.0.1",
"react-dnd-html5-backend": "16.0.1",
"react-dom": "18.0.0",
"react-dropzone": "14.3.5",
"react-ga4": "2.1.0",
"react-github-btn": "1.2.1",
"react-google-drive-picker": "^1.2.0",
"react-markdown": "9.0.1",
"react-router-dom": "6.2.1",
"react-router-hash-link": "2.4.3",
"react-table": "7.7.0",
@ -60,7 +53,7 @@
"build": "react-scripts build",
"clean": "rm -rf node_modules package-lock.json lib",
"eject": "react-scripts eject",
"clean-and-install": "rm -rf node_modules/ && rm -rf package-lock.json && rm -rf lib/ && npm install --legacy-peer-deps && npm dedupe --force",
"clean-and-install": "rm -rf node_modules/ && rm -rf package-lock.json && rm -rf lib/ && npm install --legacy-peer-deps",
"npm-install": "npm install --legacy-peer-deps",
"prepublishOnly": "tsc -p ./ --outDir lib/",
"start": "BROWSER=none react-scripts --max-http-header-size=65535 start",

12
pom.xml
View File

@ -29,7 +29,7 @@
<packaging>jar</packaging>
<properties>
<revision>0.24.0-SNAPSHOT</revision>
<revision>0.19.0-SNAPSHOT</revision>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
@ -66,7 +66,7 @@
<dependency>
<groupId>com.kingsrook.qqq</groupId>
<artifactId>qqq-backend-core</artifactId>
<version>0.21.0</version>
<version>0.17.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
@ -77,13 +77,13 @@
<dependency>
<groupId>org.seleniumhq.selenium</groupId>
<artifactId>selenium-java</artifactId>
<version>4.15.0</version>
<version>4.10.0</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>io.github.bonigarcia</groupId>
<artifactId>webdrivermanager</artifactId>
<version>5.6.2</version>
<version>5.4.1</version>
<scope>test</scope>
</dependency>
<dependency>
@ -119,7 +119,7 @@
<dependency>
<groupId>org.json</groupId>
<artifactId>json</artifactId>
<version>20231013</version>
<version>20230227</version>
<scope>test</scope>
</dependency>
<dependency>
@ -154,11 +154,11 @@
<versionTagPrefix>version-</versionTagPrefix>
</gitFlowConfig>
<skipFeatureVersion>true</skipFeatureVersion> <!-- Keep feature names out of versions -->
<postReleaseGoals>install</postReleaseGoals> <!-- Let CI run deploys -->
<commitDevelopmentVersionAtStart>true</commitDevelopmentVersionAtStart>
<versionDigitToIncrement>1</versionDigitToIncrement> <!-- In general, we update the minor -->
<versionProperty>revision</versionProperty>
<skipUpdateVersion>true</skipUpdateVersion>
<skipTestProject>true</skipTestProject> <!-- we allow CI to do the tests -->
</configuration>
</plugin>

View File

@ -33,8 +33,12 @@ import CssBaseline from "@mui/material/CssBaseline";
import Icon from "@mui/material/Icon";
import {ThemeProvider} from "@mui/material/styles";
import {LicenseInfo} from "@mui/x-license-pro";
import CommandMenu from "CommandMenu";
import jwt_decode from "jwt-decode";
import React, {JSXElementConstructor, Key, ReactElement, useEffect, useState,} from "react";
import {useCookies} from "react-cookie";
import {Navigate, Route, Routes, useLocation,} from "react-router-dom";
import {Md5} from "ts-md5/dist/md5";
import CommandMenu from "CommandMenu";
import QContext from "QContext";
import Sidenav from "qqq/components/horseshoe/sidenav/SideNav";
import theme from "qqq/components/legacy/Theme";
@ -49,14 +53,8 @@ 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 GoogleAnalyticsUtils, {AnalyticsModel} from "qqq/utils/GoogleAnalyticsUtils";
import Client from "qqq/utils/qqq/Client";
import ProcessUtils from "qqq/utils/qqq/ProcessUtils";
import React, {JSXElementConstructor, Key, ReactElement, useEffect, useState,} from "react";
import {useCookies} from "react-cookie";
import {Navigate, Route, Routes, useLocation, useSearchParams,} from "react-router-dom";
import {Md5} from "ts-md5/dist/md5";
const qController = Client.getInstance();
@ -75,14 +73,6 @@ export default function App()
const [loggedInUser, setLoggedInUser] = useState({} as { name?: string, email?: string });
const [defaultRoute, setDefaultRoute] = useState("/no-apps");
/////////////////////////////////////////////////////////
// tell the client how to do a logout if it sees a 401 //
/////////////////////////////////////////////////////////
Client.setUnauthorizedCallback(() =>
{
logout();
});
const shouldStoreNewToken = (newToken: string, oldToken: string): boolean =>
{
if (!cookies[SESSION_UUID_COOKIE_NAME])
@ -106,7 +96,7 @@ export default function App()
// if the old (local storage) token is expired, then we need to store the new one //
////////////////////////////////////////////////////////////////////////////////////
const oldExp = oldJSON["exp"];
if (oldExp * 1000 < (new Date().getTime()))
if(oldExp * 1000 < (new Date().getTime()))
{
console.log("Access token in local storage was expired - so we should store a new one.");
return (true);
@ -116,21 +106,21 @@ export default function App()
// remove the exp & iat values from what we compare - as they are always different from auth0 //
// note, this is only deleting them from what we compare, not from what we'd store. //
////////////////////////////////////////////////////////////////////////////////////////////////
delete newJSON["exp"];
delete newJSON["iat"];
delete oldJSON["exp"];
delete oldJSON["iat"];
delete newJSON["exp"]
delete newJSON["iat"]
delete oldJSON["exp"]
delete oldJSON["iat"]
const different = JSON.stringify(newJSON) !== JSON.stringify(oldJSON);
if (different)
if(different)
{
console.log("Latest access token from auth0 has changed vs localStorage - so we should store a new one.");
}
return (different);
}
catch (e)
catch(e)
{
console.log("Caught in shouldStoreNewToken: " + e);
console.log("Caught in shouldStoreNewToken: " + e)
}
return (true);
@ -162,7 +152,7 @@ export default function App()
if (shouldStoreNewToken(accessToken, lsAccessToken))
{
console.log("Sending accessToken to backend, requesting a sessionUUID...");
const {uuid: newSessionUuid, values} = await qController.manageSession(accessToken, null);
const newSessionUuid = await qController.manageSession(accessToken, null);
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// the request to the backend should send a header to set the cookie, so we don't need to do it ourselves. //
@ -170,7 +160,6 @@ export default function App()
// setCookie(SESSION_UUID_COOKIE_NAME, newSessionUuid, {path: "/"});
localStorage.setItem("accessToken", accessToken);
localStorage.setItem("sessionValues", JSON.stringify(values));
console.log("Got new sessionUUID from backend, and stored new accessToken");
}
else
@ -178,8 +167,18 @@ export default function App()
console.log("Using existing sessionUUID cookie");
}
/*
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// todo#authHeader - this is our quick rollback plan - if we feel the need to stop using the cookie approach. //
// we turn off the shouldStoreNewToken block above, and turn on these 2 lines. //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
localStorage.removeItem("accessToken");
*/
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
qController.setAuthorizationHeaderValue("Bearer " + accessToken);
setLoggedInUser(user);
console.log("Token load complete.");
@ -188,7 +187,7 @@ export default function App()
{
console.log(`Error loading token: ${JSON.stringify(e)}`);
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken");
localStorage.removeItem("accessToken")
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
logout();
return;
@ -200,8 +199,8 @@ export default function App()
// use a random token if anonymous or mock //
/////////////////////////////////////////////
console.log("Generating random token...");
qController.setAuthorizationHeaderValue(Md5.hashStr(`${new Date()}`));
setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
console.log("Token generation complete.");
return;
@ -229,7 +228,6 @@ export default function App()
const {miniSidenav, direction, layout, openConfigurator, sidenavColor} = controller;
const [onMouseEnter, setOnMouseEnter] = useState(false);
const {pathname} = useLocation();
const [queryParams] = useSearchParams();
const [needToLoadRoutes, setNeedToLoadRoutes] = useState(true);
const [sideNavRoutes, setSideNavRoutes] = useState([]);
@ -358,7 +356,7 @@ export default function App()
routeList.push({
name: `${app.label}`,
key: app.name,
route: `${path}/savedView/:id`,
route: `${path}/savedFilter/:id`,
component: <RecordQuery table={table} key={table.name} />,
});
@ -393,13 +391,6 @@ export default function App()
component: <RecordView table={table} />,
});
routeList.push({
name: `${app.label} View`,
key: `${app.name}.view`,
route: `${path}/key`,
component: <RecordViewByUniqueKey table={table} />,
});
routeList.push({
name: `${app.label}`,
key: `${app.name}.edit`,
@ -528,7 +519,7 @@ export default function App()
name: loggedInUser?.name ?? "Anonymous",
key: "username",
noCollapse: true,
icon: <Avatar src={profilePicture} alt="{loggedInUser?.name}" />,
icon: <Avatar src={profilePicture} alt="{user?.name}" />,
};
setProfileRoutes(profileRoutes);
@ -560,7 +551,7 @@ export default function App()
});
}
const pathToLabelMap: { [path: string]: string } = {};
const pathToLabelMap: {[path: string]: string} = {}
for (let i = 0; i < appRoutesList.length; i++)
{
const route = appRoutesList[i];
@ -585,11 +576,11 @@ export default function App()
console.error(e);
if (e instanceof QException)
{
if ((e as QException).status === 401)
if ((e as QException).status === "401")
{
console.log("Exception is a QException with status = 401. Clearing some of localStorage & cookies");
qController.clearAuthenticationMetaDataLocalStorage();
localStorage.removeItem("accessToken");
localStorage.removeItem("accessToken")
removeCookie(SESSION_UUID_COOKIE_NAME, {path: "/"});
//////////////////////////////////////////////////////
@ -666,52 +657,26 @@ export default function App()
const [pageHeader, setPageHeader] = useState("" as string | JSX.Element);
const [accentColor, setAccentColor] = useState("#0062FF");
const [accentColorLight, setAccentColorLight] = useState("#C0D6F7");
const [tableMetaData, setTableMetaData] = useState(null);
const [tableProcesses, setTableProcesses] = useState(null);
const [dotMenuOpen, setDotMenuOpen] = useState(false);
const [keyboardHelpOpen, setKeyboardHelpOpen] = useState(false);
const [helpHelpActive] = useState(queryParams.has("helpHelp"));
const [userId, setUserId] = useState(user?.email);
useEffect(() =>
{
setUserId(user?.email)
}, [user]);
const [googleAnalyticsUtils] = useState(new GoogleAnalyticsUtils());
/*******************************************************************************
**
*******************************************************************************/
function recordAnalytics(model: AnalyticsModel)
{
googleAnalyticsUtils.recordAnalytics(model)
}
return (
appRoutes && (
<QContext.Provider value={{
pageHeader: pageHeader,
accentColor: accentColor,
accentColorLight: accentColorLight,
tableMetaData: tableMetaData,
tableProcesses: tableProcesses,
dotMenuOpen: dotMenuOpen,
keyboardHelpOpen: keyboardHelpOpen,
helpHelpActive: helpHelpActive,
userId: userId,
setPageHeader: (header: string | JSX.Element) => setPageHeader(header),
setAccentColor: (accentColor: string) => setAccentColor(accentColor),
setAccentColorLight: (accentColorLight: string) => setAccentColorLight(accentColorLight),
setTableMetaData: (tableMetaData: QTableMetaData) => setTableMetaData(tableMetaData),
setTableProcesses: (tableProcesses: QProcessMetaData[]) => setTableProcesses(tableProcesses),
setDotMenuOpen: (dotMenuOpent: boolean) => setDotMenuOpen(dotMenuOpent),
setKeyboardHelpOpen: (keyboardHelpOpen: boolean) => setKeyboardHelpOpen(keyboardHelpOpen),
recordAnalytics: recordAnalytics,
pathToLabelMap: pathToLabelMap,
branding: branding
}}>

View File

@ -62,21 +62,16 @@ const useStyles = makeStyles((theme: any) => ({
}
}));
const A_FIRST = -1;
const B_FIRST = 1;
const CommandMenu = ({metaData}: Props) =>
{
const [searchString, setSearchString] = useState("");
const navigate = useNavigate();
const pathParts = location.pathname.replace(/\/+$/, "").split("/");
const {accentColor, tableMetaData, dotMenuOpen, setDotMenuOpen, keyboardHelpOpen, setKeyboardHelpOpen, setTableMetaData, tableProcesses, recordAnalytics} = useContext(QContext);
const {accentColor, tableMetaData, dotMenuOpen, setDotMenuOpen, keyboardHelpOpen, setKeyboardHelpOpen, setTableMetaData, tableProcesses} = useContext(QContext);
const classes = useStyles();
function evaluateKeyPress(e: KeyboardEvent)
function evalueKeyPress(e: KeyboardEvent)
{
///////////////////////////////////////////////////////////////////////////
// if a dot pressed, not from a "text" element, then toggle command menu //
@ -87,7 +82,6 @@ const CommandMenu = ({metaData}: Props) =>
if (e.key === "." && !keyboardHelpOpen)
{
e.preventDefault();
recordAnalytics({category: "globalEvents", action: "dotMenuKeyboardShortcut"});
setDotMenuOpen(true);
}
else if (e.key === "?" && !dotMenuOpen)
@ -113,20 +107,20 @@ const CommandMenu = ({metaData}: Props) =>
const down = (e: KeyboardEvent) =>
{
evaluateKeyPress(e);
};
evalueKeyPress(e);
}
document.addEventListener("keydown", down);
document.addEventListener("keydown", down)
return () =>
{
document.removeEventListener("keydown", down);
};
}, [tableMetaData, dotMenuOpen, keyboardHelpOpen]);
document.removeEventListener("keydown", down)
}
}, [tableMetaData, dotMenuOpen, keyboardHelpOpen])
useEffect(() =>
{
setDotMenuOpen(false);
}, [location.pathname]);
}, [location.pathname])
function goToItem(path: string)
{
@ -168,117 +162,69 @@ const CommandMenu = ({metaData}: Props) =>
return (null);
}
/*******************************************************************************
** sort a section (e.g, tables, apps).
**
** put labels that start-with the search word first.
*******************************************************************************/
function comparator(labelA: string, labelB: string)
{
if (searchString != "")
{
let aStartsWith = labelA.toLowerCase().startsWith(searchString.toLowerCase());
let bStartsWith = labelB.toLowerCase().startsWith(searchString.toLowerCase());
if (aStartsWith && !bStartsWith)
{
return A_FIRST;
}
else if (bStartsWith && !aStartsWith)
{
return B_FIRST;
}
const indexOfSpace = searchString.indexOf(" ");
if (indexOfSpace > 0)
{
aStartsWith = labelA.toLowerCase().startsWith(searchString.substring(0, indexOfSpace).toLowerCase());
bStartsWith = labelB.toLowerCase().startsWith(searchString.substring(0, indexOfSpace).toLowerCase());
if (aStartsWith && !bStartsWith)
{
return A_FIRST;
}
else if (bStartsWith && !aStartsWith)
{
return B_FIRST;
}
}
}
return (labelA.localeCompare(labelB));
}
/*******************************************************************************
**
*******************************************************************************/
function ActionsSection()
{
let tableNames: string[] = [];
let tableNames : string[]= [];
metaData.tables.forEach((value: QTableMetaData, key: string) =>
{
tableNames.push(value.name);
});
tableNames = tableNames.sort((a: string, b: string) =>
})
tableNames = tableNames.sort((a: string, b:string) =>
{
const labelA = metaData.tables.get(a).label ?? "";
const labelB = metaData.tables.get(b).label ?? "";
return comparator(labelA, labelB);
});
return (metaData.tables.get(a).label.localeCompare(metaData.tables.get(b).label));
})
const path = location.pathname;
return tableMetaData && !path.endsWith("/edit") && !path.endsWith("/create") && !path.endsWith("#audit") && !path.endsWith("copy") &&
return tableMetaData && !path.endsWith("/edit") && !path.endsWith("/create") && !path.endsWith("#audit") && ! path.endsWith("copy") &&
(
<Command.Group heading={`${tableMetaData.label} Actions`}>
{
tableMetaData.capabilities.has(Capability.TABLE_INSERT) && tableMetaData.insertPermission &&
<Command.Item onSelect={() => goToItem(`${pathParts.slice(0, -1).join("/")}/create`)} key={`${tableMetaData.label}-new`} value="New"><Icon sx={{color: accentColor}}>add</Icon>New</Command.Item>
<Command.Item onSelect={() => goToItem(`${pathParts.slice(0, -1).join("/")}/create`)} key={`${tableMetaData.label}-new`} value="New"><Icon sx={{color: accentColor}}>add</Icon>New</Command.Item>
}
{
tableMetaData.capabilities.has(Capability.TABLE_INSERT) && tableMetaData.insertPermission &&
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}/copy`)} key={`${tableMetaData.label}-copy`} value="Copy"><Icon sx={{color: accentColor}}>copy</Icon>Copy</Command.Item>
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}/copy`)} key={`${tableMetaData.label}-copy`} value="Copy"><Icon sx={{color: accentColor}}>copy</Icon>Copy</Command.Item>
}
{
tableMetaData.capabilities.has(Capability.TABLE_UPDATE) && tableMetaData.editPermission &&
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}/edit`)} key={`${tableMetaData.label}-edit`} value="Edit"><Icon sx={{color: accentColor}}>edit</Icon>Edit</Command.Item>
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}/edit`)} key={`${tableMetaData.label}-edit`} value="Edit"><Icon sx={{color: accentColor}}>edit</Icon>Edit</Command.Item>
}
{
metaData && metaData.tables.has("audit") &&
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}#audit`)} key={`${tableMetaData.label}-audit`} value="Audit"><Icon sx={{color: accentColor}}>checklist</Icon>Audit</Command.Item>
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}#audit`)} key={`${tableMetaData.label}-audit`} value="Audit"><Icon sx={{color: accentColor}}>checklist</Icon>Audit</Command.Item>
}
{
tableProcesses && tableProcesses.length > 0 &&
(
tableProcesses.map((process) => (
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}/${process.name}`)} key={`${process.name}`} value={`${process.label}`}><Icon sx={{color: accentColor}}>{getIconName(process.iconName, "play_arrow")}</Icon>{process.label}</Command.Item>
))
)
(
tableProcesses.map((process) => (
<Command.Item onSelect={() => goToItem(`${pathParts.join("/")}/${process.name}`)} key={`${process.name}`} value={`${process.label}`}><Icon sx={{color: accentColor}}>{getIconName(process.iconName, "play_arrow")}</Icon>{process.label}</Command.Item>
))
)
}
<Command.Separator />
</Command.Group>
);
}
/*******************************************************************************
**
*******************************************************************************/
function TablesSection()
{
let tableNames: string[] = [];
let tableNames : string[]= [];
metaData.tables.forEach((value: QTableMetaData, key: string) =>
{
tableNames.push(value.name);
});
tableNames = tableNames.sort((a: string, b: string) =>
})
tableNames = tableNames.sort((a: string, b:string) =>
{
const labelA = metaData.tables.get(a).label ?? "";
const labelB = metaData.tables.get(b).label ?? "";
return comparator(labelA, labelB);
});
return (
return (metaData.tables.get(a).label.localeCompare(metaData.tables.get(b).label));
})
return(
<Command.Group heading="Tables">
{
tableNames.map((tableName: string, index: number) =>
@ -293,7 +239,6 @@ const CommandMenu = ({metaData}: Props) =>
);
}
/*******************************************************************************
**
*******************************************************************************/
@ -303,16 +248,14 @@ const CommandMenu = ({metaData}: Props) =>
metaData.apps.forEach((value: QAppMetaData, key: string) =>
{
appNames.push(value.name);
});
})
appNames = appNames.sort((a: string, b: string) =>
appNames = appNames.sort((a: string, b:string) =>
{
const labelA = getFullAppLabel(metaData.appTree, a, 1, "") ?? "";
const labelB = getFullAppLabel(metaData.appTree, b, 1, "") ?? "";
return comparator(labelA, labelB);
});
return (getFullAppLabel(metaData.appTree, a, 1, "").localeCompare(getFullAppLabel(metaData.appTree, b, 1, "")));
})
return (
return(
<Command.Group heading="Apps">
{
appNames.map((appName: string, index: number) =>
@ -327,37 +270,31 @@ const CommandMenu = ({metaData}: Props) =>
);
}
/*******************************************************************************
**
*******************************************************************************/
function RecentlyViewedSection()
{
const history = HistoryUtils.get();
const options = [] as any;
history.entries.reverse().forEach((entry, index) =>
options.push({label: `${entry.label} index`, id: index, key: index, path: entry.path, iconName: entry.iconName})
);
)
let appNames: string[] = [];
metaData.apps.forEach((value: QAppMetaData, key: string) =>
{
appNames.push(value.name);
});
})
appNames = appNames.sort((a: string, b: string) =>
appNames = appNames.sort((a: string, b:string) =>
{
const labelA = metaData.apps.get(a).label ?? "";
const labelB = metaData.apps.get(b).label ?? "";
return comparator(labelA, labelB);
});
return (metaData.apps.get(a).label.localeCompare(metaData.apps.get(b).label));
})
const entryMap = new Map<string, boolean>();
return (
return(
<Command.Group heading="Recently Viewed Records">
{
history.entries.reverse().map((entry: QHistoryEntry, index: number) =>
!entryMap.has(entry.label) && entryMap.set(entry.label, true) && (
! entryMap.has(entry.label) && entryMap.set(entry.label, true) && (
<Command.Item onSelect={() => goToItem(`${entry.path}`)} key={`${entry.label}-${index}`} value={entry.label}><Icon sx={{color: accentColor}}>{entry.iconName}</Icon>{entry.label}</Command.Item>
)
)
@ -366,101 +303,29 @@ const CommandMenu = ({metaData}: Props) =>
);
}
const containerElement = useRef(null);
const containerElement = useRef(null)
/*******************************************************************************
**
*******************************************************************************/
function closeKeyboardHelp()
{
setKeyboardHelpOpen(false);
}
/*******************************************************************************
**
*******************************************************************************/
function closeDotMenu()
{
setDotMenuOpen(false);
}
/*******************************************************************************
** filter function for cmd-k library
**
*******************************************************************************/
function doFilter(value: string, search: string)
{
setSearchString(search);
/////////////////////
// split on spaces //
/////////////////////
const searchParts = search.toLowerCase().split(" ");
if (searchParts.length == 1)
{
//////////////////////////////////////////////
// if only 1 word, just do an includes test //
//////////////////////////////////////////////
return (value.toLowerCase().includes(search.toLowerCase()) ? 1 : 0);
}
else
{
////////////////////////////////////////
// else split the value on spaces too //
////////////////////////////////////////
const valueParts = value.toLowerCase().split(" ");
if (searchParts.length > valueParts.length)
{
//////////////////////////////////////////////////////////////////////////////////
// if there are more words in the search than in the value, then it can't match //
// e.g. "order c" can't ever match, say "order" //
//////////////////////////////////////////////////////////////////////////////////
return (0);
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
// iterate over the search parts - if any don't match the corresponding value parts, then it's a non-match //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////
let valueIndex = 0;
for (let i = 0; i < searchParts.length; i++)
{
let foundMatch = false;
for (; valueIndex < valueParts.length; valueIndex++)
{
if (valueParts[valueIndex].includes(searchParts[i]))
{
foundMatch = true;
break;
}
}
if (!foundMatch)
{
return (0);
}
}
/////////////////////////////////
// if no failure, return a hit //
/////////////////////////////////
return (1);
}
}
return (
<React.Fragment>
<Box ref={containerElement} className="raycast" sx={{position: "relative", zIndex: 10_000}}>
{
<Dialog open={dotMenuOpen} onClose={closeDotMenu}>
<Command.Dialog open={dotMenuOpen} onOpenChange={setDotMenuOpen} container={containerElement.current} filter={(value, search) => doFilter(value, search)}>
<Command.Dialog open={dotMenuOpen} onOpenChange={setDotMenuOpen} container={containerElement.current} label="Test Global Command Menu">
<Box sx={{display: "flex"}}>
<Command.Input placeholder="Search for Tables, Actions, or Recently Viewed Items..." />
<Command.Input placeholder="Search for Tables, Actions, or Recently Viewed Items..."/>
<Button onClick={closeDotMenu}><Icon>close</Icon></Button>
</Box>
<Command.Loading />
<Command.Loading />
<Command.Separator />
<Command.List>
<Command.Empty>No results found.</Command.Empty>
@ -489,7 +354,8 @@ const CommandMenu = ({metaData}: Props) =>
<Grid container columnSpacing={5} rowSpacing={1}>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>n</span>Create a New Record</Grid>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>r</span>Refresh the Query</Grid>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>f</span>Open the Filter Builder (Advanced mode only)</Grid>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>c</span>Open the Columns Panel</Grid>
<Grid item xs={6} className={classes.item}><span className={classes.keyboardKey}>f</span>Open the Filter Panel</Grid>
</Grid>
<Typography variant="h6" pt={3}>Record View</Typography>
@ -508,6 +374,6 @@ const CommandMenu = ({metaData}: Props) =>
</Dialog>
}
</React.Fragment>
);
};
)
}
export default CommandMenu;

View File

@ -19,10 +19,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QAppMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QAppMetaData";
import {QBrandingMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QBrandingMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {AnalyticsModel} from "qqq/utils/GoogleAnalyticsUtils";
import {createContext} from "react";
interface QContext
@ -31,10 +32,7 @@ interface QContext
setPageHeader?: (header: string | JSX.Element) => void;
accentColor: string;
setAccentColor?: (color: string) => void;
accentColorLight: string;
setAccentColorLight?: (color: string) => void;
setAccentColor?: (header: string) => void;
dotMenuOpen: boolean;
setDotMenuOpen?: (dotMenuOpen: boolean) => void;
@ -48,28 +46,19 @@ interface QContext
tableProcesses?: QProcessMetaData[];
setTableProcesses?: (tableProcesses: QProcessMetaData[]) => void;
///////////////////////////////////////////
// function to record an analytics event //
///////////////////////////////////////////
recordAnalytics?: (model: AnalyticsModel) => void;
///////////////////////////////////
// constants - no setters needed //
///////////////////////////////////
pathToLabelMap?: {[path: string]: string};
branding?: QBrandingMetaData;
helpHelpActive?: boolean;
userId?: string;
}
const defaultState = {
pageHeader: "",
accentColor: "#0062FF",
accentColorLight: "#C0D6F7",
dotMenuOpen: false,
keyboardHelpOpen: false,
pathToLabelMap: {},
helpHelpActive: false,
};
const QContext = createContext<QContext>(defaultState);

10
src/main/java/Placeholder.java Executable file
View File

@ -0,0 +1,10 @@
/*******************************************************************************
** Placeholder class, because maven really wants some source under src/main?
*******************************************************************************/
public class Placeholder
{
public void f()
{
}
}

View File

@ -1,153 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. 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.model.metadata;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QAppMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.layout.QSupplementalAppMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
/*******************************************************************************
** app-level meta-data for this module (handled as QSupplementalTableMetaData)
*******************************************************************************/
public class MaterialDashboardAppMetaData extends QSupplementalAppMetaData
{
public static final String TYPE_NAME = "materialDashboard";
private Boolean showAppLabelOnHomeScreen = true;
private Boolean includeTableCountsOnHomeScreen = true;
/*******************************************************************************
**
*******************************************************************************/
public static MaterialDashboardAppMetaData of(QAppMetaData app)
{
return ((MaterialDashboardAppMetaData) CollectionUtils.nonNullMap(app.getSupplementalMetaData()).get(TYPE_NAME));
}
/*******************************************************************************
** either get the supplemental meta dat attached to an app - or create a new one
** and attach it to the app, and return that.
*******************************************************************************/
public static MaterialDashboardAppMetaData ofOrWithNew(QAppMetaData app)
{
MaterialDashboardAppMetaData materialDashboardAppMetaData = of(app);
if(materialDashboardAppMetaData == null)
{
materialDashboardAppMetaData = new MaterialDashboardAppMetaData();
app.withSupplementalMetaData(materialDashboardAppMetaData);
}
return (materialDashboardAppMetaData);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public boolean includeInFullFrontendMetaData()
{
return (true);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public String getType()
{
return TYPE_NAME;
}
/*******************************************************************************
** Getter for showAppLabelOnHomeScreen
*******************************************************************************/
public Boolean getShowAppLabelOnHomeScreen()
{
return (this.showAppLabelOnHomeScreen);
}
/*******************************************************************************
** Setter for showAppLabelOnHomeScreen
*******************************************************************************/
public void setShowAppLabelOnHomeScreen(Boolean showAppLabelOnHomeScreen)
{
this.showAppLabelOnHomeScreen = showAppLabelOnHomeScreen;
}
/*******************************************************************************
** Fluent setter for showAppLabelOnHomeScreen
*******************************************************************************/
public MaterialDashboardAppMetaData withShowAppLabelOnHomeScreen(Boolean showAppLabelOnHomeScreen)
{
this.showAppLabelOnHomeScreen = showAppLabelOnHomeScreen;
return (this);
}
/*******************************************************************************
** Getter for includeTableCountsOnHomeScreen
*******************************************************************************/
public Boolean getIncludeTableCountsOnHomeScreen()
{
return (this.includeTableCountsOnHomeScreen);
}
/*******************************************************************************
** Setter for includeTableCountsOnHomeScreen
*******************************************************************************/
public void setIncludeTableCountsOnHomeScreen(Boolean includeTableCountsOnHomeScreen)
{
this.includeTableCountsOnHomeScreen = includeTableCountsOnHomeScreen;
}
/*******************************************************************************
** Fluent setter for includeTableCountsOnHomeScreen
*******************************************************************************/
public MaterialDashboardAppMetaData withIncludeTableCountsOnHomeScreen(Boolean includeTableCountsOnHomeScreen)
{
this.includeTableCountsOnHomeScreen = includeTableCountsOnHomeScreen;
return (this);
}
}

View File

@ -1,32 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. 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.model.metadata;
/*******************************************************************************
**
*******************************************************************************/
public interface MaterialDashboardIconRoleNames
{
String TOP_RIGHT_INSIDE_CARD = "topRightInsideCard";
String TOP_LEFT_INSIDE_CARD = "topLeftInsideCard";
}

View File

@ -22,29 +22,17 @@
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import com.kingsrook.qqq.backend.core.instances.QInstanceValidator;
import com.kingsrook.qqq.backend.core.model.metadata.QInstance;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QSupplementalTableMetaData;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.backend.core.utils.StringUtils;
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRule;
/*******************************************************************************
** table-level meta-data for this module (handled as QSupplementalTableMetaData)
**
*******************************************************************************/
public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
{
public static final String TYPE = "materialDashboard";
private List<List<String>> gotoFieldNames;
private List<String> defaultQuickFilterFieldNames;
private List<FieldRule> fieldRules;
/*******************************************************************************
@ -64,25 +52,10 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
@Override
public String getType()
{
return (TYPE);
return ("materialDashboard");
}
/*******************************************************************************
**
*******************************************************************************/
public static MaterialDashboardTableMetaData ofOrWithNew(QTableMetaData table)
{
MaterialDashboardTableMetaData supplementalMetaData = (MaterialDashboardTableMetaData) table.getSupplementalMetaData(TYPE);
if(supplementalMetaData == null)
{
supplementalMetaData = new MaterialDashboardTableMetaData();
table.withSupplementalMetaData(supplementalMetaData);
}
return (supplementalMetaData);
}
/*******************************************************************************
** Getter for gotoFieldNames
@ -113,155 +86,4 @@ public class MaterialDashboardTableMetaData extends QSupplementalTableMetaData
return (this);
}
/*******************************************************************************
**
*******************************************************************************/
@Override
public void validate(QInstance qInstance, QTableMetaData tableMetaData, QInstanceValidator qInstanceValidator)
{
super.validate(qInstance, tableMetaData, qInstanceValidator);
String prefix = "MaterialDashboardTableMetaData supplementalTableMetaData for table [" + tableMetaData.getName() + "] ";
for(List<String> gotoFieldNameSubList : CollectionUtils.nonNullList(gotoFieldNames))
{
qInstanceValidator.assertCondition(!gotoFieldNameSubList.isEmpty(), prefix + "has an empty gotoFieldNames list");
validateListOfFieldNames(tableMetaData, gotoFieldNameSubList, qInstanceValidator, prefix + "gotoFieldNames: ");
}
validateListOfFieldNames(tableMetaData, defaultQuickFilterFieldNames, qInstanceValidator, prefix + "defaultQuickFilterFieldNames: ");
for(FieldRule fieldRule : CollectionUtils.nonNullList(fieldRules))
{
validateFieldRule(qInstance, tableMetaData, qInstanceValidator, fieldRule, prefix);
}
}
/*******************************************************************************
**
*******************************************************************************/
static void validateFieldRule(QInstance qInstance, QTableMetaData tableMetaData, QInstanceValidator qInstanceValidator, FieldRule fieldRule, String prefix)
{
qInstanceValidator.assertCondition(fieldRule.getTrigger() != null, prefix + "has a fieldRule without a trigger");
qInstanceValidator.assertCondition(fieldRule.getAction() != null, prefix + "has a fieldRule without an action");
if(qInstanceValidator.assertCondition(StringUtils.hasContent(fieldRule.getSourceField()), prefix + "has a fieldRule without a sourceField"))
{
qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldRule.getSourceField()), prefix + "has a fieldRule with an unrecognized sourceField: " + fieldRule.getSourceField());
}
if(StringUtils.hasContent(fieldRule.getTargetField()))
{
qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldRule.getTargetField()), prefix + "has a fieldRule with an unrecognized targetField: " + fieldRule.getTargetField());
}
if(StringUtils.hasContent(fieldRule.getTargetWidget()))
{
if(qInstanceValidator.assertCondition(qInstance.getWidget(fieldRule.getTargetWidget()) != null, prefix + "has a widgetRule with an unrecognized targetWidget: " + fieldRule.getTargetWidget()))
{
qInstanceValidator.assertCondition(CollectionUtils.nonNullList(tableMetaData.getSections()).stream().anyMatch(s -> fieldRule.getTargetWidget().equals(s.getWidgetName())),
prefix + "has a widgetRule with a targetWidget which is not used in any sections on the table");
}
}
}
/*******************************************************************************
**
*******************************************************************************/
private void validateListOfFieldNames(QTableMetaData tableMetaData, List<String> fieldNames, QInstanceValidator qInstanceValidator, String prefix)
{
Set<String> usedNames = new HashSet<>();
for(String fieldName : CollectionUtils.nonNullList(fieldNames))
{
if(qInstanceValidator.assertNoException(() -> tableMetaData.getField(fieldName), prefix + " unrecognized field name: " + fieldName))
{
qInstanceValidator.assertCondition(!usedNames.contains(fieldName), prefix + "has a duplicated field name: " + fieldName);
usedNames.add(fieldName);
}
}
}
/*******************************************************************************
** Getter for defaultQuickFilterFieldNames
*******************************************************************************/
public List<String> getDefaultQuickFilterFieldNames()
{
return (this.defaultQuickFilterFieldNames);
}
/*******************************************************************************
** Setter for defaultQuickFilterFieldNames
*******************************************************************************/
public void setDefaultQuickFilterFieldNames(List<String> defaultQuickFilterFieldNames)
{
this.defaultQuickFilterFieldNames = defaultQuickFilterFieldNames;
}
/*******************************************************************************
** Fluent setter for defaultQuickFilterFieldNames
*******************************************************************************/
public MaterialDashboardTableMetaData withDefaultQuickFilterFieldNames(List<String> defaultQuickFilterFieldNames)
{
this.defaultQuickFilterFieldNames = defaultQuickFilterFieldNames;
return (this);
}
/*******************************************************************************
** Getter for fieldRules
*******************************************************************************/
public List<FieldRule> getFieldRules()
{
return (this.fieldRules);
}
/*******************************************************************************
** Setter for fieldRules
*******************************************************************************/
public void setFieldRules(List<FieldRule> fieldRules)
{
this.fieldRules = fieldRules;
}
/*******************************************************************************
** Fluent setter for fieldRules
*******************************************************************************/
public MaterialDashboardTableMetaData withFieldRules(List<FieldRule> fieldRules)
{
this.fieldRules = fieldRules;
return (this);
}
/*******************************************************************************
** Fluent setter for fieldRules
*******************************************************************************/
public MaterialDashboardTableMetaData withFieldRule(FieldRule fieldRule)
{
if(this.fieldRules == null)
{
this.fieldRules = new ArrayList<>();
}
this.fieldRules.add(fieldRule);
return (this);
}
}

View File

@ -1,198 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules;
import java.io.Serializable;
/*******************************************************************************
** definition of rules for how UI fields should behave.
**
** e.g., one field being changed causing different things to be needed in another
** field.
*******************************************************************************/
public class FieldRule implements Serializable
{
private FieldRuleTrigger trigger;
private String sourceField;
private FieldRuleAction action;
private String targetField;
private String targetWidget;
/*******************************************************************************
** Getter for trigger
*******************************************************************************/
public FieldRuleTrigger getTrigger()
{
return (this.trigger);
}
/*******************************************************************************
** Setter for trigger
*******************************************************************************/
public void setTrigger(FieldRuleTrigger trigger)
{
this.trigger = trigger;
}
/*******************************************************************************
** Fluent setter for trigger
*******************************************************************************/
public FieldRule withTrigger(FieldRuleTrigger trigger)
{
this.trigger = trigger;
return (this);
}
/*******************************************************************************
** Getter for sourceField
*******************************************************************************/
public String getSourceField()
{
return (this.sourceField);
}
/*******************************************************************************
** Setter for sourceField
*******************************************************************************/
public void setSourceField(String sourceField)
{
this.sourceField = sourceField;
}
/*******************************************************************************
** Fluent setter for sourceField
*******************************************************************************/
public FieldRule withSourceField(String sourceField)
{
this.sourceField = sourceField;
return (this);
}
/*******************************************************************************
** Getter for action
*******************************************************************************/
public FieldRuleAction getAction()
{
return (this.action);
}
/*******************************************************************************
** Setter for action
*******************************************************************************/
public void setAction(FieldRuleAction action)
{
this.action = action;
}
/*******************************************************************************
** Fluent setter for action
*******************************************************************************/
public FieldRule withAction(FieldRuleAction action)
{
this.action = action;
return (this);
}
/*******************************************************************************
** Getter for targetField
*******************************************************************************/
public String getTargetField()
{
return (this.targetField);
}
/*******************************************************************************
** Setter for targetField
*******************************************************************************/
public void setTargetField(String targetField)
{
this.targetField = targetField;
}
/*******************************************************************************
** Fluent setter for targetField
*******************************************************************************/
public FieldRule withTargetField(String targetField)
{
this.targetField = targetField;
return (this);
}
/*******************************************************************************
** Getter for targetWidget
*******************************************************************************/
public String getTargetWidget()
{
return (this.targetWidget);
}
/*******************************************************************************
** Setter for targetWidget
*******************************************************************************/
public void setTargetWidget(String targetWidget)
{
this.targetWidget = targetWidget;
}
/*******************************************************************************
** Fluent setter for targetWidget
*******************************************************************************/
public FieldRule withTargetWidget(String targetWidget)
{
this.targetWidget = targetWidget;
return (this);
}
}

View File

@ -1,32 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules;
/*******************************************************************************
** possible actions associated with field rules
*******************************************************************************/
public enum FieldRuleAction
{
CLEAR_TARGET_FIELD,
RELOAD_WIDGET
}

View File

@ -1,31 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules;
/*******************************************************************************
** possible triggers associated with field rules
*******************************************************************************/
public enum FieldRuleTrigger
{
ON_CHANGE
}

View File

@ -1,59 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
package com.kingsrook.qqq.frontend.materialdashboard.savedreports;
import java.util.List;
import com.kingsrook.qqq.backend.core.model.metadata.tables.QTableMetaData;
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.MaterialDashboardTableMetaData;
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRule;
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRuleAction;
import com.kingsrook.qqq.frontend.materialdashboard.model.metadata.fieldrules.FieldRuleTrigger;
/*******************************************************************************
** Add frontend material dashboard enhacements to saved report table
*******************************************************************************/
public class SavedReportTableFrontendMaterialDashboardEnricher
{
/*******************************************************************************
**
*******************************************************************************/
public static void enrich(QTableMetaData tableMetaData)
{
MaterialDashboardTableMetaData materialDashboardTableMetaData = MaterialDashboardTableMetaData.ofOrWithNew(tableMetaData);
/////////////////////////////////////////////////////////////////////////
// make changes to the tableName field clear the value in these fields //
/////////////////////////////////////////////////////////////////////////
for(String targetField : List.of("queryFilterJson", "columnsJson", "pivotTableJson"))
{
materialDashboardTableMetaData.withFieldRule(new FieldRule()
.withSourceField("tableName")
.withTrigger(FieldRuleTrigger.ON_CHANGE)
.withAction(FieldRuleAction.CLEAR_TARGET_FIELD)
.withTargetField(targetField));
}
}
}

View File

@ -149,7 +149,7 @@ interface Types {
}
const baseProperties = {
fontFamily: '"SF Pro Display", "Roboto", "Helvetica", "Arial", sans-serif',
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
fontWeightLighter: 100,
fontWeightLight: 300,
fontWeightRegular: 400,

View File

@ -78,19 +78,6 @@ interface Types
light: string;
main: string;
focus: string;
}
blueGray:
| {
main: string;
}
gray:
| {
main: string;
focus: string;
}
grayLines:
| {
main: string;
}
| any;
primary: ColorsTypes | any;
@ -187,19 +174,6 @@ const colors: Types = {
focus: "#ffffff",
},
blueGray: {
main: "#546E7A"
},
gray: {
main: "#757575",
focus: "#757575",
},
grayLines: {
main: "#D6D6D6"
},
black: {
light: "#000000",
main: "#000000",
@ -242,7 +216,7 @@ const colors: Types = {
},
dark: {
main: "#212121",
main: "#344767",
focus: "#2c3c58",
},

View File

@ -149,7 +149,7 @@ interface Types {
}
const baseProperties = {
fontFamily: '"SF Pro Display", "Roboto", "Helvetica", "Arial", sans-serif',
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
fontWeightLighter: 100,
fontWeightLight: 300,
fontWeightRegular: 400,
@ -199,10 +199,9 @@ const typography: Types = {
},
h3: {
fontSize: "1.75rem",
fontSize: pxToRem(30),
lineHeight: 1.375,
...baseHeadingProperties,
fontWeight: 600
},
h4: {
@ -218,10 +217,9 @@ const typography: Types = {
},
h6: {
fontSize: "1.125rem",
fontSize: pxToRem(16),
lineHeight: 1.625,
...baseHeadingProperties,
fontWeight: 500
},
subtitle1: {

View File

@ -31,7 +31,7 @@ type Types = any;
const card: Types = {
defaultProps: {
elevation: 0
elevation: 3
},
styleOverrides: {
root: {
@ -42,7 +42,7 @@ const card: Types = {
wordWrap: "break-word",
backgroundColor: white.main,
backgroundClip: "border-box",
border: `${borderWidth[1]} solid ${colors.grayLines.main}`,
border: `${borderWidth[0]} solid ${rgba(black.main, 0.125)}`,
borderRadius: borderRadius.xl,
overflow: "visible",
},

View File

@ -1,75 +1,68 @@
/**
=========================================================
* Material Dashboard 2 PRO React TS - v1.0.0
=========================================================
=========================================================
* Material Dashboard 2 PRO React TS - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/material-dashboard-2-pro-react-ts
* Copyright 2022 Creative Tim (https://www.creative-tim.com)
* Product Page: https://www.creative-tim.com/product/material-dashboard-2-pro-react-ts
* Copyright 2022 Creative Tim (https://www.creative-tim.com)
Coded by www.creative-tim.com
Coded by www.creative-tim.com
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
// Material Dashboard 2 PRO React TS Base Styles
import colors from "qqq/assets/theme/base/colors";
import borders from "qqq/assets/theme/base/borders";
import boxShadows from "qqq/assets/theme/base/boxShadows";
// Material Dashboard 2 PRO React TS Helper Functions
import pxToRem from "qqq/assets/theme/functions/pxToRem";
const {grey, white} = colors;
const { grey, white } = colors;
const { borderRadius } = borders;
const { tabsBoxShadow } = boxShadows;
// types
type Types = any;
const tabs: Types = {
styleOverrides: {
root: {
position: "relative",
borderRadius: 0,
borderBottom: "1px solid",
borderBottomColor: grey[400],
minHeight: "unset",
padding: "0",
margin: "0",
"& button": {
fontWeight: 500
}
},
styleOverrides: {
root: {
position: "relative",
backgroundColor: grey[100],
borderRadius: borderRadius.xl,
minHeight: "unset",
padding: pxToRem(4),
},
scroller: {
marginLeft: "0.5rem"
},
flexContainer: {
height: "100%",
position: "relative",
zIndex: 10,
},
flexContainer: {
height: "100%",
position: "relative",
width: "fit-content",
zIndex: 10,
},
fixed: {
overflow: "unset !important",
overflowX: "unset !important",
},
fixed: {
overflow: "unset !important",
overflowX: "unset !important",
vertical: {
"& .MuiTabs-indicator": {
width: "100%",
},
},
vertical: {
"& .MuiTabs-indicator": {
width: "100%",
},
},
indicator: {
height: "100%",
borderRadius: 0,
backgroundColor: white.main,
borderBottom: "2px solid",
borderBottomColor: colors.info.main,
transition: "all 500ms ease",
},
},
indicator: {
height: "100%",
borderRadius: borderRadius.lg,
backgroundColor: white.main,
boxShadow: tabsBoxShadow.indicator,
transition: "all 500ms ease",
},
},
};
export default tabs;

View File

@ -1,17 +1,17 @@
/**
=========================================================
* Material Dashboard 2 PRO React TS - v1.0.0
=========================================================
=========================================================
* Material Dashboard 2 PRO React TS - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/material-dashboard-2-pro-react-ts
* Copyright 2022 Creative Tim (https://www.creative-tim.com)
* Product Page: https://www.creative-tim.com/product/material-dashboard-2-pro-react-ts
* Copyright 2022 Creative Tim (https://www.creative-tim.com)
Coded by www.creative-tim.com
Coded by www.creative-tim.com
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
// Material Dashboard 2 PRO React TS Base Styles
import typography from "qqq/assets/theme/base/typography";
@ -21,50 +21,48 @@ import colors from "qqq/assets/theme/base/colors";
// Material Dashboard 2 PRO React TS Helper Functions
import pxToRem from "qqq/assets/theme/functions/pxToRem";
const {size, fontWeightRegular} = typography;
const {borderRadius} = borders;
const {dark} = colors;
const { size, fontWeightRegular } = typography;
const { borderRadius } = borders;
const { dark } = colors;
// types
type Types = any;
const tab: Types = {
styleOverrides: {
root: {
display: "flex",
alignItems: "center",
flexDirection: "row",
flex: "1 1 auto",
textAlign: "center",
maxWidth: "unset !important",
minWidth: "unset !important",
minHeight: "unset !important",
fontSize: size.md,
fontWeight: fontWeightRegular,
textTransform: "none",
lineHeight: "inherit",
padding: "0.75rem 0.5rem 0.5rem",
margin: "0 0.5rem",
borderRadius: 0,
border: 0,
color: `${dark.main} !important`,
opacity: "1 !important",
styleOverrides: {
root: {
display: "flex",
alignItems: "center",
flexDirection: "row",
flex: "1 1 auto",
textAlign: "center",
maxWidth: "unset !important",
minWidth: "unset !important",
minHeight: "unset !important",
fontSize: size.md,
fontWeight: fontWeightRegular,
textTransform: "none",
lineHeight: "inherit",
padding: pxToRem(4),
borderRadius: borderRadius.lg,
color: `${dark.main} !important`,
opacity: "1 !important",
"& .material-icons, .material-icons-round": {
marginBottom: "0 !important",
marginRight: pxToRem(6),
},
"& svg": {
marginBottom: "0 !important",
marginRight: pxToRem(6),
},
"& .material-icons, .material-icons-round": {
marginBottom: "0 !important",
marginRight: pxToRem(6),
},
labelIcon: {
paddingTop: pxToRem(4),
"& svg": {
marginBottom: "0 !important",
marginRight: pxToRem(6),
},
},
},
labelIcon: {
paddingTop: pxToRem(4),
},
},
};
export default tab;

View File

@ -24,7 +24,7 @@ import borders from "qqq/assets/theme/base/borders";
// Material Dashboard 2 PRO React TS Helper Functions
import pxToRem from "qqq/assets/theme/functions/pxToRem";
const { black, light, white, dark } = colors;
const { black, light } = colors;
const { size, fontWeightRegular } = typography;
const { borderRadius } = borders;
@ -39,20 +39,19 @@ const tooltip: Types = {
styleOverrides: {
tooltip: {
maxWidth: pxToRem(300),
backgroundColor: white.main,
color: dark.main,
maxWidth: pxToRem(200),
backgroundColor: black.main,
color: light.main,
fontSize: size.sm,
fontWeight: fontWeightRegular,
textAlign: "left",
textAlign: "center",
borderRadius: borderRadius.md,
opacity: 0.7,
padding: "1rem",
boxShadow: "0px 0px 12px rgba(128, 128, 128, 0.40)"
padding: `${pxToRem(5)} ${pxToRem(8)} ${pxToRem(4)}`,
},
arrow: {
color: white.main,
color: black.main,
},
},
};

View File

@ -34,10 +34,10 @@ import ToggleButton from "@mui/material/ToggleButton";
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import React, {JSXElementConstructor, useContext, useEffect, useState} from "react";
import QContext from "QContext";
import Client from "qqq/utils/qqq/Client";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useContext, useEffect, useState} from "react";
interface Props
{
@ -58,19 +58,19 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
const [limit, setLimit] = useState(1000);
const [statusString, setStatusString] = useState("Loading audits...");
const [auditsByDate, setAuditsByDate] = useState([] as QRecord[][]);
const [auditDetailMap, setAuditDetailMap] = useState(null as Map<number, JSX.Element[]>);
const [fieldChangeMap, setFieldChangeMap] = useState(null as Map<number, JSX.Element>);
const [auditDetailMap, setAuditDetailMap] = useState(null as Map<number, JSX.Element[]>)
const [fieldChangeMap, setFieldChangeMap] = useState(null as Map<number, JSX.Element>)
const [sortDirection, setSortDirection] = useState(localStorage.getItem("audit.sortDirection") === "true");
const {accentColor} = useContext(QContext);
function wrapValue(value: any): JSX.Element
{
return <span style={{fontWeight: "500", color: " rgb(123, 128, 154)"}}>{value}</span>;
return <span style={{fontWeight: "500", color: " rgb(123, 128, 154)"}}>{value}</span>
}
function wasValue(value: any): JSX.Element
{
return <span style={{fontWeight: "100", color: " rgb(123, 128, 154)"}}>{value}</span>;
return <span style={{fontWeight: "100", color: " rgb(123, 128, 154)"}}>{value}</span>
}
function getAuditDetailFieldChangeRow(qRecord: QRecord): JSX.Element | null
@ -79,14 +79,10 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
const fieldName = qRecord.values.get("auditDetail.fieldName");
const oldValue = qRecord.values.get("auditDetail.oldValue");
const newValue = qRecord.values.get("auditDetail.newValue");
if (fieldName && (oldValue !== null || newValue !== null))
if(fieldName && (oldValue !== null || newValue !== null))
{
const fieldLabel = tableMetaData?.fields?.get(fieldName)?.label ?? fieldName;
return (<tr>
<td>{fieldLabel}</td>
<td>{oldValue}</td>
<td>{newValue}</td>
</tr>);
const fieldLabel = tableMetaData?.fields?.get(fieldName)?.label ?? fieldName
return (<tr><td>{fieldLabel}</td><td>{oldValue}</td><td>{newValue}</td></tr>)
}
return (null);
}
@ -97,22 +93,22 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
const fieldName = qRecord.values.get("auditDetail.fieldName");
const oldValue = qRecord.values.get("auditDetail.oldValue");
const newValue = qRecord.values.get("auditDetail.newValue");
if (fieldName && (oldValue !== null || newValue !== null))
if(fieldName && (oldValue !== null || newValue !== null))
{
const fieldLabel = tableMetaData?.fields?.get(fieldName)?.label ?? fieldName;
if (oldValue !== undefined && newValue !== undefined)
if(oldValue !== undefined && newValue !== undefined)
{
return (<>{fieldLabel}: Changed from {(oldValue)} to <b>{(newValue)}</b></>);
}
else if (newValue !== undefined)
else if(newValue !== undefined)
{
return (<>{fieldLabel}: Set to <b>{(newValue)}</b></>);
}
else if (oldValue !== undefined)
else if(oldValue !== undefined)
{
return (<>{fieldLabel}: Removed value {(oldValue)}</>);
}
else if (message)
else if(message)
{
return (<>{message}</>);
}
@ -181,7 +177,7 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
}
*/
}
else if (message)
else if(message)
{
return (<>{message}</>);
}
@ -202,22 +198,22 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
new QFilterOrderBy("timestamp", sortDirection),
new QFilterOrderBy("id", sortDirection),
new QFilterOrderBy("auditDetail.id", true)
], null, "AND", 0, limit);
], "AND", 0, limit);
///////////////////////////////
// fetch audits in try-catch //
///////////////////////////////
let audits = [] as QRecord[];
let audits = [] as QRecord[]
try
{
audits = await qController.query("audit", filter, [new QueryJoin("auditDetail", true, "LEFT")]);
setAudits(audits);
}
catch (e)
catch(e)
{
if (e instanceof QException)
{
if ((e as QException).status === 403)
if ((e as QException).status === "403")
{
setStatusString("You do not have permission to view audits");
return;
@ -237,33 +233,33 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
//////////////////////////////////////////////////////////////////////////////////////////////////////////
// group the audits by auditId (e.g., this is a list that joined audit & auditDetail, so un-flatten it) //
//////////////////////////////////////////////////////////////////////////////////////////////////////////
const unflattenedAudits: QRecord[] = [];
const unflattenedAudits: QRecord[] = []
const detailMap: Map<number, JSX.Element[]> = new Map();
const fieldChangeRowsMap: Map<number, JSX.Element[]> = new Map();
for (let i = 0; i < audits.length; i++)
{
let id = audits[i].values.get("id");
if (i == 0 || unflattenedAudits[unflattenedAudits.length - 1].values.get("id") != id)
if(i == 0 || unflattenedAudits[unflattenedAudits.length-1].values.get("id") != id)
{
unflattenedAudits.push(audits[i]);
}
let auditDetail = getAuditDetailElement(audits[i]);
if (auditDetail)
if(auditDetail)
{
if (!detailMap.has(id))
if(!detailMap.has(id))
{
detailMap.set(id, []);
}
detailMap.get(id).push(auditDetail);
detailMap.get(id).push(auditDetail)
}
// table version, probably not to commit
let fieldChangeRow = getAuditDetailFieldChangeRow(audits[i]);
if (auditDetail)
if(auditDetail)
{
if (!fieldChangeRowsMap.has(id))
if(!fieldChangeRowsMap.has(id))
{
fieldChangeRowsMap.set(id, []);
}
@ -277,7 +273,7 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
for (let i = 0; i < unflattenedAudits.length; i++)
{
let id = unflattenedAudits[i].values.get("id");
if (fieldChangeRowsMap.has(id) && fieldChangeRowsMap.get(id).length > 0)
if(fieldChangeRowsMap.has(id) && fieldChangeRowsMap.get(id).length > 0)
{
const fieldChangeTable = (
<table style={{fontSize: "0.875rem"}} className="auditDetailTable" cellSpacing="0">
@ -292,11 +288,11 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
{fieldChangeRowsMap.get(id).map((row, key) => <React.Fragment key={key}>{row}</React.Fragment>)}
</tbody>
</table>
);
)
fieldChangeMap.set(id, fieldChangeTable);
}
}
setFieldChangeMap(fieldChangeMap);
setFieldChangeMap(fieldChangeMap)
//////////////////////////////
// group the audits by date //
@ -354,7 +350,7 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
const changeSortDirection = () =>
{
setAudits([]);
const newSortDirection = !sortDirection;
const newSortDirection = !sortDirection
setSortDirection(newSortDirection);
localStorage.setItem("audit.sortDirection", String(newSortDirection));
};
@ -406,16 +402,14 @@ function AuditBody({tableMetaData, recordId, record}: Props): JSX.Element
return (
<Box key={audit0.values.get("id")} className="auditGroupBlock">
<Box position="sticky" top="0" zIndex={3}>
<Box display="flex" flexDirection="row" justifyContent="center" fontSize={14} position={"relative"} top={"-1px"} pb={"6px"} sx={{backgroundImage: "linear-gradient(to bottom, rgba(255,255,255,1), rgba(255,255,255,1) 80%, rgba(255,255,255,0))"}}>
<Box borderTop={1} mt={1.25} mr={1} width="100%" borderColor="#B0B0B0" />
<Box whiteSpace="nowrap">
{ValueUtils.getFullWeekday(audit0.values.get("timestamp"))} {timestampParts[0]}
{timestampParts[0] == todayFormatted ? " (Today)" : ""}
{timestampParts[0] == yesterdayFormatted ? " (Yesterday)" : ""}
</Box>
<Box borderTop={1} mt={1.25} ml={1} width="100%" borderColor="#B0B0B0" />
<Box display="flex" flexDirection="row" justifyContent="center" fontSize={14}>
<Box borderTop={1} mt={1.25} mr={1} width="100%" borderColor="#B0B0B0" />
<Box whiteSpace="nowrap">
{ValueUtils.getFullWeekday(audit0.values.get("timestamp"))} {timestampParts[0]}
{timestampParts[0] == todayFormatted ? " (Today)" : ""}
{timestampParts[0] == yesterdayFormatted ? " (Yesterday)" : ""}
</Box>
<Box borderTop={1} mt={1.25} ml={1} width="100%" borderColor="#B0B0B0" />
</Box>
{

View File

@ -30,17 +30,14 @@ import MDButton from "qqq/components/legacy/MDButton";
export const standardWidth = "150px";
const standardML = {xs: 1, md: 3};
interface QCreateNewButtonProps
{
tablePath: string;
}
export function QCreateNewButton({tablePath}: QCreateNewButtonProps): JSX.Element
{
return (
<Box display="inline-block" ml={standardML} mr={0} width={standardWidth}>
<Box ml={3} mr={2} width={standardWidth}>
<Link to={`${tablePath}/create`}>
<MDButton variant="gradient" color="info" fullWidth startIcon={<Icon>add</Icon>}>
Create New
@ -57,7 +54,6 @@ interface QSaveButtonProps
onClickHandler?: any,
disabled: boolean
}
QSaveButton.defaultProps = {
label: "Save",
iconName: "save"
@ -66,7 +62,7 @@ QSaveButton.defaultProps = {
export function QSaveButton({label, iconName, onClickHandler, disabled}: QSaveButtonProps): JSX.Element
{
return (
<Box ml={standardML} width={standardWidth}>
<Box ml={3} width={standardWidth}>
<MDButton type="submit" variant="gradient" color="info" size="small" onClick={onClickHandler} fullWidth startIcon={<Icon>{iconName}</Icon>} disabled={disabled}>
{label}
</MDButton>
@ -76,19 +72,14 @@ export function QSaveButton({label, iconName, onClickHandler, disabled}: QSaveBu
interface QDeleteButtonProps
{
onClickHandler: any;
disabled?: boolean;
onClickHandler: any
}
QDeleteButton.defaultProps = {
disabled: false
};
export function QDeleteButton({onClickHandler, disabled}: QDeleteButtonProps): JSX.Element
export function QDeleteButton({onClickHandler}: QDeleteButtonProps): JSX.Element
{
return (
<Box ml={standardML} width={standardWidth}>
<MDButton variant="gradient" color="primary" size="small" onClick={onClickHandler} fullWidth startIcon={<Icon>delete</Icon>} disabled={disabled}>
<Box ml={3} width={standardWidth}>
<MDButton variant="gradient" color="primary" size="small" onClick={onClickHandler} fullWidth startIcon={<Icon>delete</Icon>}>
Delete
</MDButton>
</Box>
@ -98,7 +89,7 @@ export function QDeleteButton({onClickHandler, disabled}: QDeleteButtonProps): J
export function QEditButton(): JSX.Element
{
return (
<Box ml={standardML} width={standardWidth}>
<Box ml={3} width={standardWidth}>
<Link to="edit">
<MDButton variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>edit</Icon>}>
Edit
@ -132,12 +123,30 @@ export function QActionsMenuButton({isOpen, onClickHandler}: QActionsMenuButtonP
);
}
export function QSavedFiltersMenuButton({isOpen, onClickHandler}: QActionsMenuButtonProps): JSX.Element
{
return (
<Box width={standardWidth} ml={1}>
<MDButton
variant={isOpen ? "contained" : "outlined"}
color="dark"
onClick={onClickHandler}
fullWidth
startIcon={<Icon>filter_alt</Icon>}
>
saved&nbsp;filters&nbsp;
<Icon>keyboard_arrow_down</Icon>
</MDButton>
</Box>
);
}
interface QCancelButtonProps
{
onClickHandler: any;
disabled: boolean;
label?: string;
iconName?: string;
iconName?: string
}
export function QCancelButton({
@ -145,7 +154,7 @@ export function QCancelButton({
}: QCancelButtonProps): JSX.Element
{
return (
<Box ml={standardML} width={standardWidth}>
<Box ml="auto" width={standardWidth}>
<MDButton type="button" variant="outlined" color="dark" size="small" fullWidth startIcon={<Icon>{iconName}</Icon>} onClick={onClickHandler} disabled={disabled}>
{label}
</MDButton>
@ -160,15 +169,15 @@ QCancelButton.defaultProps = {
interface QSubmitButtonProps
{
label?: string;
iconName?: string;
disabled: boolean;
label?: string
iconName?: string
disabled: boolean
}
export function QSubmitButton({label, iconName, disabled}: QSubmitButtonProps): JSX.Element
{
return (
<Box ml={standardML} width={standardWidth}>
<Box ml={3} width={standardWidth}>
<MDButton type="submit" variant="gradient" color="dark" size="small" fullWidth startIcon={<Icon>{iconName}</Icon>} disabled={disabled}>
{label}
</MDButton>
@ -180,24 +189,3 @@ QSubmitButton.defaultProps = {
label: "Submit",
iconName: "check",
};
interface QAlternateButtonProps
{
label: string,
iconName?: string,
disabled: boolean,
onClick?: () => void
}
export function QAlternateButton({label, iconName, disabled, onClick}: QAlternateButtonProps): JSX.Element
{
return (
<Box ml={standardML} width={standardWidth}>
<MDButton type="button" variant="gradient" color="secondary" size="small" fullWidth startIcon={iconName && <Icon>{iconName}</Icon>} onClick={onClick} disabled={disabled}>
{label}
</MDButton>
</Box>
);
}
QAlternateButton.defaultProps = {};

View File

@ -19,18 +19,17 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Box, InputLabel} from "@mui/material";
import {InputLabel} from "@mui/material";
import Stack from "@mui/material/Stack";
import {styled} from "@mui/material/styles";
import Switch from "@mui/material/Switch";
import Typography from "@mui/material/Typography";
import {useFormikContext} from "formik";
import React, {SyntheticEvent} from "react";
import colors from "qqq/assets/theme/base/colors";
const AntSwitch = styled(Switch)(({theme}) => ({
width: 32,
height: 20,
width: 28,
height: 16,
padding: 0,
display: "flex",
"&:active": {
@ -54,19 +53,15 @@ const AntSwitch = styled(Switch)(({theme}) => ({
},
"& .MuiSwitch-thumb": {
boxShadow: "0 2px 4px 0 rgb(0 35 11 / 20%)",
width: 16,
height: 16,
borderRadius: 8,
width: 12,
height: 12,
borderRadius: 6,
transition: theme.transitions.create([ "width" ], {
duration: 200,
}),
},
"&.nullSwitch .MuiSwitch-thumb": {
width: 28,
},
"& .MuiSwitch-track": {
height: 20,
borderRadius: 20 / 2,
borderRadius: 16 / 2,
opacity: 1,
backgroundColor:
theme.palette.mode === "dark" ? "rgba(255,255,255,.35)" : "rgba(0,0,0,.25)",
@ -80,12 +75,10 @@ interface Props
label: string;
value: boolean;
isDisabled: boolean;
onChangeCallback?: (newValue: any) => void;
}
function BooleanFieldSwitch({name, label, value, isDisabled, onChangeCallback}: Props) : JSX.Element
function BooleanFieldSwitch({name, label, value, isDisabled}: Props) : JSX.Element
{
const {setFieldValue} = useFormikContext();
@ -94,10 +87,6 @@ function BooleanFieldSwitch({name, label, value, isDisabled, onChangeCallback}:
if(!isDisabled)
{
setFieldValue(name, newValue);
if(onChangeCallback)
{
onChangeCallback(newValue);
}
event.stopPropagation();
}
}
@ -105,35 +94,29 @@ function BooleanFieldSwitch({name, label, value, isDisabled, onChangeCallback}:
const toggleSwitch = () =>
{
setFieldValue(name, !value);
if(onChangeCallback)
{
onChangeCallback(!value);
}
}
const classNullSwitch = (value === null || value == undefined || `${value}` == "") ? "nullSwitch" : "";
return (
<Box bgcolor={isDisabled ? colors.grey[200] : ""}>
<>
<InputLabel shrink={true}>{label}</InputLabel>
<Stack direction="row" spacing={1} alignItems="center" height="37px">
<Stack direction="row" spacing={1} alignItems="center">
<Typography
fontSize="1rem"
fontSize="0.875rem"
color={value === false ? "auto" : "#bfbfbf" }
onClick={(e) => setSwitch(e, false)}
sx={{cursor: value === false || isDisabled ? "inherit" : "pointer"}}>
No
</Typography>
<AntSwitch className={classNullSwitch} name={name} checked={value} onClick={toggleSwitch} disabled={isDisabled} />
<AntSwitch name={name} checked={value} onClick={toggleSwitch} disabled={isDisabled} />
<Typography
fontSize="1rem"
fontSize="0.875rem"
color={value === true ? "auto" : "#bfbfbf"}
onClick={(e) => setSwitch(e, true)}
sx={{cursor: value === true || isDisabled ? "inherit" : "pointer"}}>
Yes
</Typography>
</Stack>
</Box>
</>
);
}

View File

@ -19,16 +19,20 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {colors, Icon, InputLabel} from "@mui/material";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Grid from "@mui/material/Grid";
import Tooltip from "@mui/material/Tooltip";
import {useFormikContext} from "formik";
import React, {useState} from "react";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import FileInputField from "qqq/components/forms/FileInputField";
import MDTypography from "qqq/components/legacy/MDTypography";
import HelpContent from "qqq/components/misc/HelpContent";
import React from "react";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
interface Props
{
@ -37,114 +41,150 @@ interface Props
bulkEditMode?: boolean;
bulkEditSwitchChangeHandler?: any;
record?: QRecord;
helpRoles?: string[];
helpContentKeyPrefix?: string;
}
function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHandler, record, helpRoles, helpContentKeyPrefix}: Props): JSX.Element
function QDynamicForm(props: Props): JSX.Element
{
const {formFields, values, errors, touched} = formData;
const {
formData, formLabel, bulkEditMode, bulkEditSwitchChangeHandler,
} = props;
const {
formFields, values, errors, touched,
} = formData;
const formikProps = useFormikContext();
const [fileName, setFileName] = useState(null as string);
const fileChanged = (event: React.FormEvent<HTMLInputElement>, field: any) =>
{
setFileName(null);
if (event.currentTarget.files && event.currentTarget.files[0])
{
setFileName(event.currentTarget.files[0].name);
}
formikProps.setFieldValue(field.name, event.currentTarget.files[0]);
};
const removeFile = (fieldName: string) =>
{
setFileName(null);
formikProps.setFieldValue(fieldName, null);
props.record?.values.delete(fieldName)
props.record?.displayValues.delete(fieldName)
};
const bulkEditSwitchChanged = (name: string, value: boolean) =>
{
bulkEditSwitchChangeHandler(name, value);
};
return (
<Box>
<Box lineHeight={0}>
<MDTypography variant="h5">{formLabel}</MDTypography>
{/* TODO - help text
<MDTypography variant="button" color="text">
Mandatory information
</MDTypography>
*/}
</Box>
<Box mt={1.625}>
<Grid container lg={12} display="flex" spacing={3}>
<Grid container spacing={3}>
{formFields
&& Object.keys(formFields).length > 0
&& Object.keys(formFields).map((fieldName: any) =>
{
const field = formFields[fieldName];
if (field.omitFromQDynamicForm)
{
return null;
}
if (values[fieldName] === undefined)
{
values[fieldName] = "";
}
let formattedHelpContent = <HelpContent helpContents={field?.fieldMetaData?.helpContents} roles={helpRoles} helpContentKey={`${helpContentKeyPrefix ?? ""}field:${fieldName}`} />;
if (formattedHelpContent)
if (field.omitFromQDynamicForm)
{
formattedHelpContent = <Box color="#757575" fontSize="0.875rem" mt="-0.25rem">{formattedHelpContent}</Box>;
return null;
}
const labelElement = <DynamicFormFieldLabel name={field.name} label={field.label} />;
let itemLG = (field?.fieldMetaData?.gridColumns && field?.fieldMetaData?.gridColumns > 0) ? field.fieldMetaData.gridColumns : 6;
let itemXS = 12;
let itemSM = 6;
/////////////
// files!! //
/////////////
if (field.type === "file")
{
const fileUploadAdornment = field.fieldMetaData?.getAdornment(AdornmentType.FILE_UPLOAD);
const width = fileUploadAdornment?.values?.get("width") ?? "half";
if (width == "full")
{
itemSM = 12;
}
const pseudoField = new QFieldMetaData({name: fieldName, type: QFieldType.BLOB});
return (
<Grid item lg={itemLG} xs={itemXS} sm={itemSM} flexDirection="column" key={fieldName}>
{labelElement}
<FileInputField field={field} record={record} errorMessage={errors[fieldName]} />
<Grid item xs={12} sm={6} key={fieldName}>
<Box mb={1.5}>
<InputLabel shrink={true}>{field.label}</InputLabel>
{
props.record && props.record.values.get(fieldName) && <Box fontSize="0.875rem" pb={1}>
Current File:
<Box display="inline-flex" pl={1}>
{ValueUtils.getDisplayValue(pseudoField, props.record, "view")}
<Tooltip placement="bottom" title="Remove current file">
<Icon className="blobIcon" fontSize="small" onClick={(e) => removeFile(fieldName)}>delete</Icon>
</Tooltip>
</Box>
</Box>
}
<Box display="flex" alignItems="center">
<Button variant="outlined" component="label">
<span style={{color: colors.lightBlue[500]}}>Choose file to upload</span>
<input
id={fieldName}
name={fieldName}
type="file"
hidden
onChange={(event: React.FormEvent<HTMLInputElement>) => fileChanged(event, field)}
/>
</Button>
<Box ml={1} fontSize={"1rem"}>
{fileName}
</Box>
</Box>
<Box mt={0.75}>
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
{errors[fieldName] && <span>You must select a file to proceed</span>}
</MDTypography>
</Box>
</Box>
</Grid>
);
}
///////////////////////
// possible values!! //
///////////////////////
else if (field.possibleValueProps)
// possible values!!
if (field.possibleValueProps)
{
const otherValuesMap = field.possibleValueProps.otherValues ?? new Map<string, any>();
Object.keys(values).forEach((key) =>
{
otherValuesMap.set(key, values[key]);
});
})
return (
<Grid item lg={itemLG} xs={itemXS} sm={itemSM} key={fieldName}>
{labelElement}
<Grid item xs={12} sm={6} key={fieldName}>
<DynamicSelect
fieldPossibleValueProps={field.possibleValueProps}
tableName={field.possibleValueProps.tableName}
processName={field.possibleValueProps.processName}
fieldName={fieldName}
isEditable={field.isEditable}
fieldLabel=""
fieldLabel={field.label}
initialValue={values[fieldName]}
initialDisplayValue={field.possibleValueProps.initialDisplayValue}
bulkEditMode={bulkEditMode}
bulkEditSwitchChangeHandler={bulkEditSwitchChanged}
otherValues={otherValuesMap}
useCase="form"
/>
{formattedHelpContent}
</Grid>
);
}
///////////////////////
// everything else!! //
///////////////////////
// todo? inputProps={{ autoComplete: "" }}
// todo? placeholder={password.placeholder}
return (
<Grid item lg={itemLG} xs={itemXS} sm={itemSM} key={fieldName}>
{labelElement}
<Grid item xs={12} sm={6} key={fieldName}>
<QDynamicFormField
id={field.name}
type={field.type}
label=""
label={field.label}
isEditable={field.isEditable}
name={fieldName}
displayFormat={field.displayFormat}
@ -155,7 +195,6 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
success={`${values[fieldName]}` !== "" && !errors[fieldName] && touched[fieldName]}
formFieldObject={field}
/>
{formattedHelpContent}
</Grid>
);
})}
@ -168,25 +207,9 @@ function QDynamicForm({formData, formLabel, bulkEditMode, bulkEditSwitchChangeHa
QDynamicForm.defaultProps = {
formLabel: undefined,
bulkEditMode: false,
helpRoles: ["ALL_SCREENS"],
bulkEditSwitchChangeHandler: () =>
{
},
};
interface DynamicFormFieldLabelProps
{
name: string;
label: string;
}
export function DynamicFormFieldLabel({name, label}: DynamicFormFieldLabelProps): JSX.Element
{
return (<Box fontSize="1rem" fontWeight="500" marginBottom="0.25rem">
<label htmlFor={name}>{label}</label>
</Box>);
}
export default QDynamicForm;

View File

@ -19,19 +19,15 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QPossibleValue} from "@kingsrook/qqq-frontend-core/lib/model/QPossibleValue";
import {Box, InputAdornment, InputLabel} from "@mui/material";
import {InputAdornment, InputLabel} from "@mui/material";
import Box from "@mui/material/Box";
import Switch from "@mui/material/Switch";
import {ErrorMessage, Field, useFormikContext} from "formik";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import React, {useMemo, useState} from "react";
import React, {useState} from "react";
import AceEditor from "react-ace";
import colors from "qqq/assets/theme/base/colors";
import BooleanFieldSwitch from "qqq/components/forms/BooleanFieldSwitch";
import MDInput from "qqq/components/legacy/MDInput";
import MDTypography from "qqq/components/legacy/MDTypography";
import {flushSync} from "react-dom";
// Declaring props types for FormField
interface Props
@ -42,10 +38,6 @@ interface Props
value: any;
type: string;
isEditable?: boolean;
placeholder?: string;
backgroundColor?: string;
onChangeCallback?: (newValue: any) => void;
[key: string]: any;
@ -55,12 +47,11 @@ interface Props
}
function QDynamicFormField({
label, name, displayFormat, value, bulkEditMode, bulkEditSwitchChangeHandler, type, isEditable, placeholder, backgroundColor, formFieldObject, onChangeCallback, ...rest
label, name, displayFormat, value, bulkEditMode, bulkEditSwitchChangeHandler, type, isEditable, formFieldObject, ...rest
}: Props): JSX.Element
{
const [switchChecked, setSwitchChecked] = useState(false);
const [isDisabled, setIsDisabled] = useState(!isEditable || bulkEditMode);
const {inputBorderColor} = colors;
const {setFieldValue} = useFormikContext();
@ -71,30 +62,18 @@ function QDynamicFormField({
inputLabelProps.shrink = true;
}
const inputProps: any = {};
const inputProps = {};
if (displayFormat && displayFormat.startsWith("$"))
{
// @ts-ignore
inputProps.startAdornment = <InputAdornment position="start">$</InputAdornment>;
}
if (displayFormat && displayFormat.endsWith("%%"))
{
// @ts-ignore
inputProps.endAdornment = <InputAdornment position="end">%</InputAdornment>;
}
if (placeholder)
{
inputProps.placeholder = placeholder
}
if(backgroundColor)
{
inputProps.sx = {
"&.MuiInputBase-root": {
backgroundColor: backgroundColor
}
};
}
// @ts-ignore
const handleOnWheel = (e) =>
{
@ -104,106 +83,17 @@ function QDynamicFormField({
}
};
///////////////////////////////////////////////////////////////////////////////////////
// check the field meta data for behavior that says to do toUpperCase or toLowerCase //
///////////////////////////////////////////////////////////////////////////////////////
let isToUpperCase = useMemo(() => DynamicFormUtils.isToUpperCase(formFieldObject?.fieldMetaData), [formFieldObject]);
let isToLowerCase = useMemo(() => DynamicFormUtils.isToLowerCase(formFieldObject?.fieldMetaData), [formFieldObject]);
////////////////////////////////////////////////////////////////////////
// if the field has a toUpperCase or toLowerCase behavior on it, then //
// apply that rule. But also, to avoid the cursor always jumping to //
// the end of the input, do some manipulation of the selection. //
// See: https://giacomocerquone.com/blog/keep-input-cursor-still //
// Note, we only want an onChange handle if we're doing one of these //
// behaviors, (because teh flushSync is potentially slow). hence, we //
// put the onChange in an object and assign it with a spread //
////////////////////////////////////////////////////////////////////////
let onChange: any = {};
if (isToUpperCase || isToLowerCase || onChangeCallback)
{
onChange.onChange = (e: any) =>
{
if(isToUpperCase || isToLowerCase)
{
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(name, newValue);
if(onChangeCallback)
{
onChangeCallback(newValue);
}
});
const input = document.getElementById(name) as HTMLInputElement;
if (input)
{
input.setSelectionRange(beforeStart, beforeEnd);
}
}
else if(onChangeCallback)
{
onChangeCallback(e.currentTarget.value);
}
};
}
/***************************************************************************
**
***************************************************************************/
function dynamicSelectOnChange(newValue?: QPossibleValue)
{
if(onChangeCallback)
{
onChangeCallback(newValue == null ? null : newValue.id)
}
}
let field;
let getsBulkEditHtmlLabel = true;
if(formFieldObject.possibleValueProps)
{
field = (<DynamicSelect
name={name}
fieldPossibleValueProps={formFieldObject.possibleValueProps}
isEditable={!isDisabled}
fieldLabel={label}
initialValue={value}
bulkEditMode={bulkEditMode}
bulkEditSwitchChangeHandler={bulkEditSwitchChangeHandler}
onChange={dynamicSelectOnChange}
// otherValues={otherValuesMap}
useCase="form"
/>)
}
else if (type === "checkbox")
if (type === "checkbox")
{
getsBulkEditHtmlLabel = false;
field = (<>
<BooleanFieldSwitch name={name} label={label} value={value} isDisabled={isDisabled} onChangeCallback={onChangeCallback} />
<Box mt={0.75}>
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
{!isDisabled && <div className="fieldErrorMessage"><ErrorMessage name={name} render={msg => <span data-field-error="true">{msg}</span>} /></div>}
</MDTypography>
</Box>
</>);
field = (<BooleanFieldSwitch name={name} label={label} value={value} isDisabled={isDisabled} />);
}
else if (type === "ace")
{
let mode = "text";
if (formFieldObject && formFieldObject.languageMode)
if(formFieldObject && formFieldObject.languageMode)
{
mode = formFieldObject.languageMode;
}
@ -220,16 +110,12 @@ function QDynamicFormField({
onChange={(value: string, event: any) =>
{
setFieldValue(name, value, false);
if(onChangeCallback)
{
onChangeCallback(value);
}
}}
setOptions={{useWorker: false}}
width="100%"
height="300px"
value={value}
style={{border: `1px solid ${inputBorderColor}`, borderRadius: "0.75rem"}}
style={{border: "1px solid gray"}}
/>
</>
);
@ -238,7 +124,7 @@ function QDynamicFormField({
{
field = (
<>
<Field {...rest} {...onChange} onWheel={handleOnWheel} name={name} type={type} as={MDInput} variant="outlined" label={label} InputLabelProps={inputLabelProps} InputProps={inputProps} fullWidth disabled={isDisabled}
<Field {...rest} onWheel={handleOnWheel} name={name} type={type} as={MDInput} variant="standard" label={label} InputLabelProps={inputLabelProps} InputProps={inputProps} fullWidth disabled={isDisabled}
onKeyPress={(e: any) =>
{
if (e.key === "Enter")
@ -278,15 +164,6 @@ function QDynamicFormField({
id={`bulkEditSwitch-${name}`}
checked={switchChecked}
onClick={bulkEditSwitchChanged}
sx={{
top: "-4px",
"& .MuiSwitch-track": {
height: 20,
borderRadius: 10,
top: -3,
position: "relative"
}
}}
/>
</Box>
<Box width="100%" sx={{background: (type == "checkbox" && isDisabled) ? "#f0f2f5!important" : "initial"}}>

View File

@ -22,41 +22,29 @@
import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {FieldPossibleValueProps} from "qqq/models/fields/FieldPossibleValueProps";
import * as Yup from "yup";
type DisabledFields = { [fieldName: string]: boolean } | string[];
/*******************************************************************************
** Meta-data to represent a single field in a table.
**
*******************************************************************************/
class DynamicFormUtils
{
/*******************************************************************************
**
*******************************************************************************/
public static getFormData(qqqFormFields: QFieldMetaData[], disabledFields?: DisabledFields)
public static getFormData(qqqFormFields: QFieldMetaData[])
{
const dynamicFormFields: any = {};
const formValidations: any = {};
qqqFormFields.forEach((field) =>
{
dynamicFormFields[field.name] = this.getDynamicField(field, disabledFields);
formValidations[field.name] = this.getValidationForField(field, disabledFields);
dynamicFormFields[field.name] = this.getDynamicField(field);
formValidations[field.name] = this.getValidationForField(field);
});
return {dynamicFormFields, formValidations};
}
/*******************************************************************************
**
*******************************************************************************/
public static getDynamicField(field: QFieldMetaData, disabledFields?: DisabledFields)
public static getDynamicField(field: QFieldMetaData)
{
let fieldType: string;
switch (field.type.toString())
@ -97,21 +85,14 @@ class DynamicFormUtils
}
}
////////////////////////////////////////////////////////////
// this feels right, but... might be cases where it isn't //
////////////////////////////////////////////////////////////
const effectiveIsEditable = field.isEditable && !this.isFieldDynamicallyDisabled(field.name, disabledFields);
const effectivelyIsRequired = field.isRequired && effectiveIsEditable;
let label = field.label ? field.label : field.name;
label += effectivelyIsRequired ? " *" : "";
label += field.isRequired ? " *" : "";
return ({
fieldMetaData: field,
name: field.name,
label: label,
isRequired: effectivelyIsRequired,
isEditable: effectiveIsEditable,
isRequired: field.isRequired,
isEditable: field.isEditable,
type: fieldType,
displayFormat: field.displayFormat,
// todo invalidMsg: "Zipcode is not valid (e.g. 70000).",
@ -119,143 +100,64 @@ class DynamicFormUtils
});
}
/*******************************************************************************
**
*******************************************************************************/
public static getValidationForField(field: QFieldMetaData, disabledFields?: DisabledFields)
public static getValidationForField(field: QFieldMetaData)
{
const effectiveIsEditable = field.isEditable && !this.isFieldDynamicallyDisabled(field.name, disabledFields);
const effectivelyIsRequired = field.isRequired && effectiveIsEditable;
if (effectivelyIsRequired)
if (field.isRequired)
{
////////////////////////////////////////////////////////////////////////////////////////////
// the "nullable(true)" here doesn't mean that you're allowed to set the field to null... //
// rather, it's more like "null is how empty will be treated" or some-such... //
////////////////////////////////////////////////////////////////////////////////////////////
return (Yup.string().required(`${field.label ?? "This field"} is required.`).nullable(true));
if(field.possibleValueSourceName)
{
////////////////////////////////////////////////////////////////////////////////////////////
// the "nullable(true)" here doesn't mean that you're allowed to set the field to null... //
// rather, it's more like "null is how empty will be treated" or some-such... //
////////////////////////////////////////////////////////////////////////////////////////////
return (Yup.string().required(`${field.label} is required.`).nullable(true));
}
else
{
return (Yup.string().required(`${field.label} is required.`));
}
}
return (null);
}
/*******************************************************************************
**
*******************************************************************************/
public static addPossibleValueProps(dynamicFormFields: any, qFields: QFieldMetaData[], tableName: string, processName: string, displayValues: Map<string, string>)
{
for (let i = 0; i < qFields.length; i++)
{
const field = qFields[i];
if(!dynamicFormFields[field.name])
{
continue;
}
/////////////////////////////////////////
// add props for possible value fields //
/////////////////////////////////////////
if (field.possibleValueSourceName || field.inlinePossibleValueSource)
if (field.possibleValueSourceName && dynamicFormFields[field.name])
{
let props: FieldPossibleValueProps =
{
isPossibleValue: true,
fieldName: field.name,
initialDisplayValue: null
}
let initialDisplayValue = null;
if (displayValues)
{
props.initialDisplayValue = displayValues.get(field.name);
initialDisplayValue = displayValues.get(field.name);
}
if(field.inlinePossibleValueSource)
if (tableName)
{
//////////////////////////////////////////////////////////////////////
// handle an inline PVS - which is a list of possible value objects //
//////////////////////////////////////////////////////////////////////
props.possibleValues = field.inlinePossibleValueSource;
}
else if (tableName)
{
props.tableName = tableName;
}
else if (processName)
{
props.processName = processName;
dynamicFormFields[field.name].possibleValueProps =
{
isPossibleValue: true,
tableName: tableName,
initialDisplayValue: initialDisplayValue,
};
}
else
{
props.possibleValueSourceName = field.possibleValueSourceName;
}
dynamicFormFields[field.name].possibleValueProps = props;
}
}
}
/*******************************************************************************
** private helper - check the disabled fields object (array or map), and return
** true iff fieldName is in it.
*******************************************************************************/
private static isFieldDynamicallyDisabled(fieldName: string, disabledFields?: DisabledFields): boolean
{
if (!disabledFields)
{
return (false);
}
if (Array.isArray(disabledFields))
{
return (disabledFields.indexOf(fieldName) > -1);
}
else
{
return (disabledFields[fieldName]);
}
}
/***************************************************************************
* check if a field has the TO_UPPER_CASE behavior on it.
***************************************************************************/
public static isToUpperCase(fieldMetaData: QFieldMetaData): boolean
{
return this.hasBehavior(fieldMetaData, "TO_UPPER_CASE");
}
/***************************************************************************
* check if a field has the TO_LOWER_CASE behavior on it.
***************************************************************************/
public static isToLowerCase(fieldMetaData: QFieldMetaData): boolean
{
return this.hasBehavior(fieldMetaData, "TO_LOWER_CASE");
}
/***************************************************************************
* check if a field has a specific behavior name on it.
***************************************************************************/
private static hasBehavior(fieldMetaData: QFieldMetaData, behaviorName: string): boolean
{
if (fieldMetaData && fieldMetaData.behaviors)
{
for (let i = 0; i < fieldMetaData.behaviors.length; i++)
{
if (fieldMetaData.behaviors[i] == behaviorName)
{
return (true);
dynamicFormFields[field.name].possibleValueProps =
{
isPossibleValue: true,
processName: processName,
initialDisplayValue: initialDisplayValue,
};
}
}
}
return (false);
}
}
export default DynamicFormUtils;

View File

@ -28,20 +28,20 @@ import Box from "@mui/material/Box";
import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField";
import {ErrorMessage, useFormikContext} from "formik";
import colors from "qqq/assets/theme/base/colors";
import MDTypography from "qqq/components/legacy/MDTypography";
import {FieldPossibleValueProps} from "qqq/models/fields/FieldPossibleValueProps";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useState} from "react";
import MDTypography from "qqq/components/legacy/MDTypography";
import Client from "qqq/utils/qqq/Client";
interface Props
{
fieldPossibleValueProps: FieldPossibleValueProps;
tableName?: string;
processName?: string;
fieldName: string;
overrideId?: string;
name?: string;
fieldLabel: string;
inForm: boolean;
initialValue?: any;
initialDisplayValue?: string;
initialValues?: QPossibleValue[];
onChange?: any;
isEditable?: boolean;
@ -49,97 +49,33 @@ interface Props
bulkEditMode?: boolean;
bulkEditSwitchChangeHandler?: any;
otherValues?: Map<string, any>;
variant: "standard" | "outlined";
initiallyOpen: boolean;
useCase: "form" | "filter";
}
DynamicSelect.defaultProps = {
tableName: null,
processName: null,
inForm: true,
initialValue: null,
initialDisplayValue: null,
initialValues: undefined,
onChange: null,
isEditable: true,
isMultiple: false,
bulkEditMode: false,
otherValues: new Map<string, any>(),
variant: "outlined",
initiallyOpen: false,
bulkEditSwitchChangeHandler: () =>
{
},
};
const {inputBorderColor} = colors;
export const getAutocompleteOutlinedStyle = (isDisabled: boolean) =>
{
return ({
"& .MuiOutlinedInput-root": {
borderRadius: "0.75rem",
},
"& .MuiInputBase-root": {
padding: "0.5rem",
background: isDisabled ? "#f0f2f5!important" : "initial",
},
"& .MuiOutlinedInput-root .MuiAutocomplete-input": {
padding: "0",
fontSize: "1rem"
},
"& .Mui-disabled .MuiOutlinedInput-notchedOutline": {
borderColor: inputBorderColor
}
});
};
const qController = Client.getInstance();
function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, inForm, initialValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues, variant, initiallyOpen, useCase}: Props)
function DynamicSelect({tableName, processName, fieldName, overrideId, fieldLabel, inForm, initialValue, initialDisplayValue, initialValues, onChange, isEditable, isMultiple, bulkEditMode, bulkEditSwitchChangeHandler, otherValues}: Props)
{
const {fieldName, initialDisplayValue, possibleValueSourceName, possibleValues, processName, tableName} = fieldPossibleValueProps;
const [open, setOpen] = useState(initiallyOpen);
const [open, setOpen] = useState(false);
const [options, setOptions] = useState<readonly QPossibleValue[]>([]);
const [searchTerm, setSearchTerm] = useState(null);
const [firstRender, setFirstRender] = useState(true);
const [otherValuesWhenResultsWereLoaded, setOtherValuesWhenResultsWereLoaded] = useState(JSON.stringify(Object.fromEntries((otherValues))));
useEffect(() =>
{
if (tableName && processName)
{
console.log("DynamicSelect - you may not provide both a tableName and a processName");
}
if (tableName && !fieldName)
{
console.log("DynamicSelect - if you provide a tableName, you must also provide a fieldName");
}
if (processName && !fieldName)
{
console.log("DynamicSelect - if you provide a processName, you must also provide a fieldName");
}
if (!fieldName && !possibleValueSourceName)
{
console.log("DynamicSelect - you must provide either a fieldName (and a tableName or processName) or a possibleValueSourceName");
}
if (fieldName && !possibleValueSourceName)
{
if (!tableName || !processName)
{
console.log("DynamicSelect - if you provide a fieldName and not a possibleValueSourceName, then you must also provide a tableName or processName");
}
}
if (possibleValueSourceName)
{
if (tableName || processName)
{
console.log("DynamicSelect - if you provide a possibleValueSourceName, you should not also provide a tableName or processName");
}
}
}, [tableName, processName, fieldName, possibleValueSourceName]);
////////////////////////////////////////////////////////////////////////////////////////////////
// default value - needs to be an array (from initialValues (array) prop) for multiple mode - //
@ -167,49 +103,13 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
setFieldValueRef = setFieldValue;
}
/*******************************************************************************
**
*******************************************************************************/
const filterInlinePossibleValues = (searchTerm: string, possibleValues: QPossibleValue[]): QPossibleValue[] =>
{
return possibleValues.filter(pv => pv.label?.toLowerCase().startsWith(searchTerm));
}
/***************************************************************************
**
***************************************************************************/
const loadResults = async (): Promise<QPossibleValue[]> =>
{
if(possibleValues)
{
return filterInlinePossibleValues(searchTerm, possibleValues)
}
else
{
return await qController.possibleValues(tableName, processName, possibleValueSourceName ?? fieldName, searchTerm ?? "", null, otherValues, useCase);
}
}
/***************************************************************************
**
***************************************************************************/
useEffect(() =>
{
if (firstRender)
if(firstRender)
{
// console.log("First render, so not searching...");
setFirstRender(false);
/*
if(!initiallyOpen)
{
console.log("returning because not initially open?");
return;
}
*/
return;
}
// console.log("Use effect for searchTerm - searching!");
@ -219,9 +119,9 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
(async () =>
{
// console.log(`doing a search with ${searchTerm}`);
const results: QPossibleValue[] = await loadResults();
const results: QPossibleValue[] = await qController.possibleValues(tableName, processName, fieldName, searchTerm ?? "", null, otherValues);
if (tableMetaData == null && tableName)
if(tableMetaData == null && tableName)
{
let tableMetaData: QTableMetaData = await qController.loadTableMetaData(tableName);
setTableMetaData(tableMetaData);
@ -232,7 +132,7 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
// console.log(`${results}`);
if (active)
{
setOptions([...results]);
setOptions([ ...results ]);
}
})();
@ -240,67 +140,32 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
{
active = false;
};
}, [searchTerm]);
}, [ searchTerm ]);
/***************************************************************************
** todo - finish... call it in onOpen?
***************************************************************************/
const reloadIfOtherValuesAreChanged = () =>
{
if (JSON.stringify(Object.fromEntries(otherValues)) != otherValuesWhenResultsWereLoaded)
{
(async () =>
{
setLoading(true);
setOptions([]);
console.log("Refreshing possible values...");
const results: QPossibleValue[] = await loadResults();
setLoading(false);
setOptions([...results]);
setOtherValuesWhenResultsWereLoaded(JSON.stringify(Object.fromEntries(otherValues)));
})();
}
};
/***************************************************************************
**
***************************************************************************/
const inputChanged = (event: React.SyntheticEvent, value: string, reason: string) =>
{
// console.log(`input changed. Reason: ${reason}, setting search term to ${value}`);
if (reason !== "reset")
if(reason !== "reset")
{
// console.log(` -> setting search term to ${value}`);
setSearchTerm(value);
}
};
/***************************************************************************
**
***************************************************************************/
const handleBlur = (x: any) =>
{
setSearchTerm(null);
};
}
/***************************************************************************
**
***************************************************************************/
const handleChanged = (event: React.SyntheticEvent, value: any | any[], reason: string, details?: string) =>
{
// console.log("handleChanged. value is:");
// console.log(value);
setSearchTerm(null);
if (onChange)
if(onChange)
{
if (isMultiple)
if(isMultiple)
{
onChange(value);
}
@ -309,16 +174,12 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
onChange(value ? new QPossibleValue(value) : null);
}
}
else if (setFieldValueRef && fieldName)
else if(setFieldValueRef)
{
setFieldValueRef(fieldName, value ? value.id : null);
}
};
/***************************************************************************
**
***************************************************************************/
const filterOptions = (options: { id: any; label: string; }[], state: FilterOptionsState<{ id: any; label: string; }>): { id: any; label: string; }[] =>
{
/////////////////////////////////////////////////////////////////////////////////
@ -326,12 +187,8 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
// get options whose text/label matches the input (e.g., not ids that match) //
/////////////////////////////////////////////////////////////////////////////////
return (options);
};
}
/***************************************************************************
**
***************************************************************************/
// @ts-ignore
const renderOption = (props: Object, option: any, {selected}) =>
{
@ -339,24 +196,23 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
try
{
const field = tableMetaData?.fields.get(fieldName);
if (field)
const field = tableMetaData?.fields.get(fieldName)
if(field)
{
const adornment = field.getAdornment(AdornmentType.CHIP);
if (adornment)
if(adornment)
{
const color = adornment.getValue("color." + option.id) ?? "default";
const color = adornment.getValue("color." + option.id) ?? "default"
const iconName = adornment.getValue("icon." + option.id) ?? null;
const iconElement = iconName ? <Icon>{iconName}</Icon> : null;
content = (<Chip label={option.label} color={color} icon={iconElement} size="small" variant="outlined" sx={{fontWeight: 500}} />);
}
}
}
catch (e)
{
}
catch(e)
{ }
if (isMultiple)
if(isMultiple)
{
content = (
<>
@ -374,16 +230,12 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
// attributes. so, doing this, w/ key=id, seemed to fix it. //
///////////////////////////////////////////////////////////////////////////////////////////////
return (
<li {...props} key={option.id} style={{fontSize: "1rem"}}>
<li {...props} key={option.id}>
{content}
</li>
);
};
}
/***************************************************************************
**
***************************************************************************/
const bulkEditSwitchChanged = () =>
{
const newSwitchValue = !switchChecked;
@ -392,28 +244,20 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
bulkEditSwitchChangeHandler(fieldName, newSwitchValue);
};
////////////////////////////////////////////
// for outlined style, adjust some styles //
////////////////////////////////////////////
let autocompleteSX = {};
if (variant == "outlined")
{
autocompleteSX = getAutocompleteOutlinedStyle(isDisabled);
}
// console.log(`default value: ${JSON.stringify(defaultValue)}`);
const autocomplete = (
<Box>
<Autocomplete
id={overrideId ?? fieldName ?? possibleValueSourceName ?? "anonymous"}
name={name}
sx={autocompleteSX}
id={overrideId ?? fieldName}
sx={{background: isDisabled ? "#f0f2f5!important" : "initial"}}
open={open}
fullWidth
onOpen={() =>
{
setOpen(true);
// console.log("setting open...");
if (options.length == 0)
if(options.length == 0)
{
// console.log("no options yet, so setting search term to ''...");
setSearchTerm("");
@ -423,22 +267,17 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
{
setOpen(false);
}}
isOptionEqualToValue={(option, value) => value !== null && value !== undefined && option.id === value.id}
isOptionEqualToValue={(option, value) => option.id === value.id}
getOptionLabel={(option) =>
{
if (option === null || option === undefined)
{
return ("");
}
// @ts-ignore
if (option && option.length)
if(option && option.length)
{
// @ts-ignore
option = option[0];
}
// @ts-ignore
return option.label;
return option.label
}}
options={options}
loading={loading}
@ -466,7 +305,7 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
<TextField
{...params}
label={fieldLabel}
variant={variant}
variant="standard"
autoComplete="off"
type="search"
InputProps={{
@ -485,7 +324,7 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
inForm &&
<Box mt={0.75}>
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
{!isDisabled && <div className="fieldErrorMessage"><ErrorMessage name={overrideId ?? fieldName ?? possibleValueSourceName ?? "anonymous"} render={msg => <span data-field-error="true">{msg}</span>} /></div>}
{!isDisabled && <div className="fieldErrorMessage"><ErrorMessage name={fieldName} render={msg => <span data-field-error="true">{msg}</span>} /></div>}
</MDTypography>
</Box>
}
@ -502,15 +341,6 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
id={`bulkEditSwitch-${fieldName}`}
checked={switchChecked}
onClick={bulkEditSwitchChanged}
sx={{
top: "-4px",
"& .MuiSwitch-track": {
height: 20,
borderRadius: 10,
top: -3,
position: "relative"
}
}}
/>
</Box>
<Box width="100%">
@ -522,7 +352,7 @@ function DynamicSelect({fieldPossibleValueProps, overrideId, name, fieldLabel, i
else
{
return (
<Box>
<Box mb={1.5}>
{autocomplete}
</Box>
);

File diff suppressed because it is too large Load Diff

View File

@ -1,156 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Button, colors, Icon} from "@mui/material";
import Box from "@mui/material/Box";
import Tooltip from "@mui/material/Tooltip";
import {useFormikContext} from "formik";
import MDTypography from "qqq/components/legacy/MDTypography";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useCallback, useState} from "react";
import {useDropzone} from "react-dropzone";
interface FileInputFieldProps
{
field: any,
record?: QRecord,
errorMessage?: any
}
export default function FileInputField({field, record, errorMessage}: FileInputFieldProps): JSX.Element
{
const [fileName, setFileName] = useState(null as string);
const formikProps = useFormikContext();
const fileChanged = (event: React.FormEvent<HTMLInputElement>, field: any) =>
{
setFileName(null);
if (event.currentTarget.files && event.currentTarget.files[0])
{
setFileName(event.currentTarget.files[0].name);
}
formikProps.setFieldValue(field.name, event.currentTarget.files[0]);
};
const onDrop = useCallback((acceptedFiles: any) =>
{
setFileName(null);
if (acceptedFiles.length && acceptedFiles[0])
{
setFileName(acceptedFiles[0].name);
}
formikProps.setFieldValue(field.name, acceptedFiles[0]);
}, []);
const {getRootProps, getInputProps, isDragActive} = useDropzone({onDrop});
const removeFile = (fieldName: string) =>
{
setFileName(null);
formikProps.setFieldValue(fieldName, null);
record?.values.delete(fieldName);
record?.displayValues.delete(fieldName);
};
const pseudoField = new QFieldMetaData({name: field.name, type: QFieldType.BLOB});
const fileUploadAdornment = field.fieldMetaData?.getAdornment(AdornmentType.FILE_UPLOAD);
const format = fileUploadAdornment?.values?.get("format") ?? "button";
return (
<Box mb={1.5}>
{
record && record.values.get(field.name) && <Box fontSize="0.875rem" pb={1}>
Current File:
<Box display="inline-flex" pl={1}>
{ValueUtils.getDisplayValue(pseudoField, record, "view")}
<Tooltip placement="bottom" title="Remove current file">
<Icon className="blobIcon" fontSize="small" onClick={(e) => removeFile(field.name)}>delete</Icon>
</Tooltip>
</Box>
</Box>
}
{
format == "button" &&
<Box display="flex" alignItems="center">
<Button variant="outlined" component="label">
<span style={{color: colors.lightBlue[500]}}>Choose file to upload</span>
<input
id={field.name}
name={field.name}
type="file"
hidden
onChange={(event: React.FormEvent<HTMLInputElement>) => fileChanged(event, field)}
/>
</Button>
<Box ml={1} fontSize={"1rem"}>
{fileName}
</Box>
</Box>
}
{
format == "dragAndDrop" &&
<>
<Box {...getRootProps()} sx={
{
display: "flex",
alignItems: "center",
justifyContent: "center",
width: "100%",
height: "300px",
borderRadius: "2rem",
backgroundColor: isDragActive ? colors.lightBlue[50] : "transparent",
border: `2px ${isDragActive ? "solid" : "dashed"} ${colors.lightBlue[500]}`
}}>
<input {...getInputProps()} />
<Box display="flex" alignItems="center" flexDirection="column">
<Icon sx={{fontSize: "4rem !important", color: colors.lightBlue[500]}}>upload_file</Icon>
<Box>Drag and drop a file</Box>
<Box fontSize="1rem" m="0.5rem">or</Box>
<Box border={`2px solid ${colors.lightBlue[500]}`} mt="0.25rem" padding="0.25rem 1rem" borderRadius="0.5rem" sx={{cursor: "pointer"}} fontSize="1rem">
Browse files
</Box>
</Box>
</Box>
<Box fontSize={"1rem"} mt="0.25rem">
{fileName}&nbsp;
</Box>
</>
}
<Box mt={0.75}>
<MDTypography component="div" variant="caption" color="error" fontWeight="regular">
{errorMessage && <span>{errorMessage}</span>}
</MDTypography>
</Box>
</Box>
);
}

View File

@ -25,7 +25,6 @@ import Icon from "@mui/material/Icon";
import {ReactNode, useContext} from "react";
import {Link} from "react-router-dom";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import MDTypography from "qqq/components/legacy/MDTypography";
interface Props
@ -63,55 +62,41 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element
///////////////////////////////////////////////////////////////////////
// strip away empty elements of the route (e.g., trailing slash(es)) //
///////////////////////////////////////////////////////////////////////
if (route.length)
if(route.length)
{
// @ts-ignore
route = route.filter(r => r != "");
}
const routes: string[] | any = route.slice(0, -1);
const {pathToLabelMap, branding} = useContext(QContext);
const {pageHeader, pathToLabelMap, branding} = useContext(QContext);
const fullPathToLabel = (fullPath: string, route: string): string =>
{
if (fullPath.endsWith("/"))
if(fullPath.endsWith("/"))
{
fullPath = fullPath.replace(/\/+$/, "");
}
if (pathToLabelMap && pathToLabelMap[fullPath])
if(pathToLabelMap && pathToLabelMap[fullPath])
{
return pathToLabelMap[fullPath];
}
return (routeToLabel(route));
};
}
let pageTitle = branding?.appName ?? "";
const fullRoutes: string[] = [];
let accumulatedPath = "";
for (let i = 0; i < routes.length; i++)
{
////////////////////////////////////////////////////////
// avoid showing "saved view" as a breadcrumb element //
// e.g., if at /app/table/savedView/1 //
////////////////////////////////////////////////////////
if (routes[i] === "savedView" && i == routes.length - 1)
if(routes[i] === "savedFilter")
{
continue;
}
///////////////////////////////////////////////////////////////////////
// avoid showing the table name if it's the element before savedView //
// e.g., when at /app/table/savedView/1 (so where i==1) //
// we want to just be showing "App" //
///////////////////////////////////////////////////////////////////////
if (i < routes.length - 1 && routes[i + 1] == "savedView" && i == 1)
{
continue;
}
if (routes[i] === "")
if(routes[i] === "")
{
continue;
}
@ -127,31 +112,48 @@ function QBreadcrumbs({icon, title, route, light}: Props): JSX.Element
<Box mr={{xs: 0, xl: 8}}>
<MuiBreadcrumbs
sx={{
fontSize: "1.125rem",
fontWeight: "500",
color: colors.dark.main,
"& li": {
lineHeight: "unset!important"
},
"& a": {
color: colors.gray.main
},
"& .MuiBreadcrumbs-separator": {
fontSize: "1.125rem",
fontWeight: "500",
color: colors.dark.main
color: ({palette: {white, grey}}) => (light ? white.main : grey[600]),
},
}}
>
<Link to="/">
<Icon sx={{fontSize: "1.25rem!important", position: "relative", top: "0.25rem"}}>{icon}</Icon>
<MDTypography
component="span"
variant="body2"
color={light ? "white" : "dark"}
opacity={light ? 0.8 : 0.5}
sx={{lineHeight: 0}}
>
<Icon>{icon}</Icon>
</MDTypography>
</Link>
{fullRoutes.map((fullRoute: string) => (
<Link to={fullRoute} key={fullRoute}>
{fullPathToLabel(fullRoute, fullRoute.replace(/.*\//, ""))}
<MDTypography
component="span"
variant="button"
fontWeight="regular"
textTransform="capitalize"
color={light ? "white" : "dark"}
opacity={light ? 0.8 : 0.5}
sx={{lineHeight: 0}}
>
{fullPathToLabel(fullRoute, fullRoute.replace(/.*\//, ""))}
</MDTypography>
</Link>
))}
</MuiBreadcrumbs>
<MDTypography
pt={1}
fontWeight="bold"
textTransform="capitalize"
variant="h5"
color={light ? "white" : "dark"}
noWrap
>
{pageHeader}
</MDTypography>
</Box>
);
}

View File

@ -64,14 +64,13 @@ function Footer({company, links}: Props): JSX.Element
<Box
width="100%"
display="flex"
flexDirection={{xs: "column", md: "row"}}
flexDirection={{xs: "column", lg: "row"}}
justifyContent="space-between"
alignItems="center"
px={1.5}
style={{
position: "fixed", bottom: "0px", zIndex: -1, marginBottom: "10px",
}}
left={{xs: "0", xl: "auto"}}
>
{
href && name &&

View File

@ -19,22 +19,23 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Popper, InputAdornment, Box} from "@mui/material";
import {Popper} from "@mui/material";
import AppBar from "@mui/material/AppBar";
import Autocomplete from "@mui/material/Autocomplete";
import Badge from "@mui/material/Badge";
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import ListItemIcon from "@mui/material/ListItemIcon";
import {Theme} from "@mui/material/styles";
import Menu from "@mui/material/Menu";
import TextField from "@mui/material/TextField";
import Toolbar from "@mui/material/Toolbar";
import React, {useContext, useEffect, useRef, useState} from "react";
import React, {useContext, useEffect, useState} from "react";
import {useLocation, useNavigate} from "react-router-dom";
import QContext from "QContext";
import QBreadcrumbs, {routeToLabel} from "qqq/components/horseshoe/Breadcrumbs";
import {navbar, navbarContainer, navbarRow, navbarMobileMenu, recentlyViewedMenu,} from "qqq/components/horseshoe/Styles";
import MDTypography from "qqq/components/legacy/MDTypography";
import {setTransparentNavbar, useMaterialUIController, setMiniSidenav} from "qqq/context";
import {navbar, navbarContainer, navbarIconButton, navbarRow,} from "qqq/components/horseshoe/Styles";
import {setTransparentNavbar, useMaterialUIController,} from "qqq/context";
import HistoryUtils from "qqq/utils/HistoryUtils";
// Declaring prop types for NavBar
@ -45,8 +46,7 @@ interface Props
isMini?: boolean;
}
interface HistoryEntry
{
interface HistoryEntry {
id: number;
path: string;
label: string;
@ -57,7 +57,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
{
const [navbarType, setNavbarType] = useState<"fixed" | "absolute" | "relative" | "static" | "sticky">();
const [controller, dispatch] = useMaterialUIController();
const {miniSidenav, transparentNavbar, fixedNavbar, darkMode,} = controller;
const {transparentNavbar, fixedNavbar, darkMode,} = controller;
const [openMenu, setOpenMenu] = useState<any>(false);
const [history, setHistory] = useState<any>([] as HistoryEntry[]);
const [autocompleteValue, setAutocompleteValue] = useState<any>(null);
@ -65,8 +65,6 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
const route = useLocation().pathname.split("/").slice(1);
const navigate = useNavigate();
const {pageHeader, setDotMenuOpen} = useContext(QContext);
useEffect(() =>
{
// Setting the navbar type
@ -100,19 +98,17 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
const options = [] as any;
history.entries.reverse().forEach((entry, index) =>
options.push({label: `${entry.label} index`, id: index, key: index, path: entry.path, iconName: entry.iconName})
);
)
setHistory(options);
// Remove event listener on cleanup
return () => window.removeEventListener("scroll", handleTransparentNavbar);
}, [dispatch, fixedNavbar]);
const handleMiniSidenav = () => setMiniSidenav(dispatch, !miniSidenav);
const goToHistory = (path: string) =>
{
navigate(path);
};
}
function buildHistoryEntries()
{
@ -120,7 +116,7 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
const options = [] as any;
history.entries.reverse().forEach((entry, index) =>
options.push({label: entry.label, id: index, key: index, path: entry.path, iconName: entry.iconName})
);
)
setHistory(options);
}
@ -134,12 +130,12 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
const handleAutocompleteOnChange = (event: any, value: any, reason: any, details: any) =>
{
if (value)
if(value)
{
goToHistory(value.path);
}
setAutocompleteValue(null);
};
}
const CustomPopper = function (props: any)
{
@ -147,8 +143,8 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
{...props}
style={{whiteSpace: "nowrap", width: "auto"}}
placement="bottom-end"
/>);
};
/>)
}
const renderHistory = () =>
{
@ -161,20 +157,12 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
options={history}
autoHighlight
blurOnSelect
style={{width: "16rem"}}
style={{width: "200px"}}
onOpen={handleHistoryOnOpen}
onChange={handleAutocompleteOnChange}
PopperComponent={CustomPopper}
isOptionEqualToValue={(option, value) => option.id === value.id}
sx={recentlyViewedMenu}
renderInput={(params) => <TextField {...params} label="Recently Viewed Records" InputProps={{
...params.InputProps,
endAdornment: (
<InputAdornment position="end">
<Icon sx={{position: "relative", right: "-1rem"}}>keyboard_arrow_down</Icon>
</InputAdornment>
)
}} />}
renderInput={(params) => <TextField {...params} label="Recently Viewed Records" />}
renderOption={(props, option: HistoryEntry) => (
<Box {...props} component="li" key={option.id} sx={{width: "auto"}}>
<Box sx={{width: "auto", px: "8px", whiteSpace: "overflow"}} key={option.id}>
@ -185,7 +173,23 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
)}
/>
);
};
}
// Render the notifications menu
const renderMenu = () => (
<Menu
anchorEl={openMenu}
anchorReference={null}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
open={Boolean(openMenu)}
onClose={handleCloseMenu}
sx={{mt: 2}}
/>
);
// Styles for the navbar icons
const iconsStyle = ({
@ -211,34 +215,21 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
const {pathToLabelMap} = useContext(QContext);
const fullPathToLabel = (fullPath: string, route: string): string =>
{
if (fullPath.endsWith("/"))
if(fullPath.endsWith("/"))
{
fullPath = fullPath.replace(/\/+$/, "");
}
if (pathToLabelMap && pathToLabelMap[fullPath])
if(pathToLabelMap && pathToLabelMap[fullPath])
{
return pathToLabelMap[fullPath];
}
return (routeToLabel(route));
};
}
const breadcrumbTitle = fullPathToLabel(fullPath, route[route.length - 1]);
///////////////////////////////////////////////////////////////////////////////////////////////
// set the right-half of the navbar up so that below the 'md' breakpoint, it just disappears //
///////////////////////////////////////////////////////////////////////////////////////////////
const navbarRowRight = (theme: Theme, {isMini}: any) =>
{
return {
[theme.breakpoints.down("md")]: {
display: "none",
},
...navbarRow(theme, isMini)
}
};
return (
<AppBar
position={absolute ? "absolute" : navbarType}
@ -249,32 +240,29 @@ function NavBar({absolute, light, isMini}: Props): JSX.Element
>
<Toolbar sx={navbarContainer}>
<Box color="inherit" mb={{xs: 1, md: 0}} sx={(theme) => navbarRow(theme, {isMini})}>
<IconButton size="small" disableRipple color="inherit" sx={navbarMobileMenu} onClick={handleMiniSidenav}>
<Icon sx={iconsStyle} fontSize="large">menu</Icon>
</IconButton>
<QBreadcrumbs icon="home" title={breadcrumbTitle} route={route} light={light} />
</Box>
{isMini ? null : (
<Box sx={(theme) => navbarRowRight(theme, {isMini})}>
<Box mt={"-0.25rem"} pb={"0.75rem"} pr={2} mr={-2} sx={{"& *": {cursor: "pointer !important"}}}>
<Box sx={(theme) => navbarRow(theme, {isMini})}>
<Box pr={1}>
{renderHistory()}
</Box>
<Box mt={"-1rem"}>
<IconButton size="small" disableRipple color="inherit" onClick={() => setDotMenuOpen(true)}>
<Icon sx={iconsStyle} fontSize="small">search</Icon>
<Box color={light ? "white" : "inherit"}>
<IconButton
size="small"
color="inherit"
sx={navbarIconButton}
onClick={handleOpenMenu}
>
<Badge badgeContent={0} color="error" variant="dot">
<Icon sx={iconsStyle}>notifications</Icon>
</Badge>
</IconButton>
{renderMenu()}
</Box>
</Box>
)}
</Toolbar>
{
pageHeader &&
<Box display="flex" justifyContent="space-between">
<MDTypography pb="0.5rem" textTransform="capitalize" variant="h3" color={light ? "white" : "dark"} noWrap>
{pageHeader}
</MDTypography>
</Box>
}
</AppBar>
);
}

View File

@ -20,7 +20,6 @@
*/
import {Theme} from "@mui/material/styles";
import colors from "qqq/assets/theme/base/colors";
function navbar(theme: Theme | any, ownerState: any)
{
@ -66,12 +65,12 @@ function navbar(theme: Theme | any, ownerState: any)
return color;
},
top: absolute ? 0 : pxToRem(12),
minHeight: "auto",
minHeight: pxToRem(75),
display: "grid",
alignItems: "center",
borderRadius: borderRadius.xl,
paddingTop: pxToRem(0),
paddingBottom: pxToRem(0),
paddingTop: pxToRem(8),
paddingBottom: pxToRem(8),
paddingRight: absolute ? pxToRem(8) : 0,
paddingLeft: absolute ? pxToRem(16) : 0,
@ -85,7 +84,7 @@ function navbar(theme: Theme | any, ownerState: any)
"& .MuiToolbar-root": {
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
alignItems: "center",
[breakpoints.up("sm")]: {
minHeight: "auto",
@ -99,10 +98,10 @@ const navbarContainer = ({breakpoints}: Theme): any => ({
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "space-between",
padding: "0 !important",
[breakpoints.up("md")]: {
flexDirection: "row",
alignItems: "center",
paddingTop: "0",
paddingBottom: "0",
},
@ -111,10 +110,11 @@ const navbarContainer = ({breakpoints}: Theme): any => ({
const navbarRow = ({breakpoints}: Theme, {isMini}: any) => ({
display: "flex",
alignItems: "center",
justifyContent: "space-between",
width: "100%",
[breakpoints.up("md")]: {
justifyContent: "stretch",
justifyContent: isMini ? "space-between" : "stretch",
width: isMini ? "100%" : "max-content",
},
@ -146,38 +146,12 @@ const navbarDesktopMenu = ({breakpoints}: Theme) => ({
display: "none !important",
cursor: "pointer",
[breakpoints.down("sm")]: {
[breakpoints.up("xl")]: {
display: "inline-block !important",
},
});
const recentlyViewedMenu = ({breakpoints}: Theme) => ({
marginTop: "-0.5rem",
"& .MuiInputLabel-root": {
color: colors.gray.main,
fontWeight: "500",
fontSize: "1rem"
},
"& .MuiInputAdornment-root": {
marginTop: "0.5rem",
color: colors.gray.main,
fontSize: "1rem"
},
"& .MuiOutlinedInput-root": {
borderRadius: "0",
padding: "0"
},
"& .MuiOutlinedInput-root .MuiOutlinedInput-notchedOutline": {
border: "0"
},
display: "block",
[breakpoints.down("md")]: {
display: "none !important",
},
});
const navbarMobileMenu = ({breakpoints}: Theme) => ({
left: "-0.75rem",
display: "inline-block",
lineHeight: 0,
@ -193,5 +167,4 @@ export {
navbarIconButton,
navbarDesktopMenu,
navbarMobileMenu,
recentlyViewedMenu
};

View File

@ -27,7 +27,7 @@ export default styled(Drawer)(({theme, ownerState}: { theme?: Theme | any; owner
const {palette, boxShadows, transitions, breakpoints, functions} = theme;
const {transparentSidenav, whiteSidenav, miniSidenav, darkMode} = ownerState;
const sidebarWidth = 245;
const sidebarWidth = 250;
const {transparent, gradients, white, background} = palette;
const {xxl} = boxShadows;
const {pxToRem, linearGradient} = functions;
@ -94,9 +94,6 @@ export default styled(Drawer)(({theme, ownerState}: { theme?: Theme | any; owner
"& .MuiDrawer-paper": {
boxShadow: xxl,
border: "none",
margin: "0",
borderRadius: "0",
height: "100%",
...(miniSidenav ? drawerCloseStyles() : drawerOpenStyles()),
},

View File

@ -64,8 +64,7 @@ function collapseItem(theme: Theme, ownerState: any)
borderRadius: borderRadius.md,
cursor: "pointer",
userSelect: "none",
whiteSpace: "wrap",
overflow: "hidden",
whiteSpace: "nowrap",
boxShadow: active && !whiteSidenav && !darkMode && !transparentSidenav ? md : "none",
[breakpoints.up("xl")]: {
transition: transitions.create(["box-shadow", "background-color"], {
@ -74,10 +73,6 @@ function collapseItem(theme: Theme, ownerState: any)
}),
},
"& .MuiListItemText-primary": {
lineHeight: "revert"
},
"&:hover, &:focus": {
backgroundColor:
transparentSidenav && !darkMode

View File

@ -69,15 +69,7 @@ export default styled(TextField)(({theme, ownerState}: { theme?: Theme; ownerSta
});
return {
"& .MuiInputBase-root": {
backgroundColor: disabled ? `${grey[200]} !important` : transparent.main,
borderRadius: "0.75rem",
},
"& input": {
backgroundColor: `${transparent.main}!important`,
padding: "0.5rem",
fontSize: "1rem",
},
backgroundColor: disabled ? `${grey[200]} !important` : transparent.main,
pointerEvents: disabled ? "none" : "auto",
...(error && errorStyles()),
...(success && successStyles()),

View File

@ -149,7 +149,7 @@ interface Types {
}
const baseProperties = {
fontFamily: '"SF Pro Display", "Roboto", "Helvetica", "Arial", sans-serif',
fontFamily: '"Roboto", "Helvetica", "Arial", sans-serif',
fontWeightLighter: 100,
fontWeightLight: 300,
fontWeightRegular: 400,

View File

@ -153,7 +153,7 @@ interface Types
}
const baseProperties = {
fontFamily: "\"SF Pro Display\", \"Roboto\", \"Helvetica\", \"Arial\", sans-serif",
fontFamily: "\"Roboto\", \"Helvetica\", \"Arial\", sans-serif",
fontWeightLighter: 100,
fontWeightLight: 300,
fontWeightRegular: 400,

View File

@ -1,74 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import React, {Component, ErrorInfo} from "react";
interface Props
{
errorElement?: React.ReactNode;
children: React.ReactNode;
}
interface State
{
hasError: boolean;
}
/*******************************************************************************
** Component that you can wrap around other components that might throw an error,
** to give some isolation, rather than breaking a whole page.
** Credit: https://medium.com/@bobjunior542/how-to-use-error-boundaries-in-react-js-with-typescript-ee90ec814bf1
*******************************************************************************/
class ErrorBoundary extends Component<Props, State>
{
/***************************************************************************
*
***************************************************************************/
constructor(props: Props)
{
super(props);
this.state = {hasError: false};
}
/***************************************************************************
*
***************************************************************************/
componentDidCatch(error: Error, errorInfo: ErrorInfo)
{
console.error("ErrorBoundary caught an error: ", error, errorInfo);
this.setState({hasError: true});
}
/***************************************************************************
*
***************************************************************************/
render()
{
if (this.state.hasError)
{
return this.props.errorElement ?? <span>(Error)</span>;
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@ -1,206 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. 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 {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {Box} from "@mui/material";
import Autocomplete, {AutocompleteRenderOptionState} from "@mui/material/Autocomplete";
import Icon from "@mui/material/Icon";
import TextField from "@mui/material/TextField";
import React, {ReactNode, useState} from "react";
interface FieldAutoCompleteProps
{
id: string;
metaData: QInstance;
tableMetaData: QTableMetaData;
handleFieldChange: (event: any, newValue: any, reason: string) => void;
defaultValue?: { field: QFieldMetaData, table: QTableMetaData, fieldName: string };
autoFocus?: boolean;
forceOpen?: boolean;
hiddenFieldNames?: string[];
availableFieldNames?: string[];
variant?: "standard" | "filled" | "outlined";
label?: string;
textFieldSX?: any;
autocompleteSlotProps?: any;
hasError?: boolean;
noOptionsText?: string;
}
FieldAutoComplete.defaultProps =
{
defaultValue: null,
autoFocus: false,
forceOpen: null,
hiddenFieldNames: [],
availableFieldNames: [],
variant: "standard",
label: "Field",
textFieldSX: null,
autocompleteSlotProps: null,
hasError: false,
noOptionsText: "No options",
};
function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean, hiddenFieldNames: string[], availableFieldNames: string[], selectedFieldName: string)
{
const sortedFields = [...tableMetaData.fields.values()].sort((a, b) => a.label.localeCompare(b.label));
for (let i = 0; i < sortedFields.length; i++)
{
const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name;
if (hiddenFieldNames && hiddenFieldNames.indexOf(fieldName) > -1 && fieldName != selectedFieldName)
{
continue;
}
if (availableFieldNames?.length && availableFieldNames.indexOf(fieldName) == -1)
{
continue;
}
fieldOptions.push({field: sortedFields[i], table: tableMetaData, fieldName: fieldName});
}
}
/*******************************************************************************
** Component for rendering a list of field names from a table as an auto-complete.
*******************************************************************************/
export default function FieldAutoComplete({id, metaData, tableMetaData, handleFieldChange, defaultValue, autoFocus, forceOpen, hiddenFieldNames, availableFieldNames, variant, label, textFieldSX, autocompleteSlotProps, hasError, noOptionsText}: FieldAutoCompleteProps): JSX.Element
{
const [selectedFieldName, setSelectedFieldName] = useState(defaultValue ? defaultValue.fieldName : null);
const fieldOptions: any[] = [];
makeFieldOptionsForTable(tableMetaData, fieldOptions, false, hiddenFieldNames, availableFieldNames, selectedFieldName);
let fieldsGroupBy = null;
if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length > 0)
{
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
{
const exposedJoin = tableMetaData.exposedJoins[i];
if (metaData.tables.has(exposedJoin.joinTable.name))
{
fieldsGroupBy = (option: any) => `${option.table.label} fields`;
makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true, hiddenFieldNames, availableFieldNames, selectedFieldName);
}
}
}
function getFieldOptionLabel(option: any)
{
/////////////////////////////////////////////////////////////////////////////////////////
// note - we're using renderFieldOption below for the actual select-box options, which //
// are always jut field label (as they are under groupings that show their table name) //
/////////////////////////////////////////////////////////////////////////////////////////
if (option && option.field && option.table)
{
if (option.table.name == tableMetaData.name)
{
return (option.field.label);
}
else
{
return (option.table.label + ": " + option.field.label);
}
}
return ("");
}
//////////////////////////////////////////////////////////////////////////////////////////////
// for options, we only want the field label (contrast with what we show in the input box, //
// which comes out of getFieldOptionLabel, which is the table-label prefix for join fields) //
//////////////////////////////////////////////////////////////////////////////////////////////
function renderFieldOption(props: React.HTMLAttributes<HTMLLIElement>, option: any, state: AutocompleteRenderOptionState): ReactNode
{
let label = "";
if (option && option.field)
{
label = (option.field.label);
}
return (<li {...props}>{label}</li>);
}
function isFieldOptionEqual(option: any, value: any)
{
return option.fieldName === value.fieldName;
}
///////////////////////////////////////////////////////////////////////////////////////////////////////
// seems like, if we always add the open attribute, then if its false or null, then the autocomplete //
// doesn't open at all... so, only add the attribute at all, if forceOpen is true //
///////////////////////////////////////////////////////////////////////////////////////////////////////
const alsoOpen: { [key: string]: any } = {};
if (forceOpen)
{
alsoOpen["open"] = forceOpen;
}
/*******************************************************************************
**
*******************************************************************************/
function onChange(event: any, newValue: any, reason: string)
{
setSelectedFieldName(newValue ? newValue.fieldName : null);
handleFieldChange(event, newValue, reason);
}
return (
<Autocomplete
id={id}
renderInput={(params) =>
{
const inputProps = params.InputProps;
const originalEndAdornment = inputProps.endAdornment;
inputProps.endAdornment = <Box>
{hasError && <Icon color="error">error_outline</Icon>}
{originalEndAdornment}
</Box>;
return (<TextField {...params} autoFocus={autoFocus} label={label} variant={variant} sx={textFieldSX} autoComplete="off" type="search" InputProps={inputProps} />)
}}
// @ts-ignore
defaultValue={defaultValue}
options={fieldOptions}
onChange={onChange}
isOptionEqualToValue={(option, value) => isFieldOptionEqual(option, value)}
groupBy={fieldsGroupBy}
getOptionLabel={(option) => getFieldOptionLabel(option)}
renderOption={(props, option, state) => renderFieldOption(props, option, state)}
autoSelect={true}
autoHighlight={true}
slotProps={autocompleteSlotProps ?? {}}
noOptionsText={noOptionsText}
{...alsoOpen}
/>
);
}

View File

@ -34,8 +34,8 @@ import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Grid from "@mui/material/Grid";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import TextField from "@mui/material/TextField";
import {any} from "prop-types";
import React, {useState} from "react";
import {useNavigate} from "react-router-dom";
import {QCancelButton} from "qqq/components/buttons/DefaultButtons";
@ -62,7 +62,7 @@ const qController = Client.getInstance();
function hasGotoFieldNames(tableMetaData: QTableMetaData): boolean
{
const mdbMetaData = tableMetaData?.supplementalTableMetaData?.get("materialDashboard");
if (mdbMetaData && mdbMetaData.gotoFieldNames)
if(mdbMetaData && mdbMetaData.gotoFieldNames)
{
return (true);
}
@ -72,56 +72,44 @@ function hasGotoFieldNames(tableMetaData: QTableMetaData): boolean
function GotoRecordDialog(props: Props): JSX.Element
{
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// this is an array of array of fields. //
// that is - each entry in the top-level array is a set of fields that can be used together to goto a record //
// such as (pkey), (ukey-field1,ukey-field2). //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
const options: QFieldMetaData[][] = [];
const fields: QFieldMetaData[] = []
let pkey = props?.tableMetaData?.fields.get(props?.tableMetaData?.primaryKeyField);
let addedPkey = false;
const mdbMetaData = props?.tableMetaData?.supplementalTableMetaData?.get("materialDashboard");
if (mdbMetaData)
if(mdbMetaData)
{
if (mdbMetaData.gotoFieldNames)
if(mdbMetaData.gotoFieldNames)
{
for (let i = 0; i < mdbMetaData.gotoFieldNames.length; i++)
for(let i = 0; i<mdbMetaData.gotoFieldNames.length; i++)
{
const option: QFieldMetaData[] = [];
options.push(option);
for (let j = 0; j < mdbMetaData.gotoFieldNames[i].length; j++)
// todo - multi-field keys!!
let fieldName = mdbMetaData.gotoFieldNames[i][0];
let field = props.tableMetaData.fields.get(fieldName);
if(field)
{
let fieldName = mdbMetaData.gotoFieldNames[i][j];
let field = props.tableMetaData.fields.get(fieldName);
if (field)
{
option.push(field);
fields.push(field);
if (pkey != null && field.name == pkey.name)
{
addedPkey = true;
}
if(field.name == pkey.name)
{
addedPkey = true;
}
}
}
}
}
//////////////////////////////////////////////////////////////////////////////////////////
// if pkey wasn't in the gotoField options meta-data, go ahead add it as an option here //
//////////////////////////////////////////////////////////////////////////////////////////
if (pkey && !addedPkey)
if(pkey && !addedPkey)
{
options.unshift([pkey]);
fields.unshift(pkey);
}
const makeInitialValues = () =>
{
const rs = {} as { [field: string]: string };
options.forEach((option) => option.forEach((field) => rs[field.name] = ""));
const rs = {} as {[field: string]: string};
fields.forEach((field) => rs[field.name] = "");
return (rs);
};
}
const [error, setError] = useState("");
const [values, setValues] = useState(makeInitialValues());
@ -131,91 +119,49 @@ function GotoRecordDialog(props: Props): JSX.Element
{
values[fieldName] = newValue;
setValues(JSON.parse(JSON.stringify(values)));
};
}
const close = () =>
{
setError("");
setValues(makeInitialValues());
props.closeHandler();
};
}
const keyPressed = (e: React.KeyboardEvent<HTMLDivElement>) =>
{
// @ts-ignore
const targetId: string = e.target?.id;
if (e.key == "Esc")
if(e.key == "Esc")
{
if (props.mayClose)
if(props.mayClose)
{
close();
}
}
else if (e.key == "Enter" && targetId?.startsWith("gotoInput-"))
else if(e.key == "Enter" && targetId?.startsWith("gotoInput-"))
{
const parts = targetId?.split(/-/);
const index = parts[1];
const index = targetId?.replaceAll("gotoInput-", "");
document.getElementById("gotoButton-" + index).click();
}
};
}
/***************************************************************************
** event handler for close button
***************************************************************************/
const closeRequested = () =>
{
if (props.mayClose)
if(props.mayClose)
{
close();
}
};
/*******************************************************************************
** function to say if an option's submit button should be disabled
*******************************************************************************/
const isOptionSubmitButtonDisabled = (optionIndex: number) =>
{
let anyFieldsInThisOptionHaveAValue = false;
options[optionIndex].forEach((field) =>
{
if(values[field.name])
{
anyFieldsInThisOptionHaveAValue = true;
}
})
if(!anyFieldsInThisOptionHaveAValue)
{
return (true);
}
return (false);
}
/***************************************************************************
** event handler for clicking an 'option's go/submit button
***************************************************************************/
const optionGoClicked = async (optionIndex: number) =>
const goClicked = async (fieldName: string) =>
{
setError("");
const criteria: QFilterCriteria[] = [];
const queryStringParts: string[] = [];
options[optionIndex].forEach((field) =>
{
criteria.push(new QFilterCriteria(field.name, QCriteriaOperator.EQUALS, [values[field.name]]))
queryStringParts.push(`${field.name}=${encodeURIComponent(values[field.name])}`)
})
const filter = new QQueryFilter(criteria, null, null, "AND", null, 10);
const filter = new QQueryFilter([new QFilterCriteria(fieldName, QCriteriaOperator.EQUALS, [values[fieldName]])], null, "AND", null, 10);
try
{
const queryResult = await qController.query(props.tableMetaData.name, filter, null, props.tableVariant);
const queryResult = await qController.query(props.tableMetaData.name, filter, null, props.tableVariant)
if (queryResult.length == 0)
{
setError("Record not found.");
@ -223,84 +169,72 @@ function GotoRecordDialog(props: Props): JSX.Element
}
else if (queryResult.length == 1)
{
if(options[optionIndex].length == 1 && options[optionIndex][0].name == pkey?.name)
{
/////////////////////////////////////////////////
// navigate by pkey, if that's how we searched //
/////////////////////////////////////////////////
navigate(`${props.metaData.getTablePathByName(props.tableMetaData.name)}/${encodeURIComponent(queryResult[0].values.get(props.tableMetaData.primaryKeyField))}`);
}
else
{
/////////////////////////////////
// else navigate by unique-key //
/////////////////////////////////
navigate(`${props.metaData.getTablePathByName(props.tableMetaData.name)}/key/?${queryStringParts.join("&")}`);
}
navigate(`${props.metaData.getTablePathByName(props.tableMetaData.name)}/${encodeURIComponent(queryResult[0].values.get(props.tableMetaData.primaryKeyField))}`);
close();
}
else
{
setError("More than 1 record was found...");
setError("More than 1 record found...");
setTimeout(() => setError(""), 3000);
}
}
catch (e)
catch(e)
{
// @ts-ignore
setError(`Error: ${(e && e.message) ? e.message : e}`);
setTimeout(() => setError(""), 6000);
}
};
}
if (props.tableMetaData)
if(props.tableMetaData)
{
if (options.length == 0 && !error)
if (fields.length == 0 && !error)
{
setError("This table is not configured for this feature.");
setError("This table is not configured for this feature.")
}
}
return (
<Dialog open={props.isOpen} onClose={() => closeRequested} onKeyPress={(e) => keyPressed(e)} fullWidth maxWidth={"sm"}>
<DialogTitle>Go To...</DialogTitle>
<DialogTitle sx={{display: "flex"}}>
<Box sx={{display: "flex", flexGrow: 1}}>
Go To...
</Box>
<Box sx={{display: "flex"}}>
<IconButton onClick={() =>
{
document.location.href = "/";
}}><Icon sx={{align: "right"}} fontSize="small">close</Icon></IconButton>
</Box>
</DialogTitle>
<DialogContent>
{props.subHeader}
{
options.map((option, optionIndex) =>
<Box key={optionIndex}>
{
option.map((field, index) =>
(
<Grid key={field.name} container alignItems="center" py={1}>
<Grid item xs={3} textAlign="right" pr={2}>
{field.label}
</Grid>
<Grid item xs={6}>
<TextField
id={`gotoInput-${optionIndex}-${index}`}
autoFocus={optionIndex == 0 && index == 0}
autoComplete="off"
inputProps={{width: "100%"}}
onChange={(e) => handleChange(field.name, e.target.value)}
value={values[field.name]}
sx={{width: "100%"}}
onFocus={event => event.target.select()}
/>
</Grid>
<Grid item xs={1} pl={2}>
{
(index == option.length - 1) &&
<MDButton id={`gotoButton-${optionIndex}`} type="submit" variant="gradient" color="info" size="small" onClick={() => optionGoClicked(optionIndex)} fullWidth startIcon={<Icon>double_arrow</Icon>} disabled={isOptionSubmitButtonDisabled(optionIndex)}>Go</MDButton>
}
</Grid>
</Grid>
))
}
</Box>
)
fields.map((field, index) =>
(
<Grid key={field.name} container alignItems="center" py={1}>
<Grid item xs={3} textAlign="right" pr={2}>
{field.label}
</Grid>
<Grid item xs={6}>
<TextField
id={`gotoInput-${index}`}
autoFocus={index == 0}
autoComplete="off"
inputProps={{width: "100%"}}
onChange={(e) => handleChange(field.name, e.target.value)}
value={values[field.name]}
sx={{width: "100%"}}
onFocus={event => event.target.select()}
/>
</Grid>
<Grid item xs={1} pl={2}>
<MDButton id={`gotoButton-${index}`} type="submit" variant="gradient" color="info" size="small" onClick={() => goClicked(field.name)} fullWidth startIcon={<Icon>double_arrow</Icon>} disabled={`${values[field.name]}`.length == 0}>
Go
</MDButton>
</Grid>
</Grid>
))
}
{
error &&
@ -320,7 +254,7 @@ function GotoRecordDialog(props: Props): JSX.Element
: <Box>&nbsp;</Box>
}
</Dialog>
);
)
}
interface GotoRecordButtonProps
@ -342,7 +276,7 @@ GotoRecordButton.defaultProps = {
export function GotoRecordButton(props: GotoRecordButtonProps): JSX.Element
{
const [gotoIsOpen, setGotoIsOpen] = useState(props.autoOpen);
const [gotoIsOpen, setGotoIsOpen] = useState(props.autoOpen)
function openGoto()
{
@ -358,7 +292,7 @@ export function GotoRecordButton(props: GotoRecordButtonProps): JSX.Element
return (
<React.Fragment>
{
props.buttonVisible && hasGotoFieldNames(props.tableMetaData) && <Button onClick={openGoto} sx={{whiteSpace: "nowrap"}}>Go To...</Button>
props.buttonVisible && hasGotoFieldNames(props.tableMetaData) && <Button onClick={openGoto} >Go To...</Button>
}
<GotoRecordDialog metaData={props.metaData} tableMetaData={props.tableMetaData} tableVariant={props.tableVariant} isOpen={gotoIsOpen} closeHandler={closeGoto} mayClose={props.mayClose} subHeader={props.subHeader} />
</React.Fragment>

View File

@ -1,163 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. 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 {QHelpContent} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QHelpContent";
import Box from "@mui/material/Box";
import parse from "html-react-parser";
import ErrorBoundary from "qqq/components/misc/ErrorBoundary";
import React, {useContext} from "react";
import Markdown from "react-markdown";
import QContext from "QContext";
interface Props
{
helpContents: null | QHelpContent | QHelpContent[];
roles: string[];
heading?: string;
helpContentKey?: string;
}
HelpContent.defaultProps = {};
/*******************************************************************************
** format some content - meaning, change it from string to JSX element(s) or string.
** does a parse() for HTML, and a <Markdown> for markdown, else just text.
*******************************************************************************/
const formatHelpContent = (content: string, format: string): string | JSX.Element | JSX.Element[] =>
{
if (format == "HTML")
{
return parse(content);
}
else if (format == "MARKDOWN")
{
return (<Markdown>{content}</Markdown>)
}
return content;
}
/*******************************************************************************
** return the first help content from the list that matches the first role
** in the roles list.
*******************************************************************************/
const getMatchingHelpContent = (helpContents: QHelpContent[], roles: string[]): QHelpContent =>
{
if (helpContents)
{
if (helpContents.length == 1 && helpContents[0].roles.size == 0)
{
//////////////////////////////////////////////////////////////////////////////////////////////////
// if there's only 1 entry, and it has no roles, then assume user wanted it globally and use it //
//////////////////////////////////////////////////////////////////////////////////////////////////
return (helpContents[0]);
}
else
{
for (let i = 0; i < roles.length; i++)
{
for (let j = 0; j < helpContents.length; j++)
{
if (helpContents[j].roles.has(roles[i]))
{
return(helpContents[j])
}
}
}
}
}
return (null);
}
/*******************************************************************************
** test if a list of help contents would find any matches from a list of roles.
*******************************************************************************/
export const hasHelpContent = (helpContents: null | QHelpContent | QHelpContent[], roles: string[]) =>
{
return getMatchingHelpContent(nullOrSingletonOrArrayToArray(helpContents), roles) != null;
}
/*******************************************************************************
**
*******************************************************************************/
const nullOrSingletonOrArrayToArray = (helpContents: null | QHelpContent | QHelpContent[]): QHelpContent[] =>
{
let array: QHelpContent[] = [];
if(Array.isArray(helpContents))
{
array = helpContents;
}
else if(helpContents != null)
{
array.push(helpContents);
}
return (array);
}
/*******************************************************************************
** component that renders a box of formatted help content, from a list of
** helpContents (from meta-data), and for a list of roles (based on what screen
*******************************************************************************/
function HelpContent({helpContents, roles, heading, helpContentKey}: Props): JSX.Element
{
const {helpHelpActive} = useContext(QContext);
const helpContentsArray = nullOrSingletonOrArrayToArray(helpContents);
let selectedHelpContent = getMatchingHelpContent(helpContentsArray, roles);
let content = null;
let errorContent = "Error rendering help content.";
if (helpHelpActive)
{
if (!selectedHelpContent)
{
selectedHelpContent = new QHelpContent({content: ""});
}
content = selectedHelpContent.content + ` [${helpContentKey ?? "?"}]`;
errorContent += ` [${helpContentKey ?? "?"}]`;
}
else if(selectedHelpContent)
{
content = selectedHelpContent.content;
}
///////////////////////////////////////////////////
// if content was found, format it and return it //
///////////////////////////////////////////////////
if (content)
{
return <Box display="inline" className="helpContent">
{heading && <span className="header">{heading}</span>}
<ErrorBoundary errorElement={<i>{errorContent}</i>}>
{formatHelpContent(content, selectedHelpContent.format)}
</ErrorBoundary>
</Box>;
}
return (null);
}
export default HelpContent;

View File

@ -1,795 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import FormControlLabel from "@mui/material/FormControlLabel";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import List from "@mui/material/List/List";
import ListItem, {ListItemProps} from "@mui/material/ListItem/ListItem";
import Menu from "@mui/material/Menu";
import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import React, {useState} from "react";
export type Option = { label: string, value: string | number, [key: string]: any }
export type Group = { label: string, value: string | number, options: Option[], subGroups?: Group[], [key: string]: any }
type StringOrNumber = string | number
interface QHierarchyAutoCompleteProps
{
idPrefix: string;
heading?: string;
placeholder?: string;
defaultGroup: Group;
showGroupHeaderEvenIfNoSubGroups: boolean;
optionValuesToHide?: StringOrNumber[];
buttonProps: any;
buttonChildren: JSX.Element | string;
menuDirection: "down" | "up";
isModeSelectOne?: boolean;
keepOpenAfterSelectOne?: boolean;
handleSelectedOption?: (option: Option, group: Group) => void;
isModeToggle?: boolean;
toggleStates?: { [optionValue: string]: boolean };
disabledStates?: { [optionValue: string]: boolean };
tooltips?: { [optionValue: string]: string };
handleToggleOption?: (option: Option, group: Group, newValue: boolean) => void;
optionEndAdornment?: JSX.Element;
handleAdornmentClick?: (option: Option, group: Group, event: React.MouseEvent<any>) => void;
forceRerender?: number
}
QHierarchyAutoComplete.defaultProps = {
menuDirection: "down",
showGroupHeaderEvenIfNoSubGroups: false,
isModeSelectOne: false,
keepOpenAfterSelectOne: false,
isModeToggle: false,
};
interface GroupWithOptions
{
group?: Group;
options: Option[];
}
/***************************************************************************
** a sort of re-implementation of Autocomplete, that can display headers
** & children, which may be collapsable (Is that only for toggle mode?)
** but which also can have adornments that trigger actions, or be in a
** single-click-do-something mode.
*
** Originally built just for fields exposed on a table query screen, but
** then factored out of that for use in bulk-load (where it wasn't based on
** exposed joins).
***************************************************************************/
export default function QHierarchyAutoComplete({idPrefix, heading, placeholder, defaultGroup, showGroupHeaderEvenIfNoSubGroups, optionValuesToHide, buttonProps, buttonChildren, isModeSelectOne, keepOpenAfterSelectOne, isModeToggle, handleSelectedOption, toggleStates, disabledStates, tooltips, handleToggleOption, optionEndAdornment, handleAdornmentClick, menuDirection, forceRerender}: QHierarchyAutoCompleteProps): JSX.Element
{
const [menuAnchorElement, setMenuAnchorElement] = useState(null);
const [searchText, setSearchText] = useState("");
const [focusedIndex, setFocusedIndex] = useState(null as number);
const [optionsByGroup, setOptionsByGroup] = useState([] as GroupWithOptions[]);
const [collapsedGroups, setCollapsedGroups] = useState({} as { [groupValue: string | number]: boolean });
const [lastMouseOverXY, setLastMouseOverXY] = useState({x: 0, y: 0});
const [timeOfLastArrow, setTimeOfLastArrow] = useState(0);
//////////////////
// check usages //
//////////////////
if(isModeSelectOne)
{
if(!handleSelectedOption)
{
throw("In QAutoComplete, if isModeSelectOne=true, then a callback for handleSelectedOption must be provided.");
}
}
if(isModeToggle)
{
if(!toggleStates)
{
throw("In QAutoComplete, if isModeToggle=true, then a model for toggleStates must be provided.");
}
if(!handleToggleOption)
{
throw("In QAutoComplete, if isModeToggle=true, then a callback for handleToggleOption must be provided.");
}
}
/////////////////////
// init some stuff //
/////////////////////
if (optionsByGroup.length == 0)
{
collapsedGroups[defaultGroup.value] = false;
if (defaultGroup.subGroups?.length > 0)
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if we have exposed joins, put the table meta data with its fields, and then all of the join tables & fields too //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
optionsByGroup.push({group: defaultGroup, options: getGroupOptionsAsAlphabeticalArray(defaultGroup)});
for (let i = 0; i < defaultGroup.subGroups?.length; i++)
{
const subGroup = defaultGroup.subGroups[i];
optionsByGroup.push({group: subGroup, options: getGroupOptionsAsAlphabeticalArray(subGroup)});
collapsedGroups[subGroup.value] = false;
}
}
else
{
///////////////////////////////////////////////////////////
// no exposed joins - just the table (w/o its meta-data) //
///////////////////////////////////////////////////////////
optionsByGroup.push({options: getGroupOptionsAsAlphabeticalArray(defaultGroup)});
}
setOptionsByGroup(optionsByGroup);
setCollapsedGroups(collapsedGroups);
}
/*******************************************************************************
**
*******************************************************************************/
function getGroupOptionsAsAlphabeticalArray(group: Group): Option[]
{
const options: Option[] = [];
group.options.forEach(option =>
{
let fullOptionValue = option.value;
if(group.value != defaultGroup.value)
{
fullOptionValue = `${defaultGroup.value}.${option.value}`;
}
if(optionValuesToHide && optionValuesToHide.indexOf(fullOptionValue) > -1)
{
return;
}
options.push(option)
});
options.sort((a, b) => a.label.localeCompare(b.label));
return (options);
}
const optionsByGroupToShow: GroupWithOptions[] = [];
let maxOptionIndex = 0;
optionsByGroup.forEach((groupWithOptions) =>
{
let optionsToShowForThisGroup = groupWithOptions.options.filter(doesOptionMatchSearchText);
if (optionsToShowForThisGroup.length > 0)
{
optionsByGroupToShow.push({group: groupWithOptions.group, options: optionsToShowForThisGroup});
maxOptionIndex += optionsToShowForThisGroup.length;
}
});
/*******************************************************************************
**
*******************************************************************************/
function doesOptionMatchSearchText(option: Option): boolean
{
if (searchText == "")
{
return (true);
}
const columnLabelMinusTable = option.label.replace(/.*: /, "");
if (columnLabelMinusTable.toLowerCase().startsWith(searchText.toLowerCase()))
{
return (true);
}
try
{
////////////////////////////////////////////////////////////
// try to match word-boundary followed by the filter text //
// e.g., "name" would match "First Name" or "Last Name" //
////////////////////////////////////////////////////////////
const re = new RegExp("\\b" + searchText.toLowerCase());
if (columnLabelMinusTable.toLowerCase().match(re))
{
return (true);
}
}
catch (e)
{
//////////////////////////////////////////////////////////////////////////////////
// in case text is an invalid regex... well, at least do a starts-with match... //
//////////////////////////////////////////////////////////////////////////////////
if (columnLabelMinusTable.toLowerCase().startsWith(searchText.toLowerCase()))
{
return (true);
}
}
const tableLabel = option.label.replace(/:.*/, "");
if (tableLabel)
{
try
{
////////////////////////////////////////////////////////////
// try to match word-boundary followed by the filter text //
// e.g., "name" would match "First Name" or "Last Name" //
////////////////////////////////////////////////////////////
const re = new RegExp("\\b" + searchText.toLowerCase());
if (tableLabel.toLowerCase().match(re))
{
return (true);
}
}
catch (e)
{
//////////////////////////////////////////////////////////////////////////////////
// in case text is an invalid regex... well, at least do a starts-with match... //
//////////////////////////////////////////////////////////////////////////////////
if (tableLabel.toLowerCase().startsWith(searchText.toLowerCase()))
{
return (true);
}
}
}
return (false);
}
/*******************************************************************************
**
*******************************************************************************/
function openMenu(event: any)
{
setFocusedIndex(null);
setMenuAnchorElement(event.currentTarget);
setTimeout(() =>
{
document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus();
doSetFocusedIndex(0, true);
});
}
/*******************************************************************************
**
*******************************************************************************/
function closeMenu()
{
setMenuAnchorElement(null);
}
/*******************************************************************************
** Event handler for toggling an option in toggle mode
*******************************************************************************/
function handleOptionToggle(event: React.ChangeEvent<HTMLInputElement>, option: Option, group: Group)
{
event.stopPropagation();
handleToggleOption(option, group, event.target.checked);
}
/*******************************************************************************
** Event handler for toggling a group in toggle mode
*******************************************************************************/
function handleGroupToggle(event: React.ChangeEvent<HTMLInputElement>, group: Group)
{
event.stopPropagation();
const optionsList = [...group.options.values()];
for (let i = 0; i < optionsList.length; i++)
{
const option = optionsList[i];
if (doesOptionMatchSearchText(option))
{
handleToggleOption(option, group, event.target.checked);
}
}
}
/*******************************************************************************
**
*******************************************************************************/
function toggleCollapsedGroup(value: string | number)
{
collapsedGroups[value] = !collapsedGroups[value];
setCollapsedGroups(Object.assign({}, collapsedGroups));
}
/*******************************************************************************
**
*******************************************************************************/
function getShownOptionAndGroupByIndex(targetIndex: number): { option: Option, group: Group }
{
let index = -1;
for (let i = 0; i < optionsByGroupToShow.length; i++)
{
const groupWithOption = optionsByGroupToShow[i];
for (let j = 0; j < groupWithOption.options.length; j++)
{
index++;
if (index == targetIndex)
{
return {option: groupWithOption.options[j], group: groupWithOption.group};
}
}
}
return (null);
}
/*******************************************************************************
** event handler for keys presses
*******************************************************************************/
function keyDown(event: any)
{
// console.log(`Event key: ${event.key}`);
setTimeout(() => document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus());
if (isModeSelectOne && event.key == "Enter" && focusedIndex != null)
{
setTimeout(() =>
{
event.stopPropagation();
const {option, group} = getShownOptionAndGroupByIndex(focusedIndex);
if (option)
{
const fullOptionValue = group && group.value != defaultGroup.value ? `${group.value}.${option.value}` : option.value;
const isDisabled = disabledStates && disabledStates[fullOptionValue]
if(isDisabled)
{
return;
}
if(!keepOpenAfterSelectOne)
{
closeMenu();
}
handleSelectedOption(option, group ?? defaultGroup);
}
});
return;
}
const keyOffsetMap: { [key: string]: number } = {
"End": 10000,
"Home": -10000,
"ArrowDown": 1,
"ArrowUp": -1,
"PageDown": 5,
"PageUp": -5,
};
const offset = keyOffsetMap[event.key];
if (offset)
{
event.stopPropagation();
setTimeOfLastArrow(new Date().getTime());
if (isModeSelectOne)
{
let startIndex = focusedIndex;
if (offset > 0)
{
/////////////////
// a down move //
/////////////////
if (startIndex == null)
{
startIndex = -1;
}
let goalIndex = startIndex + offset;
if (goalIndex > maxOptionIndex - 1)
{
goalIndex = maxOptionIndex - 1;
}
doSetFocusedIndex(goalIndex, true);
}
else
{
////////////////
// an up move //
////////////////
let goalIndex = startIndex + offset;
if (goalIndex < 0)
{
goalIndex = 0;
}
doSetFocusedIndex(goalIndex, true);
}
}
}
}
/*******************************************************************************
**
*******************************************************************************/
function doSetFocusedIndex(i: number, tryToScrollIntoView: boolean): void
{
if (isModeSelectOne)
{
setFocusedIndex(i);
console.log(`Setting index to ${i}`);
if (tryToScrollIntoView)
{
const element = document.getElementById(`field-list-dropdown-${idPrefix}-${i}`);
element?.scrollIntoView({block: "center"});
}
}
}
/*******************************************************************************
**
*******************************************************************************/
function setFocusedOption(option: Option, group: Group, tryToScrollIntoView: boolean)
{
let index = -1;
for (let i = 0; i < optionsByGroupToShow.length; i++)
{
const groupWithOption = optionsByGroupToShow[i];
for (let j = 0; j < groupWithOption.options.length; j++)
{
const loopOption = groupWithOption.options[j];
index++;
const groupMatches = (group == null || group.value == groupWithOption.group.value);
if (groupMatches && option.value == loopOption.value)
{
doSetFocusedIndex(index, tryToScrollIntoView);
return;
}
}
}
}
/*******************************************************************************
** event handler for mouse-over the menu
*******************************************************************************/
function handleMouseOver(event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLDivElement> | React.MouseEvent<HTMLLIElement>, option: Option, group: Group, isDisabled: boolean)
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// so we're trying to fix the case where, if you put your mouse over an element, but then press up/down arrows, //
// where the mouse will become over a different element after the scroll, and the focus will follow the mouse instead of keyboard. //
// the last x/y isn't really useful, because the mouse generally isn't left exactly where it was when the mouse-over happened (edge of the element) //
// but the keyboard last-arrow time that we capture, that's what's actually being useful in here //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (event.clientX == lastMouseOverXY.x && event.clientY == lastMouseOverXY.y)
{
// console.log("mouse didn't move, so, doesn't count");
return;
}
const now = new Date().getTime();
// console.log(`Compare now [${now}] to last arrow [${timeOfLastArrow}] (diff: [${now - timeOfLastArrow}])`);
if (now < timeOfLastArrow + 300)
{
// console.log("An arrow event happened less than 300 mills ago, so doesn't count.");
return;
}
// console.log("yay, mouse over...");
if(isDisabled)
{
setFocusedIndex(null);
}
else
{
setFocusedOption(option, group, false);
}
setLastMouseOverXY({x: event.clientX, y: event.clientY});
}
/*******************************************************************************
** event handler for text input changes
*******************************************************************************/
function updateSearch(event: React.ChangeEvent<HTMLInputElement>)
{
setSearchText(event?.target?.value ?? "");
doSetFocusedIndex(0, true);
}
/*******************************************************************************
**
*******************************************************************************/
function doHandleAdornmentClick(option: Option, group: Group, event: React.MouseEvent<any>)
{
console.log("In doHandleAdornmentClick");
closeMenu();
handleAdornmentClick(option, group, event);
}
/////////////////////////////////////////////////////////
// compute the group-level toggle state & count values //
/////////////////////////////////////////////////////////
const groupToggleStates: { [value: string]: boolean } = {};
const groupToggleCounts: { [value: string]: number } = {};
if (isModeToggle)
{
const {allOn, count} = getGroupToggleState(defaultGroup, true);
groupToggleStates[defaultGroup.value] = allOn;
groupToggleCounts[defaultGroup.value] = count;
for (let i = 0; i < defaultGroup.subGroups?.length; i++)
{
const subGroup = defaultGroup.subGroups[i];
const {allOn, count} = getGroupToggleState(subGroup, false);
groupToggleStates[subGroup.value] = allOn;
groupToggleCounts[subGroup.value] = count;
}
}
/*******************************************************************************
**
*******************************************************************************/
function getGroupToggleState(group: Group, isMainGroup: boolean): {allOn: boolean, count: number}
{
const optionsList = [...group.options.values()];
let allOn = true;
let count = 0;
for (let i = 0; i < optionsList.length; i++)
{
const option = optionsList[i];
const name = isMainGroup ? option.value : `${group.value}.${option.value}`;
if(!toggleStates[name])
{
allOn = false;
}
else
{
count++;
}
}
return ({allOn: allOn, count: count});
}
let index = -1;
const textFieldId = `field-list-dropdown-${idPrefix}-textField`;
let listItemPadding = isModeToggle ? "0.125rem" : "0.5rem";
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// for z-indexes, we set each table header to i+1, then the fields in that table to i (so they go behind it) //
// then we increment i by 2 for the next table (so the next header goes above the previous header) //
// this fixes a thing where, if one table's name wrapped to 2 lines, then when the next table below it would //
// come up, if it was only 1 line, then the second line from the previous one would bleed through. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
let zIndex = 1;
return (
<>
<Button onClick={openMenu} {...buttonProps}>
{buttonChildren}
</Button>
<Menu
anchorEl={menuAnchorElement}
anchorOrigin={{vertical: menuDirection == "down" ? "bottom" : "top", horizontal: "left"}}
transformOrigin={{vertical: menuDirection == "down" ? "top" : "bottom", horizontal: "left"}}
open={menuAnchorElement != null}
onClose={closeMenu}
onKeyDown={keyDown} // this is added here so arrow-key-up/down events don't make the whole menu become "focused" (blue outline). it works.
keepMounted
>
<Box width={isModeToggle ? "305px" : "265px"} borderRadius={2} className={`fieldListMenuBody fieldListMenuBody-${idPrefix}`}>
{
heading &&
<Box px={1} py={0.5} fontWeight={"700"}>
{heading}
</Box>
}
<Box p={1} pt={0.5}>
<TextField id={textFieldId} variant="outlined" placeholder={placeholder ?? "Search Fields"} fullWidth value={searchText} onChange={updateSearch} onKeyDown={keyDown} inputProps={{sx: {pr: "2rem"}}} />
{
searchText != "" && <IconButton sx={{position: "absolute", right: "0.5rem", top: "0.5rem"}} onClick={() =>
{
updateSearch(null);
document.getElementById(textFieldId).focus();
}}><Icon fontSize="small">close</Icon></IconButton>
}
</Box>
<Box maxHeight={"445px"} minHeight={"445px"} overflow="auto" mr={"-0.5rem"} sx={{scrollbarGutter: "stable"}}>
<List sx={{px: "0.5rem", cursor: "default"}}>
{
optionsByGroupToShow.map((groupWithOptions) =>
{
let headerContents = null;
const headerGroup = groupWithOptions.group || defaultGroup;
if (groupWithOptions.group || showGroupHeaderEvenIfNoSubGroups)
{
headerContents = (<b>{headerGroup.label}</b>);
}
if (isModeToggle)
{
headerContents = (<FormControlLabel
sx={{display: "flex", alignItems: "flex-start", "& .MuiFormControlLabel-label": {lineHeight: "1.4", fontWeight: "500 !important"}}}
control={<Switch
size="small"
sx={{top: "1px"}}
checked={toggleStates[headerGroup.value]}
onChange={(event) => handleGroupToggle(event, headerGroup)}
/>}
label={<span style={{marginTop: "0.25rem", display: "inline-block"}}><b>{headerGroup.label} Fields</b>&nbsp;<span style={{fontWeight: 400}}>({groupToggleCounts[headerGroup.value]})</span></span>} />);
}
if (isModeToggle)
{
headerContents = (
<>
<IconButton
onClick={() => toggleCollapsedGroup(headerGroup.value)}
sx={{justifyContent: "flex-start", fontSize: "0.875rem", pt: 0.5, pb: 0, mr: "0.25rem"}}
disableRipple={true}
>
<Icon sx={{fontSize: "1.5rem !important", position: "relative", top: "2px"}}>{collapsedGroups[headerGroup.value] ? "expand_less" : "expand_more"}</Icon>
</IconButton>
{headerContents}
</>
);
}
let marginLeft = "unset";
if (isModeToggle)
{
marginLeft = "-1rem";
}
zIndex += 2;
return (
<React.Fragment key={groupWithOptions.group?.value ?? "theGroup"}>
<>
{headerContents && <ListItem sx={{position: "sticky", top: -1, zIndex: zIndex + 1, padding: listItemPadding, ml: marginLeft, display: "flex", alignItems: "flex-start", backgroundImage: "linear-gradient(to bottom, rgba(255,255,255,1), rgba(255,255,255,1) 90%, rgba(255,255,255,0))"}}>{headerContents}</ListItem>}
{
groupWithOptions.options.map((option) =>
{
index++;
const key = `${groupWithOptions?.group?.value}-${option.value}`;
let label: JSX.Element | string = option.label;
const fullOptionValue = groupWithOptions.group && groupWithOptions.group.value != defaultGroup.value ? `${groupWithOptions.group.value}.${option.value}` : option.value;
const isDisabled = disabledStates && disabledStates[fullOptionValue]
if (collapsedGroups[headerGroup.value])
{
return (<React.Fragment key={key} />);
}
let style = {};
if (index == focusedIndex)
{
style = {backgroundColor: "#EFEFEF"};
}
const onClick: ListItemProps = {};
if (isModeSelectOne)
{
onClick.onClick = () =>
{
if(isDisabled)
{
return;
}
if(!keepOpenAfterSelectOne)
{
closeMenu();
}
handleSelectedOption(option, groupWithOptions.group ?? defaultGroup);
};
}
if (optionEndAdornment)
{
label = <Box width="100%" display="inline-flex" justifyContent="space-between">
{label}
<Box onClick={(event) => handleAdornmentClick(option, groupWithOptions.group, event)}>
{optionEndAdornment}
</Box>
</Box>;
}
let contents = <>{label}</>;
let paddingLeft = "0.5rem";
if (isModeToggle)
{
contents = (<FormControlLabel
sx={{display: "flex", alignItems: "flex-start", "& .MuiFormControlLabel-label": {lineHeight: "1.4", color: "#606060", fontWeight: "500 !important"}}}
control={<Switch
size="small"
sx={{top: "-3px"}}
checked={toggleStates[fullOptionValue]}
onChange={(event) => handleOptionToggle(event, option, groupWithOptions.group)}
/>}
label={label} />);
paddingLeft = "2.5rem";
}
const listItem = <ListItem
key={key}
id={`field-list-dropdown-${idPrefix}-${index}`}
sx={{color: isDisabled ? "#C0C0C0" : "#757575", p: 1, borderRadius: ".5rem", padding: listItemPadding, pl: paddingLeft, scrollMarginTop: "3rem", zIndex: zIndex, background: "#FFFFFF", ...style}}
onMouseOver={(event) =>
{
handleMouseOver(event, option, groupWithOptions.group, isDisabled)
}}
{...onClick}
>{contents}</ListItem>;
if(tooltips[fullOptionValue])
{
return <Tooltip key={key} title={tooltips[fullOptionValue]} placement="right" enterDelay={500}>{listItem}</Tooltip>
}
else
{
return listItem
}
})
}
</>
</React.Fragment>
);
})
}
{
index == -1 && <ListItem sx={{p: "0.5rem"}}><i>No options found.</i></ListItem>
}
</List>
</Box>
</Box>
</Menu>
</>
);
}

View File

@ -22,7 +22,7 @@
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Box} from "@mui/material";
import Box from "@mui/material/Box";
import Card from "@mui/material/Card";
import Icon from "@mui/material/Icon";
import {Theme} from "@mui/material/styles";
@ -41,7 +41,7 @@ interface Props
QRecordSidebar.defaultProps = {
light: false,
stickyTop: "1rem",
stickyTop: "110px",
};
interface SidebarEntry
@ -76,12 +76,12 @@ function QRecordSidebar({tableSections, widgetMetaDataList, light, stickyTop}: P
return (
<Card sx={{borderRadius: "0.75rem", position: "sticky", top: stickyTop, overflow: "hidden", maxHeight: "calc(100vh - 2rem)"}}>
<Box component="ul" display="flex" flexDirection="column" p={2} m={0} sx={{listStyle: "none", overflow: "auto", height: "100%"}}>
<Card sx={{borderRadius: ({borders: {borderRadius}}) => borderRadius.lg, position: "sticky", top: stickyTop}}>
<Box component="ul" display="flex" flexDirection="column" p={2} m={0} sx={{listStyle: "none"}}>
{
sidebarEntries ? sidebarEntries.map((entry: SidebarEntry, key: number) => (
<Box key={`section-link-${entry.name}`} onClick={() => document.getElementById(entry.name).scrollIntoView()} sx={{cursor: "pointer"}}>
<HashLink key={`section-link-${entry.name}`} to={`#${entry.name}`}>
<Box key={`section-${entry.name}`} component="li" pt={key === 0 ? 0 : 1}>
<MDTypography
variant="button"
@ -112,7 +112,7 @@ function QRecordSidebar({tableSections, widgetMetaDataList, light, stickyTop}: P
</MDTypography>
</Box>
</Box>
</HashLink>
)) : null
}
</Box>

View File

@ -1,790 +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 {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete";
import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Alert, Button} from "@mui/material";
import Box from "@mui/material/Box";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Divider from "@mui/material/Divider";
import Icon from "@mui/material/Icon";
import ListItemIcon from "@mui/material/ListItemIcon";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import {TooltipProps} from "@mui/material/Tooltip/Tooltip";
import FormData from "form-data";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QDeleteButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import {BulkLoadMapping, BulkLoadTableStructure, FileDescription} from "qqq/models/processes/BulkLoadModels";
import Client from "qqq/utils/qqq/Client";
import {SavedBulkLoadProfileUtils} from "qqq/utils/qqq/SavedBulkLoadProfileUtils";
import React, {useContext, useEffect, useRef, useState} from "react";
import {useLocation} from "react-router-dom";
interface Props
{
metaData: QInstance,
tableMetaData: QTableMetaData,
tableStructure: BulkLoadTableStructure,
currentSavedBulkLoadProfileRecord: QRecord,
currentMapping: BulkLoadMapping,
bulkLoadProfileOnChangeCallback?: (record: QRecord | null) => void,
allowSelectingProfile?: boolean,
fileDescription?: FileDescription,
bulkLoadProfileResetToSuggestedMappingCallback?: () => void
}
SavedBulkLoadProfiles.defaultProps = {
allowSelectingProfile: true
};
const qController = Client.getInstance();
/***************************************************************************
** menu-button, text elements, and modal(s) that let you work with saved
** bulk-load profiles.
***************************************************************************/
function SavedBulkLoadProfiles({metaData, tableMetaData, tableStructure, currentSavedBulkLoadProfileRecord, bulkLoadProfileOnChangeCallback, currentMapping, allowSelectingProfile, fileDescription, bulkLoadProfileResetToSuggestedMappingCallback}: Props): JSX.Element
{
const [yourSavedBulkLoadProfiles, setYourSavedBulkLoadProfiles] = useState([] as QRecord[]);
const [bulkLoadProfilesSharedWithYou, setBulkLoadProfilesSharedWithYou] = useState([] as QRecord[]);
const [savedBulkLoadProfilesMenu, setSavedBulkLoadProfilesMenu] = useState(null);
const [savedBulkLoadProfilesHaveLoaded, setSavedBulkLoadProfilesHaveLoaded] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [savePopupOpen, setSavePopupOpen] = useState(false);
const [isSaveAsAction, setIsSaveAsAction] = useState(false);
const [isRenameAction, setIsRenameAction] = useState(false);
const [isDeleteAction, setIsDeleteAction] = useState(false);
const [savedBulkLoadProfileNameInputValue, setSavedBulkLoadProfileNameInputValue] = useState(null as string);
const [popupAlertContent, setPopupAlertContent] = useState("");
const [savedSuccessMessage, setSavedSuccessMessage] = useState(null as string);
const [savedFailedMessage, setSavedFailedMessage] = useState(null as string);
const anchorRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const [saveOptionsOpen, setSaveOptionsOpen] = useState(false);
const SAVE_OPTION = "Save...";
const DUPLICATE_OPTION = "Duplicate...";
const RENAME_OPTION = "Rename...";
const DELETE_OPTION = "Delete...";
const CLEAR_OPTION = "New Profile";
const RESET_TO_SUGGESTION = "Reset to Suggested Mapping";
const {accentColor, accentColorLight, userId: currentUserId} = useContext(QContext);
const openSavedBulkLoadProfilesMenu = (event: any) => setSavedBulkLoadProfilesMenu(event.currentTarget);
const closeSavedBulkLoadProfilesMenu = () => setSavedBulkLoadProfilesMenu(null);
////////////////////////////////////////////////////////////////////////
// load records on first run (if user is allowed to select a profile) //
////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (allowSelectingProfile)
{
loadSavedBulkLoadProfiles()
.then(() =>
{
setSavedBulkLoadProfilesHaveLoaded(true);
});
}
}, []);
const baseBulkLoadMapping: BulkLoadMapping = currentSavedBulkLoadProfileRecord ? BulkLoadMapping.fromSavedProfileRecord(tableStructure, currentSavedBulkLoadProfileRecord) : new BulkLoadMapping(tableStructure);
const bulkLoadProfileDiffs: any[] = SavedBulkLoadProfileUtils.diffBulkLoadMappings(tableStructure, fileDescription, baseBulkLoadMapping, currentMapping);
let bulkLoadProfileIsModified = false;
if (bulkLoadProfileDiffs.length > 0)
{
bulkLoadProfileIsModified = true;
}
/*******************************************************************************
** make request to load all saved profiles from backend
*******************************************************************************/
async function loadSavedBulkLoadProfiles()
{
if (!tableMetaData)
{
return;
}
const formData = new FormData();
formData.append("tableName", tableMetaData.name);
const savedBulkLoadProfiles = await makeSavedBulkLoadProfileRequest("querySavedBulkLoadProfile", formData);
const yourSavedBulkLoadProfiles: QRecord[] = [];
const bulkLoadProfilesSharedWithYou: QRecord[] = [];
for (let i = 0; i < savedBulkLoadProfiles.length; i++)
{
const record = savedBulkLoadProfiles[i];
if (record.values.get("userId") == currentUserId)
{
yourSavedBulkLoadProfiles.push(record);
}
else
{
bulkLoadProfilesSharedWithYou.push(record);
}
}
setYourSavedBulkLoadProfiles(yourSavedBulkLoadProfiles);
setBulkLoadProfilesSharedWithYou(bulkLoadProfilesSharedWithYou);
}
/*******************************************************************************
** fired when a saved record is clicked from the dropdown
*******************************************************************************/
const handleSavedBulkLoadProfileRecordOnClick = async (record: QRecord) =>
{
setSavePopupOpen(false);
closeSavedBulkLoadProfilesMenu();
if (bulkLoadProfileOnChangeCallback)
{
bulkLoadProfileOnChangeCallback(record);
}
};
/*******************************************************************************
** fired when a save option is selected from the save... button/dropdown combo
*******************************************************************************/
const handleDropdownOptionClick = (optionName: string) =>
{
setSaveOptionsOpen(false);
setPopupAlertContent("");
closeSavedBulkLoadProfilesMenu();
setSavePopupOpen(true);
setIsSaveAsAction(false);
setIsRenameAction(false);
setIsDeleteAction(false);
switch (optionName)
{
case SAVE_OPTION:
if (currentSavedBulkLoadProfileRecord == null)
{
setSavedBulkLoadProfileNameInputValue("");
}
break;
case DUPLICATE_OPTION:
setSavedBulkLoadProfileNameInputValue("");
setIsSaveAsAction(true);
break;
case CLEAR_OPTION:
setSavePopupOpen(false);
if (bulkLoadProfileOnChangeCallback)
{
bulkLoadProfileOnChangeCallback(null);
}
break;
case RESET_TO_SUGGESTION:
setSavePopupOpen(false);
if(bulkLoadProfileResetToSuggestedMappingCallback)
{
bulkLoadProfileResetToSuggestedMappingCallback();
}
break;
case RENAME_OPTION:
if (currentSavedBulkLoadProfileRecord != null)
{
setSavedBulkLoadProfileNameInputValue(currentSavedBulkLoadProfileRecord.values.get("label"));
}
setIsRenameAction(true);
break;
case DELETE_OPTION:
setIsDeleteAction(true);
break;
}
};
/*******************************************************************************
** fired when save or delete button saved on confirmation dialogs
*******************************************************************************/
async function handleDialogButtonOnClick()
{
try
{
setPopupAlertContent("");
setIsSubmitting(true);
const formData = new FormData();
if (isDeleteAction)
{
formData.append("id", currentSavedBulkLoadProfileRecord.values.get("id"));
await makeSavedBulkLoadProfileRequest("deleteSavedBulkLoadProfile", formData);
setSavePopupOpen(false);
setSaveOptionsOpen(false);
await (async () =>
{
handleDropdownOptionClick(CLEAR_OPTION);
})();
}
else
{
formData.append("tableName", tableMetaData.name);
/////////////////////////////////////////////////////////////////////////////////////////
// convert the BulkLoadMapping object to a BulkLoadProfile - the thing that gets saved //
/////////////////////////////////////////////////////////////////////////////////////////
const bulkLoadProfile = currentMapping.toProfile();
const mappingJson = JSON.stringify(bulkLoadProfile.profile);
formData.append("mappingJson", mappingJson);
if (isSaveAsAction || isRenameAction || currentSavedBulkLoadProfileRecord == null)
{
formData.append("label", savedBulkLoadProfileNameInputValue);
if (currentSavedBulkLoadProfileRecord != null && isRenameAction)
{
formData.append("id", currentSavedBulkLoadProfileRecord.values.get("id"));
}
}
else
{
formData.append("id", currentSavedBulkLoadProfileRecord.values.get("id"));
formData.append("label", currentSavedBulkLoadProfileRecord?.values.get("label"));
}
const recordList = await makeSavedBulkLoadProfileRequest("storeSavedBulkLoadProfile", formData);
await (async () =>
{
if (recordList && recordList.length > 0)
{
setSavedBulkLoadProfilesHaveLoaded(false);
setSavedSuccessMessage("Profile Saved.");
setTimeout(() => setSavedSuccessMessage(null), 2500);
if (allowSelectingProfile)
{
loadSavedBulkLoadProfiles();
handleSavedBulkLoadProfileRecordOnClick(recordList[0]);
}
else
{
if (bulkLoadProfileOnChangeCallback)
{
bulkLoadProfileOnChangeCallback(recordList[0]);
}
}
}
})();
}
setSavePopupOpen(false);
setSaveOptionsOpen(false);
}
catch (e: any)
{
let message = JSON.stringify(e);
if (typeof e == "string")
{
message = e;
}
else if (typeof e == "object" && e.message)
{
message = e.message;
}
setPopupAlertContent(message);
console.log(`Setting error: ${message}`);
}
finally
{
setIsSubmitting(false);
}
}
/*******************************************************************************
** stores the current dialog input text to state
*******************************************************************************/
const handleSaveDialogInputChange = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) =>
{
setSavedBulkLoadProfileNameInputValue(event.target.value);
};
/*******************************************************************************
** closes current dialog
*******************************************************************************/
const handleSavePopupClose = () =>
{
setSavePopupOpen(false);
};
/*******************************************************************************
** make a request to the backend for various savedBulkLoadProfile processes
*******************************************************************************/
async function makeSavedBulkLoadProfileRequest(processName: string, formData: FormData): Promise<QRecord[]>
{
/////////////////////////
// fetch saved records //
/////////////////////////
let savedBulkLoadProfiles = [] as QRecord[];
try
{
//////////////////////////////////////////////////////////////////
// we don't want this job to go async, so, pass a large timeout //
//////////////////////////////////////////////////////////////////
formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000);
const processResult = await qController.processInit(processName, formData, qController.defaultMultipartFormDataHeaders());
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
throw (jobError.error);
}
else
{
const result = processResult as QJobComplete;
if (result.values.savedBulkLoadProfileList)
{
for (let i = 0; i < result.values.savedBulkLoadProfileList.length; i++)
{
const qRecord = new QRecord(result.values.savedBulkLoadProfileList[i]);
savedBulkLoadProfiles.push(qRecord);
}
}
}
}
catch (e)
{
throw (e);
}
return (savedBulkLoadProfiles);
}
const hasStorePermission = metaData?.processes.has("storeSavedBulkLoadProfile");
const hasDeletePermission = metaData?.processes.has("deleteSavedBulkLoadProfile");
const hasQueryPermission = metaData?.processes.has("querySavedBulkLoadProfile");
const tooltipMaxWidth = (maxWidth: string) =>
{
return ({
slotProps: {
tooltip: {
sx: {
maxWidth: maxWidth
}
}
}
});
};
const menuTooltipAttribs = {...tooltipMaxWidth("250px"), placement: "left", enterDelay: 1000} as TooltipProps;
let disabledBecauseNotOwner = false;
let notOwnerTooltipText = null;
if (currentSavedBulkLoadProfileRecord && currentSavedBulkLoadProfileRecord.values.get("userId") != currentUserId)
{
disabledBecauseNotOwner = true;
notOwnerTooltipText = "You may not save changes to this bulk load profile, because you are not its owner.";
}
const menuWidth = "300px";
const renderSavedBulkLoadProfilesMenu = tableMetaData && (
<Menu
anchorEl={savedBulkLoadProfilesMenu}
anchorOrigin={{vertical: "bottom", horizontal: "left",}}
transformOrigin={{vertical: "top", horizontal: "left",}}
open={Boolean(savedBulkLoadProfilesMenu)}
onClose={closeSavedBulkLoadProfilesMenu}
keepMounted
PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minWidth: menuWidth}}}
>
{
<MenuItem sx={{width: menuWidth}} disabled style={{opacity: "initial"}}><b>Bulk Load Profile Actions</b></MenuItem>
}
{
!allowSelectingProfile &&
<MenuItem sx={{width: menuWidth}} disabled style={{opacity: "initial", whiteSpace: "wrap", display: "block"}}>
{
currentSavedBulkLoadProfileRecord ?
<span>You are using the bulk load profile:<br /><b style={{paddingLeft: "1rem"}}>{currentSavedBulkLoadProfileRecord.values.get("label")}</b><br /><br />You can manage this profile on this screen.</span>
: <span>You are not using a saved bulk load profile.<br /><br />You can save your profile on this screen.</span>
}
</MenuItem>
}
{
!allowSelectingProfile && <Divider />
}
{
hasStorePermission &&
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? <>Save your current mapping, for quick re-use at a later time.<br /><br />You will be prompted to enter a name if you choose this option.</>}>
<span>
<MenuItem disabled={disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>
<ListItemIcon><Icon>save</Icon></ListItemIcon>
{currentSavedBulkLoadProfileRecord ? "Save..." : "Save As..."}
</MenuItem>
</span>
</Tooltip>
}
{
hasStorePermission && currentSavedBulkLoadProfileRecord != null &&
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Change the name for this saved bulk load profile."}>
<span>
<MenuItem disabled={currentSavedBulkLoadProfileRecord === null || disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(RENAME_OPTION)}>
<ListItemIcon><Icon>edit</Icon></ListItemIcon>
Rename...
</MenuItem>
</span>
</Tooltip>
}
{
hasStorePermission && currentSavedBulkLoadProfileRecord != null &&
<Tooltip {...menuTooltipAttribs} title="Save a new copy this bulk load profile, with a different name, separate from the original.">
<span>
<MenuItem disabled={currentSavedBulkLoadProfileRecord === null} onClick={() => handleDropdownOptionClick(DUPLICATE_OPTION)}>
<ListItemIcon><Icon>content_copy</Icon></ListItemIcon>
Save As...
</MenuItem>
</span>
</Tooltip>
}
{
hasDeletePermission && currentSavedBulkLoadProfileRecord != null &&
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Delete this saved bulk load profile."}>
<span>
<MenuItem disabled={currentSavedBulkLoadProfileRecord === null || disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(DELETE_OPTION)}>
<ListItemIcon><Icon>delete</Icon></ListItemIcon>
Delete...
</MenuItem>
</span>
</Tooltip>
}
{
allowSelectingProfile &&
<Tooltip {...menuTooltipAttribs} title="Create a new blank bulk load profile for this table, removing all mappings.">
<span>
<MenuItem onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>
<ListItemIcon><Icon>monitor</Icon></ListItemIcon>
New Bulk Load Profile
</MenuItem>
</span>
</Tooltip>
}
{
allowSelectingProfile &&
<Box>
{
<Divider />
}
<MenuItem disabled style={{"opacity": "initial"}}><b>Your Saved Bulk Load Profiles</b></MenuItem>
{
yourSavedBulkLoadProfiles && yourSavedBulkLoadProfiles.length > 0 ? (
yourSavedBulkLoadProfiles.map((record: QRecord, index: number) =>
<MenuItem sx={{paddingLeft: "50px"}} key={`savedFiler-${index}`} onClick={() => handleSavedBulkLoadProfileRecordOnClick(record)}>
{record.values.get("label")}
</MenuItem>
)
) : (
<MenuItem disabled sx={{opacity: "1 !important"}}>
<i>You do not have any saved bulk load profiles for this table.</i>
</MenuItem>
)
}
<MenuItem disabled style={{"opacity": "initial"}}><b>Bulk Load Profiles Shared with you</b></MenuItem>
{
bulkLoadProfilesSharedWithYou && bulkLoadProfilesSharedWithYou.length > 0 ? (
bulkLoadProfilesSharedWithYou.map((record: QRecord, index: number) =>
<MenuItem sx={{paddingLeft: "50px"}} key={`savedFiler-${index}`} onClick={() => handleSavedBulkLoadProfileRecordOnClick(record)}>
{record.values.get("label")}
</MenuItem>
)
) : (
<MenuItem disabled sx={{opacity: "1 !important"}}>
<i>You do not have any bulk load profiles shared with you for this table.</i>
</MenuItem>
)
}
</Box>
}
</Menu>
);
let buttonText = "Saved Bulk Load Profiles";
let buttonBackground = "none";
let buttonBorder = colors.grayLines.main;
let buttonColor = colors.gray.main;
if (currentSavedBulkLoadProfileRecord)
{
if (bulkLoadProfileIsModified)
{
buttonBackground = accentColorLight;
buttonBorder = buttonBackground;
buttonColor = accentColor;
}
else
{
buttonBackground = accentColor;
buttonBorder = buttonBackground;
buttonColor = "#FFFFFF";
}
}
const buttonStyles = {
border: `1px solid ${buttonBorder}`,
backgroundColor: buttonBackground,
color: buttonColor,
"&:focus:not(:hover)": {
color: buttonColor,
backgroundColor: buttonBackground,
},
"&:hover": {
color: buttonColor,
backgroundColor: buttonBackground,
}
};
/*******************************************************************************
**
*******************************************************************************/
function isSaveButtonDisabled(): boolean
{
if (isSubmitting)
{
return (true);
}
const haveInputText = (savedBulkLoadProfileNameInputValue != null && savedBulkLoadProfileNameInputValue.trim() != "");
if (isSaveAsAction || isRenameAction || currentSavedBulkLoadProfileRecord == null)
{
if (!haveInputText)
{
return (true);
}
}
return (false);
}
const linkButtonStyle = {
minWidth: "unset",
textTransform: "none",
fontSize: "0.875rem",
fontWeight: "500",
padding: "0.5rem"
};
return (
hasQueryPermission && tableMetaData ? (
<>
<Box order="1" mr={"0.5rem"}>
<Button
onClick={openSavedBulkLoadProfilesMenu}
sx={{
borderRadius: "0.75rem",
textTransform: "none",
fontWeight: 500,
fontSize: "0.875rem",
p: "0.5rem",
...buttonStyles
}}
>
<Icon sx={{mr: "0.5rem"}}>save</Icon>
{buttonText}
<Icon sx={{ml: "0.5rem"}}>keyboard_arrow_down</Icon>
</Button>
{renderSavedBulkLoadProfilesMenu}
</Box>
<Box order="3" display="flex" justifyContent="center" flexDirection="column">
<Box pl={2} pr={2} fontSize="0.875rem" sx={{display: "flex", alignItems: "center"}}>
{
savedSuccessMessage && <Box color={colors.success.main}>{savedSuccessMessage}</Box>
}
{
savedFailedMessage && <Box color={colors.error.main}>{savedFailedMessage}</Box>
}
{
!currentSavedBulkLoadProfileRecord /*&& bulkLoadProfileIsModified*/ && <>
{
<>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Unsaved Mapping</b>
<ul style={{padding: "0.5rem 1rem"}}>
<li>You are not using a saved bulk load profile.</li>
{
/*bulkLoadProfileDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)*/
}
</ul>
</>}>
<Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save Bulk Load Profile As&hellip;</Button>
</Tooltip>
{/* vertical rule */}
{allowSelectingProfile && <Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />}
</>
}
{/* for the no-profile use-case, don't give a reset-link on screens other than the first (file mapping) one - which is tied to the allowSelectingProfile attribute */}
{allowSelectingProfile && <>
<Box pl="0.5rem">Reset to:</Box>
<Button disableRipple={true} sx={{color: colors.gray.main, ...linkButtonStyle}} onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>Empty Mapping</Button>
<Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
<Button disableRipple={true} sx={{color: colors.gray.main, ...linkButtonStyle}} onClick={() => handleDropdownOptionClick(RESET_TO_SUGGESTION)}>Suggested Mapping</Button>
</>}
</>
}
{
currentSavedBulkLoadProfileRecord && bulkLoadProfileIsModified && <>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Unsaved Changes</b>
<ul style={{padding: "0.5rem 1rem"}}>
{
bulkLoadProfileDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
}
</ul>
{
notOwnerTooltipText && <i>{notOwnerTooltipText}</i>
}
</>}>
<Box display="inline" sx={{...linkButtonStyle, p: 0, cursor: "default", position: "relative", top: "-1px"}}>{bulkLoadProfileDiffs.length} Unsaved Change{bulkLoadProfileDiffs.length == 1 ? "" : "s"}</Box>
</Tooltip>
{disabledBecauseNotOwner ? <>&nbsp;&nbsp;</> : <Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save&hellip;</Button>}
{/* vertical rule */}
{/* also, don't give a reset-link on screens other than the first (file mapping) one - which is tied to the allowSelectingProfile attribute */}
{/* partly because it isn't correctly resetting the values, but also because, it's a litle unclear that what, it would reset changes from other screens too?? */}
{
allowSelectingProfile && <>
<Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
<Button disableRipple={true} sx={{color: colors.gray.main, ...linkButtonStyle}} onClick={() => handleSavedBulkLoadProfileRecordOnClick(currentSavedBulkLoadProfileRecord)}>Reset All Changes</Button>
</>
}
</>
}
</Box>
</Box>
{
<Dialog
open={savePopupOpen}
onClose={handleSavePopupClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
onKeyPress={(e) =>
{
////////////////////////////////////////////////////
// make user actually hit delete button //
// but for other modes, let Enter submit the form //
////////////////////////////////////////////////////
if (e.key == "Enter" && !isDeleteAction)
{
handleDialogButtonOnClick();
}
}}
>
{
currentSavedBulkLoadProfileRecord ? (
isDeleteAction ? (
<DialogTitle id="alert-dialog-title">Delete Bulk Load Profile</DialogTitle>
) : (
isSaveAsAction ? (
<DialogTitle id="alert-dialog-title">Save Bulk Load Profile As</DialogTitle>
) : (
isRenameAction ? (
<DialogTitle id="alert-dialog-title">Rename Bulk Load Profile</DialogTitle>
) : (
<DialogTitle id="alert-dialog-title">Update Existing Bulk Load Profile</DialogTitle>
)
)
)
) : (
<DialogTitle id="alert-dialog-title">Save New Bulk Load Profile</DialogTitle>
)
}
<DialogContent sx={{width: "500px"}}>
{popupAlertContent ? (
<Box mb={1}>
<Alert severity="error" onClose={() => setPopupAlertContent("")}>{popupAlertContent}</Alert>
</Box>
) : ("")}
{
(!currentSavedBulkLoadProfileRecord || isSaveAsAction || isRenameAction) && !isDeleteAction ? (
<Box>
{
isSaveAsAction ? (
<Box mb={3}>Enter a name for this new saved bulk load profile.</Box>
) : (
<Box mb={3}>Enter a new name for this saved bulk load profile.</Box>
)
}
<TextField
autoFocus
name="custom-delimiter-value"
placeholder="Bulk Load Profile Name"
inputProps={{width: "100%", maxLength: 100}}
value={savedBulkLoadProfileNameInputValue}
sx={{width: "100%"}}
onChange={handleSaveDialogInputChange}
onFocus={event =>
{
event.target.select();
}}
/>
</Box>
) : (
isDeleteAction ? (
<Box>Are you sure you want to delete the bulk load profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}?</Box>
) : (
<Box>Are you sure you want to update the bulk load profile {`'${currentSavedBulkLoadProfileRecord?.values.get("label")}'`}?</Box>
)
)
}
</DialogContent>
<DialogActions>
<QCancelButton onClickHandler={handleSavePopupClose} disabled={false} />
{
isDeleteAction ?
<QDeleteButton onClickHandler={handleDialogButtonOnClick} disabled={isSubmitting} />
:
<QSaveButton label="Save" onClickHandler={handleDialogButtonOnClick} disabled={isSaveButtonDisabled()} />
}
</DialogActions>
</Dialog>
}
</>
) : null
);
}
export default SavedBulkLoadProfiles;

View File

@ -0,0 +1,511 @@
/*
* 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 {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete";
import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {FiberManualRecord} from "@mui/icons-material";
import {Alert} from "@mui/material";
import Box from "@mui/material/Box";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Divider from "@mui/material/Divider";
import Icon from "@mui/material/Icon";
import ListItemIcon from "@mui/material/ListItemIcon";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import Typography from "@mui/material/Typography";
import {GridFilterModel, GridSortItem} from "@mui/x-data-grid-pro";
import FormData from "form-data";
import React, {useEffect, useRef, useState} from "react";
import {useLocation, useNavigate} from "react-router-dom";
import {QCancelButton, QDeleteButton, QSaveButton, QSavedFiltersMenuButton} from "qqq/components/buttons/DefaultButtons";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
interface Props
{
qController: QController;
metaData: QInstance;
tableMetaData: QTableMetaData;
currentSavedFilter: QRecord;
filterModel?: GridFilterModel;
columnSortModel?: GridSortItem[];
filterOnChangeCallback?: (selectedSavedFilterId: number) => void;
}
function SavedFilters({qController, metaData, tableMetaData, currentSavedFilter, filterModel, columnSortModel, filterOnChangeCallback}: Props): JSX.Element
{
const navigate = useNavigate();
const [savedFilters, setSavedFilters] = useState([] as QRecord[]);
const [savedFiltersMenu, setSavedFiltersMenu] = useState(null);
const [savedFiltersHaveLoaded, setSavedFiltersHaveLoaded] = useState(false);
const [filterIsModified, setFilterIsModified] = useState(false);
const [saveFilterPopupOpen, setSaveFilterPopupOpen] = useState(false);
const [isSaveFilterAs, setIsSaveFilterAs] = useState(false);
const [isRenameFilter, setIsRenameFilter] = useState(false);
const [isDeleteFilter, setIsDeleteFilter] = useState(false);
const [savedFilterNameInputValue, setSavedFilterNameInputValue] = useState(null as string);
const [popupAlertContent, setPopupAlertContent] = useState("");
const anchorRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const [saveOptionsOpen, setSaveOptionsOpen] = useState(false);
const SAVE_OPTION = "Save...";
const DUPLICATE_OPTION = "Duplicate...";
const RENAME_OPTION = "Rename...";
const DELETE_OPTION = "Delete...";
const CLEAR_OPTION = "Clear Current Filter";
const dropdownOptions = [DUPLICATE_OPTION, RENAME_OPTION, DELETE_OPTION, CLEAR_OPTION];
const openSavedFiltersMenu = (event: any) => setSavedFiltersMenu(event.currentTarget);
const closeSavedFiltersMenu = () => setSavedFiltersMenu(null);
//////////////////////////////////////////////////////////////////////////
// load filters on first run, then monitor location or metadata changes //
//////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
loadSavedFilters()
.then(() =>
{
if (currentSavedFilter != null)
{
let qFilter = FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel);
setFilterIsModified(JSON.stringify(qFilter) !== currentSavedFilter.values.get("filterJson"));
}
setSavedFiltersHaveLoaded(true);
});
}, [location , tableMetaData, currentSavedFilter, filterModel, columnSortModel])
/*******************************************************************************
** make request to load all saved filters from backend
*******************************************************************************/
async function loadSavedFilters()
{
if (! tableMetaData)
{
return;
}
const formData = new FormData();
formData.append("tableName", tableMetaData.name);
let savedFilters = await makeSavedFilterRequest("querySavedFilter", formData);
setSavedFilters(savedFilters);
}
/*******************************************************************************
** fired when a saved record is clicked from the dropdown
*******************************************************************************/
const handleSavedFilterRecordOnClick = async (record: QRecord) =>
{
setSaveFilterPopupOpen(false);
closeSavedFiltersMenu();
filterOnChangeCallback(record.values.get("id"));
navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedFilter/${record.values.get("id")}`);
};
/*******************************************************************************
** fired when a save option is selected from the save... button/dropdown combo
*******************************************************************************/
const handleDropdownOptionClick = (optionName: string) =>
{
setSaveOptionsOpen(false);
setPopupAlertContent(null);
closeSavedFiltersMenu();
setSaveFilterPopupOpen(true);
setIsSaveFilterAs(false);
setIsRenameFilter(false);
setIsDeleteFilter(false)
switch(optionName)
{
case SAVE_OPTION:
break;
case DUPLICATE_OPTION:
setIsSaveFilterAs(true);
break;
case CLEAR_OPTION:
setSaveFilterPopupOpen(false)
filterOnChangeCallback(null);
navigate(metaData.getTablePathByName(tableMetaData.name));
break;
case RENAME_OPTION:
if(currentSavedFilter != null)
{
setSavedFilterNameInputValue(currentSavedFilter.values.get("label"));
}
setIsRenameFilter(true);
break;
case DELETE_OPTION:
setIsDeleteFilter(true)
break;
}
}
/*******************************************************************************
** fired when save or delete button saved on confirmation dialogs
*******************************************************************************/
async function handleFilterDialogButtonOnClick()
{
try
{
const formData = new FormData();
if (isDeleteFilter)
{
formData.append("id", currentSavedFilter.values.get("id"));
await makeSavedFilterRequest("deleteSavedFilter", formData);
await(async() =>
{
handleDropdownOptionClick(CLEAR_OPTION);
})();
}
else
{
formData.append("tableName", tableMetaData.name);
formData.append("filterJson", JSON.stringify(FilterUtils.convertFilterPossibleValuesToIds(FilterUtils.buildQFilterFromGridFilter(tableMetaData, filterModel, columnSortModel))));
if (isSaveFilterAs || isRenameFilter || currentSavedFilter == null)
{
formData.append("label", savedFilterNameInputValue);
if(currentSavedFilter != null && isRenameFilter)
{
formData.append("id", currentSavedFilter.values.get("id"));
}
}
else
{
formData.append("id", currentSavedFilter.values.get("id"));
formData.append("label", currentSavedFilter?.values.get("label"));
}
const recordList = await makeSavedFilterRequest("storeSavedFilter", formData);
await(async() =>
{
if (recordList && recordList.length > 0)
{
setSavedFiltersHaveLoaded(false);
loadSavedFilters();
handleSavedFilterRecordOnClick(recordList[0]);
}
})();
}
}
catch (e: any)
{
setPopupAlertContent(JSON.stringify(e.message));
}
}
/*******************************************************************************
** hides/shows the save options
*******************************************************************************/
const handleToggleSaveOptions = () =>
{
setSaveOptionsOpen((prevOpen) => !prevOpen);
};
/*******************************************************************************
** closes save options menu (on clickaway)
*******************************************************************************/
const handleSaveOptionsMenuClose = (event: Event) =>
{
if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement))
{
return;
}
setSaveOptionsOpen(false);
};
/*******************************************************************************
** stores the current dialog input text to state
*******************************************************************************/
const handleSaveFilterInputChange = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) =>
{
setSavedFilterNameInputValue(event.target.value);
};
/*******************************************************************************
** closes current dialog
*******************************************************************************/
const handleSaveFilterPopupClose = () =>
{
setSaveFilterPopupOpen(false);
};
/*******************************************************************************
** make a request to the backend for various savedFilter processes
*******************************************************************************/
async function makeSavedFilterRequest(processName: string, formData: FormData): Promise<QRecord[]>
{
/////////////////////////
// fetch saved filters //
/////////////////////////
let savedFilters = [] as QRecord[]
try
{
//////////////////////////////////////////////////////////////////
// we don't want this job to go async, so, pass a large timeout //
//////////////////////////////////////////////////////////////////
formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000);
const processResult = await qController.processInit(processName, formData, qController.defaultMultipartFormDataHeaders());
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
throw(jobError.error);
}
else
{
const result = processResult as QJobComplete;
if(result.values.savedFilterList)
{
for (let i = 0; i < result.values.savedFilterList.length; i++)
{
const qRecord = new QRecord(result.values.savedFilterList[i]);
savedFilters.push(qRecord);
}
}
}
}
catch (e)
{
throw(e);
}
return (savedFilters);
}
const hasStorePermission = metaData?.processes.has("storeSavedFilter");
const hasDeletePermission = metaData?.processes.has("deleteSavedFilter");
const hasQueryPermission = metaData?.processes.has("querySavedFilter");
const renderSavedFiltersMenu = tableMetaData && (
<Menu
anchorEl={savedFiltersMenu}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
transformOrigin={{
vertical: "top",
horizontal: "left",
}}
open={Boolean(savedFiltersMenu)}
onClose={closeSavedFiltersMenu}
keepMounted
>
<MenuItem sx={{width: "300px"}} disabled style={{"opacity": "initial"}}><b>Filter Actions</b></MenuItem>
{
hasStorePermission &&
<MenuItem onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>
<ListItemIcon><Icon>save</Icon></ListItemIcon>
Save...
</MenuItem>
}
{
hasStorePermission &&
<MenuItem disabled={currentSavedFilter === null} onClick={() => handleDropdownOptionClick(RENAME_OPTION)}>
<ListItemIcon><Icon>edit</Icon></ListItemIcon>
Rename...
</MenuItem>
}
{
hasStorePermission &&
<MenuItem disabled={currentSavedFilter === null} onClick={() => handleDropdownOptionClick(DUPLICATE_OPTION)}>
<ListItemIcon><Icon>content_copy</Icon></ListItemIcon>
Duplicate...
</MenuItem>
}
{
hasDeletePermission &&
<MenuItem disabled={currentSavedFilter === null} onClick={() => handleDropdownOptionClick(DELETE_OPTION)}>
<ListItemIcon><Icon>delete</Icon></ListItemIcon>
Delete...
</MenuItem>
}
{
<MenuItem disabled={currentSavedFilter === null} onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>
<ListItemIcon><Icon>clear</Icon></ListItemIcon>
Clear Current Filter
</MenuItem>
}
<Divider/>
<MenuItem disabled style={{"opacity": "initial"}}><b>Your Filters</b></MenuItem>
{
savedFilters && savedFilters.length > 0 ? (
savedFilters.map((record: QRecord, index: number) =>
<MenuItem sx={{paddingLeft: "50px"}} key={`savedFiler-${index}`} onClick={() => handleSavedFilterRecordOnClick(record)}>
{record.values.get("label")}
</MenuItem>
)
): (
<MenuItem >
<i>No filters have been saved for this table.</i>
</MenuItem>
)
}
</Menu>
);
return (
hasQueryPermission && tableMetaData ? (
<Box display="flex" flexGrow={1}>
<QSavedFiltersMenuButton isOpen={savedFiltersMenu} onClickHandler={openSavedFiltersMenu} />
{renderSavedFiltersMenu}
<Box display="flex" justifyContent="center" flexDirection="column">
<Box pl={2} pr={2} sx={{display: "flex", alignItems: "center"}}>
{
savedFiltersHaveLoaded && currentSavedFilter && (
<Typography mr={2} variant="h6">Current Filter:&nbsp;
<span style={{fontWeight: "initial"}}>
{currentSavedFilter.values.get("label")}
{
filterIsModified && (
<Tooltip sx={{cursor: "pointer"}} title={"The current filter has been modified. Click \"Save...\" to save the changes."}>
<FiberManualRecord sx={{color: "orange", paddingLeft: "2px", paddingTop: "4px"}} />
</Tooltip>
)
}
</span>
</Typography>
)
}
</Box>
</Box>
{
<Dialog
open={saveFilterPopupOpen}
onClose={handleSaveFilterPopupClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
onKeyPress={(e) =>
{
if (e.key == "Enter")
{
handleFilterDialogButtonOnClick();
}
}}
>
{
currentSavedFilter ? (
isDeleteFilter ? (
<DialogTitle id="alert-dialog-title">Delete Filter</DialogTitle>
) : (
isSaveFilterAs ? (
<DialogTitle id="alert-dialog-title">Save Filter As</DialogTitle>
):(
isRenameFilter ? (
<DialogTitle id="alert-dialog-title">Rename Filter</DialogTitle>
):(
<DialogTitle id="alert-dialog-title">Update Existing Filter</DialogTitle>
)
)
)
):(
<DialogTitle id="alert-dialog-title">Save New Filter</DialogTitle>
)
}
<DialogContent sx={{width: "500px"}}>
{
(! currentSavedFilter || isSaveFilterAs || isRenameFilter) && ! isDeleteFilter ? (
<Box>
{
isSaveFilterAs ? (
<Box mb={3}>Enter a name for this new saved filter.</Box>
):(
<Box mb={3}>Enter a new name for this saved filter.</Box>
)
}
<TextField
autoFocus
name="custom-delimiter-value"
placeholder="Filter Name"
label="Filter Name"
inputProps={{width: "100%", maxLength: 100}}
value={savedFilterNameInputValue}
sx={{width: "100%"}}
onChange={handleSaveFilterInputChange}
onFocus={event =>
{
event.target.select();
}}
/>
</Box>
):(
isDeleteFilter ? (
<Box>Are you sure you want to delete the filter {`'${currentSavedFilter?.values.get("label")}'`}?</Box>
):(
<Box>Are you sure you want to update the filter {`'${currentSavedFilter?.values.get("label")}'`} with the current filter criteria?</Box>
)
)
}
{popupAlertContent ? (
<Box m={1}>
<Alert severity="error">{popupAlertContent}</Alert>
</Box>
) : ("")}
</DialogContent>
<DialogActions>
<QCancelButton onClickHandler={handleSaveFilterPopupClose} disabled={false} />
{
isDeleteFilter ?
<QDeleteButton onClickHandler={handleFilterDialogButtonOnClick} />
:
<QSaveButton label="Save" onClickHandler={handleFilterDialogButtonOnClick} disabled={(isSaveFilterAs || currentSavedFilter == null) && savedFilterNameInputValue == null}/>
}
</DialogActions>
</Dialog>
}
</Box>
) : null
);
}
export default SavedFilters;

View File

@ -1,824 +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 {QController} from "@kingsrook/qqq-frontend-core/lib/controllers/QController";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete";
import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Alert, Button} from "@mui/material";
import Box from "@mui/material/Box";
import Dialog from "@mui/material/Dialog";
import DialogActions from "@mui/material/DialogActions";
import DialogContent from "@mui/material/DialogContent";
import DialogTitle from "@mui/material/DialogTitle";
import Divider from "@mui/material/Divider";
import Icon from "@mui/material/Icon";
import ListItemIcon from "@mui/material/ListItemIcon";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import {TooltipProps} from "@mui/material/Tooltip/Tooltip";
import FormData from "form-data";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QDeleteButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import RecordQueryView from "qqq/models/query/RecordQueryView";
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import {SavedViewUtils} from "qqq/utils/qqq/SavedViewUtils";
import React, {useContext, useEffect, useRef, useState} from "react";
import {useLocation, useNavigate} from "react-router-dom";
interface Props
{
qController: QController;
metaData: QInstance;
tableMetaData: QTableMetaData;
currentSavedView: QRecord;
tableDefaultView: RecordQueryView;
view?: RecordQueryView;
viewAsJson?: string;
viewOnChangeCallback?: (selectedSavedViewId: number) => void;
loadingSavedView: boolean;
queryScreenUsage: QueryScreenUsage;
}
function SavedViews({qController, metaData, tableMetaData, currentSavedView, tableDefaultView, view, viewAsJson, viewOnChangeCallback, loadingSavedView, queryScreenUsage}: Props): JSX.Element
{
const navigate = useNavigate();
const [savedViews, setSavedViews] = useState([] as QRecord[]);
const [yourSavedViews, setYourSavedViews] = useState([] as QRecord[]);
const [viewsSharedWithYou, setViewsSharedWithYou] = useState([] as QRecord[]);
const [savedViewsMenu, setSavedViewsMenu] = useState(null);
const [savedViewsHaveLoaded, setSavedViewsHaveLoaded] = useState(false);
const [isSubmitting, setIsSubmitting] = useState(false);
const [saveFilterPopupOpen, setSaveFilterPopupOpen] = useState(false);
const [isSaveFilterAs, setIsSaveFilterAs] = useState(false);
const [isRenameFilter, setIsRenameFilter] = useState(false);
const [isDeleteFilter, setIsDeleteFilter] = useState(false);
const [savedViewNameInputValue, setSavedViewNameInputValue] = useState(null as string);
const [popupAlertContent, setPopupAlertContent] = useState("");
const anchorRef = useRef<HTMLDivElement>(null);
const location = useLocation();
const [saveOptionsOpen, setSaveOptionsOpen] = useState(false);
const SAVE_OPTION = "Save...";
const DUPLICATE_OPTION = "Duplicate...";
const RENAME_OPTION = "Rename...";
const DELETE_OPTION = "Delete...";
const CLEAR_OPTION = "New View";
const NEW_REPORT_OPTION = "Create Report from Current View";
const {accentColor, accentColorLight, userId: currentUserId} = useContext(QContext);
/////////////////////////////////////////////////////////////////////////////////////////////
// this component is used by <RecordQuery> - but that component has different usages - //
// e.g., the full-fledged query screen, but also, within other screens (e.g., a modal //
// under the FilterAndColumnsSetupWidget). So, there are some behaviors we only want when //
// we're on the full-fledged query screen, such as changing the URL with saved view ids. //
/////////////////////////////////////////////////////////////////////////////////////////////
const isQueryScreen = queryScreenUsage == "queryScreen";
const openSavedViewsMenu = (event: any) => setSavedViewsMenu(event.currentTarget);
const closeSavedViewsMenu = () => setSavedViewsMenu(null);
//////////////////////////////////////////////////////////////////////////
// load filters on first run, then monitor location or metadata changes //
//////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
loadSavedViews()
.then(() =>
{
setSavedViewsHaveLoaded(true);
});
}, [location, tableMetaData]);
const baseView = currentSavedView ? JSON.parse(currentSavedView.values.get("viewJson")) as RecordQueryView : tableDefaultView;
const viewDiffs = SavedViewUtils.diffViews(tableMetaData, baseView, view);
let viewIsModified = false;
if (viewDiffs.length > 0)
{
viewIsModified = true;
}
/*******************************************************************************
** make request to load all saved filters from backend
*******************************************************************************/
async function loadSavedViews()
{
if (!tableMetaData)
{
return;
}
const formData = new FormData();
formData.append("tableName", tableMetaData.name);
let savedViews = await makeSavedViewRequest("querySavedView", formData);
setSavedViews(savedViews);
const yourSavedViews: QRecord[] = [];
const viewsSharedWithYou: QRecord[] = [];
for (let i = 0; i < savedViews.length; i++)
{
const record = savedViews[i];
if (record.values.get("userId") == currentUserId)
{
yourSavedViews.push(record);
}
else
{
viewsSharedWithYou.push(record);
}
}
setYourSavedViews(yourSavedViews);
setViewsSharedWithYou(viewsSharedWithYou);
}
/*******************************************************************************
** fired when a saved record is clicked from the dropdown
*******************************************************************************/
const handleSavedViewRecordOnClick = async (record: QRecord) =>
{
setSaveFilterPopupOpen(false);
closeSavedViewsMenu();
viewOnChangeCallback(record.values.get("id"));
if (isQueryScreen)
{
navigate(`${metaData.getTablePathByName(tableMetaData.name)}/savedView/${record.values.get("id")}`);
}
};
/*******************************************************************************
** fired when a save option is selected from the save... button/dropdown combo
*******************************************************************************/
const handleDropdownOptionClick = (optionName: string) =>
{
setSaveOptionsOpen(false);
setPopupAlertContent("");
closeSavedViewsMenu();
setSaveFilterPopupOpen(true);
setIsSaveFilterAs(false);
setIsRenameFilter(false);
setIsDeleteFilter(false);
switch (optionName)
{
case SAVE_OPTION:
if (currentSavedView == null)
{
setSavedViewNameInputValue("");
}
break;
case DUPLICATE_OPTION:
setSavedViewNameInputValue("");
setIsSaveFilterAs(true);
break;
case CLEAR_OPTION:
setSaveFilterPopupOpen(false);
viewOnChangeCallback(null);
if (isQueryScreen)
{
navigate(metaData.getTablePathByName(tableMetaData.name));
}
break;
case RENAME_OPTION:
if (currentSavedView != null)
{
setSavedViewNameInputValue(currentSavedView.values.get("label"));
}
setIsRenameFilter(true);
break;
case DELETE_OPTION:
setIsDeleteFilter(true);
break;
case NEW_REPORT_OPTION:
createNewReport();
break;
}
};
/*******************************************************************************
**
*******************************************************************************/
function createNewReport()
{
const defaultValues: { [key: string]: any } = {};
defaultValues.tableName = tableMetaData.name;
let filterForBackend = JSON.parse(JSON.stringify(view.queryFilter));
filterForBackend = FilterUtils.prepQueryFilterForBackend(tableMetaData, filterForBackend);
defaultValues.queryFilterJson = JSON.stringify(filterForBackend);
defaultValues.columnsJson = JSON.stringify(view.queryColumns);
navigate(`${metaData.getTablePathByName("savedReport")}/create#defaultValues=${encodeURIComponent(JSON.stringify(defaultValues))}`);
}
/*******************************************************************************
** fired when save or delete button saved on confirmation dialogs
*******************************************************************************/
async function handleFilterDialogButtonOnClick()
{
try
{
setPopupAlertContent("");
setIsSubmitting(true);
const formData = new FormData();
if (isDeleteFilter)
{
formData.append("id", currentSavedView.values.get("id"));
await makeSavedViewRequest("deleteSavedView", formData);
setSaveFilterPopupOpen(false);
setSaveOptionsOpen(false);
await (async () =>
{
handleDropdownOptionClick(CLEAR_OPTION);
})();
}
else
{
formData.append("tableName", tableMetaData.name);
/////////////////////////////////////////////////////////////////////////////////////////////////
// clone view via json serialization/deserialization //
// then replace the viewJson in it with a copy that has had its possible values changed to ids //
// then stringify that for the backend //
/////////////////////////////////////////////////////////////////////////////////////////////////
const viewObject = JSON.parse(JSON.stringify(view));
viewObject.queryFilter = JSON.parse(JSON.stringify(FilterUtils.convertFilterPossibleValuesToIds(viewObject.queryFilter)));
////////////////////////////////////////////////////////////////////////////
// strip away incomplete filters too, just for cleaner saved view filters //
////////////////////////////////////////////////////////////////////////////
FilterUtils.stripAwayIncompleteCriteria(viewObject.queryFilter);
formData.append("viewJson", JSON.stringify(viewObject));
if (isSaveFilterAs || isRenameFilter || currentSavedView == null)
{
formData.append("label", savedViewNameInputValue);
if (currentSavedView != null && isRenameFilter)
{
formData.append("id", currentSavedView.values.get("id"));
}
}
else
{
formData.append("id", currentSavedView.values.get("id"));
formData.append("label", currentSavedView?.values.get("label"));
}
const recordList = await makeSavedViewRequest("storeSavedView", formData);
await (async () =>
{
if (recordList && recordList.length > 0)
{
setSavedViewsHaveLoaded(false);
loadSavedViews();
handleSavedViewRecordOnClick(recordList[0]);
}
})();
}
setSaveFilterPopupOpen(false);
setSaveOptionsOpen(false);
}
catch (e: any)
{
let message = JSON.stringify(e);
if (typeof e == "string")
{
message = e;
}
else if (typeof e == "object" && e.message)
{
message = e.message;
}
setPopupAlertContent(message);
console.log(`Setting error: ${message}`);
}
finally
{
setIsSubmitting(false);
}
}
/*******************************************************************************
** hides/shows the save options
*******************************************************************************/
const handleToggleSaveOptions = () =>
{
setSaveOptionsOpen((prevOpen) => !prevOpen);
};
/*******************************************************************************
** closes save options menu (on clickaway)
*******************************************************************************/
const handleSaveOptionsMenuClose = (event: Event) =>
{
if (anchorRef.current && anchorRef.current.contains(event.target as HTMLElement))
{
return;
}
setSaveOptionsOpen(false);
};
/*******************************************************************************
** stores the current dialog input text to state
*******************************************************************************/
const handleSaveFilterInputChange = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) =>
{
setSavedViewNameInputValue(event.target.value);
};
/*******************************************************************************
** closes current dialog
*******************************************************************************/
const handleSaveFilterPopupClose = () =>
{
setSaveFilterPopupOpen(false);
};
/*******************************************************************************
** make a request to the backend for various savedView processes
*******************************************************************************/
async function makeSavedViewRequest(processName: string, formData: FormData): Promise<QRecord[]>
{
/////////////////////////
// fetch saved filters //
/////////////////////////
let savedViews = [] as QRecord[];
try
{
//////////////////////////////////////////////////////////////////
// we don't want this job to go async, so, pass a large timeout //
//////////////////////////////////////////////////////////////////
formData.append(QController.STEP_TIMEOUT_MILLIS_PARAM_NAME, 60 * 1000);
const processResult = await qController.processInit(processName, formData, qController.defaultMultipartFormDataHeaders());
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
throw (jobError.error);
}
else
{
const result = processResult as QJobComplete;
if (result.values.savedViewList)
{
for (let i = 0; i < result.values.savedViewList.length; i++)
{
const qRecord = new QRecord(result.values.savedViewList[i]);
savedViews.push(qRecord);
}
}
}
}
catch (e)
{
throw (e);
}
return (savedViews);
}
const hasStorePermission = metaData?.processes.has("storeSavedView");
const hasDeletePermission = metaData?.processes.has("deleteSavedView");
const hasQueryPermission = metaData?.processes.has("querySavedView");
const hasSavedReportsPermission = metaData?.tables.has("savedReport");
const tooltipMaxWidth = (maxWidth: string) =>
{
return ({
slotProps: {
tooltip: {
sx: {
maxWidth: maxWidth
}
}
}
});
};
const menuTooltipAttribs = {...tooltipMaxWidth("250px"), placement: "left", enterDelay: 1000} as TooltipProps;
let disabledBecauseNotOwner = false;
let notOwnerTooltipText = null;
if (currentSavedView && currentSavedView.values.get("userId") != currentUserId)
{
disabledBecauseNotOwner = true;
notOwnerTooltipText = "You may not save changes to this view, because you are not its owner.";
}
const renderSavedViewsMenu = tableMetaData && (
<Menu
anchorEl={savedViewsMenu}
anchorOrigin={{vertical: "bottom", horizontal: "left",}}
transformOrigin={{vertical: "top", horizontal: "left",}}
open={Boolean(savedViewsMenu)}
onClose={closeSavedViewsMenu}
keepMounted
PaperProps={{style: {maxHeight: "calc(100vh - 200px)", minWidth: "300px"}}}
>
{
isQueryScreen &&
<MenuItem sx={{width: "300px"}} disabled style={{"opacity": "initial"}}><b>View Actions</b></MenuItem>
}
{
isQueryScreen && hasStorePermission &&
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? <>Save your current filters, columns and settings, for quick re-use at a later time.<br /><br />You will be prompted to enter a name if you choose this option.</>}>
<span>
<MenuItem disabled={disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>
<ListItemIcon><Icon>save</Icon></ListItemIcon>
{currentSavedView ? "Save..." : "Save As..."}
</MenuItem>
</span>
</Tooltip>
}
{
isQueryScreen && hasStorePermission && currentSavedView != null &&
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Change the name for this saved view."}>
<span>
<MenuItem disabled={currentSavedView === null || disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(RENAME_OPTION)}>
<ListItemIcon><Icon>edit</Icon></ListItemIcon>
Rename...
</MenuItem>
</span>
</Tooltip>
}
{
isQueryScreen && hasStorePermission && currentSavedView != null &&
<Tooltip {...menuTooltipAttribs} title="Save a new copy this view, with a different name, separate from the original.">
<span>
<MenuItem disabled={currentSavedView === null} onClick={() => handleDropdownOptionClick(DUPLICATE_OPTION)}>
<ListItemIcon><Icon>content_copy</Icon></ListItemIcon>
Save As...
</MenuItem>
</span>
</Tooltip>
}
{
isQueryScreen && hasDeletePermission && currentSavedView != null &&
<Tooltip {...menuTooltipAttribs} title={notOwnerTooltipText ?? "Delete this saved view."}>
<span>
<MenuItem disabled={currentSavedView === null || disabledBecauseNotOwner} onClick={() => handleDropdownOptionClick(DELETE_OPTION)}>
<ListItemIcon><Icon>delete</Icon></ListItemIcon>
Delete...
</MenuItem>
</span>
</Tooltip>
}
{
isQueryScreen &&
<Tooltip {...menuTooltipAttribs} title="Create a new view of this table, resetting the filters and columns to their defaults.">
<span>
<MenuItem onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>
<ListItemIcon><Icon>monitor</Icon></ListItemIcon>
New View
</MenuItem>
</span>
</Tooltip>
}
{
isQueryScreen && hasSavedReportsPermission &&
<Tooltip {...menuTooltipAttribs} title="Create a new Saved Report using your current view of this table as a starting point.">
<span>
<MenuItem onClick={() => handleDropdownOptionClick(NEW_REPORT_OPTION)}>
<ListItemIcon><Icon>article</Icon></ListItemIcon>
Create Report from Current View
</MenuItem>
</span>
</Tooltip>
}
{
isQueryScreen && <Divider />
}
<MenuItem disabled style={{"opacity": "initial"}}><b>Your Saved Views</b></MenuItem>
{
yourSavedViews && yourSavedViews.length > 0 ? (
yourSavedViews.map((record: QRecord, index: number) =>
<MenuItem sx={{paddingLeft: "50px"}} key={`savedFiler-${index}`} onClick={() => handleSavedViewRecordOnClick(record)}>
{record.values.get("label")}
</MenuItem>
)
) : (
<MenuItem disabled sx={{opacity: "1 !important"}}>
<i>You do not have any saved views for this table.</i>
</MenuItem>
)
}
<MenuItem disabled style={{"opacity": "initial"}}><b>Views Shared with you</b></MenuItem>
{
viewsSharedWithYou && viewsSharedWithYou.length > 0 ? (
viewsSharedWithYou.map((record: QRecord, index: number) =>
<MenuItem sx={{paddingLeft: "50px"}} key={`savedFiler-${index}`} onClick={() => handleSavedViewRecordOnClick(record)}>
{record.values.get("label")}
</MenuItem>
)
) : (
<MenuItem disabled sx={{opacity: "1 !important"}}>
<i>You do not have any views shared with you for this table.</i>
</MenuItem>
)
}
</Menu>
);
let buttonText = "Views";
let buttonBackground = "none";
let buttonBorder = colors.grayLines.main;
let buttonColor = colors.gray.main;
if (currentSavedView)
{
if (viewIsModified)
{
buttonBackground = accentColorLight;
buttonBorder = buttonBackground;
buttonColor = accentColor;
}
else
{
buttonBackground = accentColor;
buttonBorder = buttonBackground;
buttonColor = "#FFFFFF";
}
}
const buttonStyles = {
border: `1px solid ${buttonBorder}`,
backgroundColor: buttonBackground,
color: buttonColor,
"&:focus:not(:hover)": {
color: buttonColor,
backgroundColor: buttonBackground,
},
"&:hover": {
color: buttonColor,
backgroundColor: buttonBackground,
}
};
/*******************************************************************************
**
*******************************************************************************/
function isSaveButtonDisabled(): boolean
{
if (isSubmitting)
{
return (true);
}
const haveInputText = (savedViewNameInputValue != null && savedViewNameInputValue.trim() != "");
if (isSaveFilterAs || isRenameFilter || currentSavedView == null)
{
if (!haveInputText)
{
return (true);
}
}
return (false);
}
const linkButtonStyle = {
minWidth: "unset",
textTransform: "none",
fontSize: "0.875rem",
fontWeight: "500",
padding: "0.5rem"
};
return (
hasQueryPermission && tableMetaData ? (
<>
<Box order="1" mr={"0.5rem"}>
<Button
onClick={openSavedViewsMenu}
sx={{
borderRadius: "0.75rem",
textTransform: "none",
fontWeight: 500,
fontSize: "0.875rem",
p: "0.5rem",
...buttonStyles
}}
>
<Icon sx={{mr: "0.5rem"}}>save</Icon>
{buttonText}
<Icon sx={{ml: "0.5rem"}}>keyboard_arrow_down</Icon>
</Button>
{renderSavedViewsMenu}
</Box>
<Box order="3" display="flex" justifyContent="center" flexDirection="column">
<Box pl={2} pr={2} sx={{display: "flex", alignItems: "center"}}>
{
!currentSavedView && viewIsModified && <>
{
isQueryScreen && <>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Unsaved Changes</b>
<ul style={{padding: "0.5rem 1rem"}}>
{
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
}
</ul>
</>}>
<Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save View As&hellip;</Button>
</Tooltip>
{/* vertical rule */}
<Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
</>
}
<Button disableRipple={true} sx={{color: colors.gray.main, ...linkButtonStyle}} onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>Reset All Changes</Button>
</>
}
{
isQueryScreen && currentSavedView && viewIsModified && <>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Unsaved Changes</b>
<ul style={{padding: "0.5rem 1rem"}}>
{
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
}
</ul>
{
notOwnerTooltipText && <i>{notOwnerTooltipText}</i>
}
</>}>
<Box display="inline" sx={{...linkButtonStyle, p: 0, cursor: "default", position: "relative", top: "-1px"}}>{viewDiffs.length} Unsaved Change{viewDiffs.length == 1 ? "" : "s"}</Box>
</Tooltip>
{disabledBecauseNotOwner ? <>&nbsp;&nbsp;</> : <Button disableRipple={true} sx={linkButtonStyle} onClick={() => handleDropdownOptionClick(SAVE_OPTION)}>Save&hellip;</Button>}
{/* vertical rule */}
<Box display="inline-block" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
<Button disableRipple={true} sx={{color: colors.gray.main, ...linkButtonStyle}} onClick={() => handleSavedViewRecordOnClick(currentSavedView)}>Reset All Changes</Button>
</>
}
{
!isQueryScreen && currentSavedView &&
<Box>
<Box display="inline-block" fontSize="0.875rem" fontWeight="500" sx={{position: "relative", top: "-1px"}}>
{currentSavedView.values.get("label")}
</Box>
{
viewIsModified &&
<>
<Tooltip {...tooltipMaxWidth("24rem")} sx={{cursor: "pointer"}} title={<>
<b>Changes</b>
<ul style={{padding: "0.5rem 1rem"}}>
{
viewDiffs.map((s: string, i: number) => <li key={i}>{s}</li>)
}
</ul>
</>}>
<Box display="inline" ml="0.25rem" mr="0.25rem" sx={{...linkButtonStyle, p: 0, cursor: "default", position: "relative", top: "-1px"}}>with {viewDiffs.length} Change{viewDiffs.length == 1 ? "" : "s"}</Box>
</Tooltip>
<Button disableRipple={true} sx={{color: colors.gray.main, ...linkButtonStyle}} onClick={() => handleSavedViewRecordOnClick(currentSavedView)}>Reset Changes</Button>
</>
}
{/* vertical rule */}
<Box display="inline-block" ml="0.25rem" borderLeft={`1px solid ${colors.grayLines.main}`} height="1rem" width="1px" position="relative" />
<Button disableRipple={true} sx={{color: colors.gray.main, ...linkButtonStyle}} onClick={() => handleDropdownOptionClick(CLEAR_OPTION)}>Reset to New View</Button>
</Box>
}
</Box>
</Box>
{
<Dialog
open={saveFilterPopupOpen}
onClose={handleSaveFilterPopupClose}
aria-labelledby="alert-dialog-title"
aria-describedby="alert-dialog-description"
onKeyPress={(e) =>
{
////////////////////////////////////////////////////
// make user actually hit delete button //
// but for other modes, let Enter submit the form //
////////////////////////////////////////////////////
if (e.key == "Enter" && !isDeleteFilter)
{
handleFilterDialogButtonOnClick();
}
}}
>
{
currentSavedView ? (
isDeleteFilter ? (
<DialogTitle id="alert-dialog-title">Delete View</DialogTitle>
) : (
isSaveFilterAs ? (
<DialogTitle id="alert-dialog-title">Save View As</DialogTitle>
) : (
isRenameFilter ? (
<DialogTitle id="alert-dialog-title">Rename View</DialogTitle>
) : (
<DialogTitle id="alert-dialog-title">Update Existing View</DialogTitle>
)
)
)
) : (
<DialogTitle id="alert-dialog-title">Save New View</DialogTitle>
)
}
<DialogContent sx={{width: "500px"}}>
{popupAlertContent ? (
<Box mb={1}>
<Alert severity="error" onClose={() => setPopupAlertContent("")}>{popupAlertContent}</Alert>
</Box>
) : ("")}
{
(!currentSavedView || isSaveFilterAs || isRenameFilter) && !isDeleteFilter ? (
<Box>
{
isSaveFilterAs ? (
<Box mb={3}>Enter a name for this new saved view.</Box>
) : (
<Box mb={3}>Enter a new name for this saved view.</Box>
)
}
<TextField
autoFocus
name="custom-delimiter-value"
placeholder="View Name"
inputProps={{width: "100%", maxLength: 100}}
value={savedViewNameInputValue}
sx={{width: "100%"}}
onChange={handleSaveFilterInputChange}
onFocus={event =>
{
event.target.select();
}}
/>
</Box>
) : (
isDeleteFilter ? (
<Box>Are you sure you want to delete the view {`'${currentSavedView?.values.get("label")}'`}?</Box>
) : (
<Box>Are you sure you want to update the view {`'${currentSavedView?.values.get("label")}'`}?</Box>
)
)
}
</DialogContent>
<DialogActions>
<QCancelButton onClickHandler={handleSaveFilterPopupClose} disabled={false} />
{
isDeleteFilter ?
<QDeleteButton onClickHandler={handleFilterDialogButtonOnClick} disabled={isSubmitting} />
:
<QSaveButton label="Save" onClickHandler={handleFilterDialogButtonOnClick} disabled={isSaveButtonDisabled()} />
}
</DialogActions>
</Dialog>
}
</>
) : null
);
}
export default SavedViews;

View File

@ -28,12 +28,11 @@ interface TabPanelProps
children?: React.ReactNode;
index: number;
value: number;
style?: any;
}
export default function TabPanel(props: TabPanelProps)
{
const {children, value, index, style, ...other} = props;
const {children, value, index, ...other} = props;
return (
<div
@ -41,7 +40,6 @@ export default function TabPanel(props: TabPanelProps)
hidden={value !== index}
id={`simple-tabpanel-${index}`}
aria-labelledby={`simple-tab-${index}`}
style={style}
{...other}
>
{value === index && (

View File

@ -1,329 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {Checkbox, FormControlLabel, Radio, Tooltip} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import RadioGroup from "@mui/material/RadioGroup";
import TextField from "@mui/material/TextField";
import {useFormikContext} from "formik";
import colors from "qqq/assets/theme/base/colors";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import {BulkLoadField, FileDescription} from "qqq/models/processes/BulkLoadModels";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useState} from "react";
interface BulkLoadMappingFieldProps
{
bulkLoadField: BulkLoadField,
isRequired: boolean,
removeFieldCallback?: () => void,
fileDescription: FileDescription,
forceParentUpdate?: () => void,
}
const xIconButtonSX =
{
border: `1px solid ${colors.grayLines.main} !important`,
borderRadius: "0.5rem",
textTransform: "none",
fontSize: "1rem",
fontWeight: "400",
width: "30px",
minWidth: "30px",
height: "2rem",
minHeight: "2rem",
paddingLeft: 0,
paddingRight: 0,
marginRight: "0.5rem",
marginTop: "0.5rem",
color: colors.error.main,
"&:hover": {color: colors.error.main},
"&:focus": {color: colors.error.main},
"&:focus:not(:hover)": {color: colors.error.main},
};
const qController = Client.getInstance();
/***************************************************************************
** row for a single field on the bulk load mapping screen.
***************************************************************************/
export default function BulkLoadFileMappingField({bulkLoadField, isRequired, removeFieldCallback, fileDescription, forceParentUpdate}: BulkLoadMappingFieldProps): JSX.Element
{
const columnNames = fileDescription.getColumnNames();
const [valueType, setValueType] = useState(bulkLoadField.valueType);
const [selectedColumn, setSelectedColumn] = useState({label: columnNames[bulkLoadField.columnIndex], value: bulkLoadField.columnIndex});
const [selectedColumnInputValue, setSelectedColumnInputValue] = useState(columnNames[bulkLoadField.columnIndex]);
const [doingInitialLoadOfPossibleValue, setDoingInitialLoadOfPossibleValue] = useState(false);
const [everDidInitialLoadOfPossibleValue, setEverDidInitialLoadOfPossibleValue] = useState(false);
const [possibleValueInitialDisplayValue, setPossibleValueInitialDisplayValue] = useState(null as string);
const fieldMetaData = new QFieldMetaData(bulkLoadField.field);
const dynamicField = DynamicFormUtils.getDynamicField(fieldMetaData);
const dynamicFieldInObject: any = {};
dynamicFieldInObject[fieldMetaData["name"]] = dynamicField;
DynamicFormUtils.addPossibleValueProps(dynamicFieldInObject, [fieldMetaData], bulkLoadField.tableStructure.tableName, null, null);
/////////////////////////////////////////////////////////////////////////////////////
// deal with dynamically loading the initial default value for a possible value... //
/////////////////////////////////////////////////////////////////////////////////////
let actuallyDoingInitialLoadOfPossibleValue = doingInitialLoadOfPossibleValue;
if(dynamicField.possibleValueProps && bulkLoadField.defaultValue && !doingInitialLoadOfPossibleValue && !everDidInitialLoadOfPossibleValue)
{
actuallyDoingInitialLoadOfPossibleValue = true;
setDoingInitialLoadOfPossibleValue(true);
setEverDidInitialLoadOfPossibleValue(true);
(async () =>
{
try
{
const possibleValues = await qController.possibleValues(bulkLoadField.tableStructure.tableName, null, fieldMetaData.name, null, [bulkLoadField.defaultValue], undefined, "filter");
if (possibleValues && possibleValues.length > 0)
{
setPossibleValueInitialDisplayValue(possibleValues[0].label);
}
else
{
setPossibleValueInitialDisplayValue(null);
}
}
catch(e)
{
console.log(`Error loading possible value: ${e}`)
}
actuallyDoingInitialLoadOfPossibleValue = false;
setDoingInitialLoadOfPossibleValue(false);
})();
}
if(dynamicField.possibleValueProps && possibleValueInitialDisplayValue)
{
dynamicField.possibleValueProps.initialDisplayValue = possibleValueInitialDisplayValue;
}
//////////////////////////////////////////////////////
// build array of options for the columns drop down //
// don't allow duplicates //
//////////////////////////////////////////////////////
const columnOptions: { value: number, label: string }[] = [];
const usedLabels: {[label: string]: boolean} = {};
for (let i = 0; i < columnNames.length; i++)
{
const label = columnNames[i];
if(!usedLabels[label])
{
columnOptions.push({label: label, value: i});
usedLabels[label] = true;
}
}
//////////////////////////////////////////////////////////////////////
// try to pick up changes in the hasHeaderRow toggle from way above //
//////////////////////////////////////////////////////////////////////
if(bulkLoadField.columnIndex != null && bulkLoadField.columnIndex != undefined && selectedColumn.label && columnNames[bulkLoadField.columnIndex] != selectedColumn.label)
{
setSelectedColumn({label: columnNames[bulkLoadField.columnIndex], value: bulkLoadField.columnIndex})
setSelectedColumnInputValue(columnNames[bulkLoadField.columnIndex]);
}
const mainFontSize = "0.875rem";
const smallerFontSize = "0.75rem";
/////////////////////////////////////////////////////////////////////////////////////////////
// some field types get their value from formik. //
// so for a pre-populated value, do an on-load useEffect, that'll set the value in formik. //
/////////////////////////////////////////////////////////////////////////////////////////////
const {setFieldValue} = useFormikContext();
useEffect(() =>
{
if (valueType == "defaultValue")
{
setFieldValue(`${bulkLoadField.field.name}.defaultValue`, bulkLoadField.defaultValue);
}
}, []);
/***************************************************************************
**
***************************************************************************/
function columnChanged(event: any, newValue: any, reason: string)
{
setSelectedColumn(newValue);
setSelectedColumnInputValue(newValue == null ? "" : newValue.label);
bulkLoadField.columnIndex = newValue == null ? null : newValue.value;
if (fileDescription.hasHeaderRow)
{
bulkLoadField.headerName = newValue == null ? null : newValue.label;
}
bulkLoadField.error = null;
bulkLoadField.warning = null;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function defaultValueChanged(newValue: any)
{
setFieldValue(`${bulkLoadField.field.name}.defaultValue`, newValue);
bulkLoadField.defaultValue = newValue;
bulkLoadField.error = null;
bulkLoadField.warning = null;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function valueTypeChanged(isColumn: boolean)
{
const newValueType = isColumn ? "column" : "defaultValue";
bulkLoadField.valueType = newValueType;
setValueType(newValueType);
bulkLoadField.error = null;
bulkLoadField.warning = null;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function mapValuesChanged(value: boolean)
{
bulkLoadField.doValueMapping = value;
forceParentUpdate && forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function changeSelectedColumnInputValue(e: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>)
{
setSelectedColumnInputValue(e.target.value);
}
return (<Box py="0.5rem" sx={{borderBottom: "1px solid lightgray", width: "100%", overflow: "auto"}} id={`blfmf-${bulkLoadField.field.name}`}>
<Box display="grid" gridTemplateColumns="200px 400px auto" fontSize="1rem" gap="0.5rem" sx={
{
"& .MuiFormControlLabel-label": {ml: "0 !important", fontWeight: "normal !important", fontSize: mainFontSize}
}}>
<Box display="flex" alignItems="flex-start">
{
(!isRequired) && <Tooltip placement="bottom" title="Remove this field from your mapping.">
<Button sx={xIconButtonSX} onClick={() => removeFieldCallback()}><Icon>clear</Icon></Button>
</Tooltip>
}
<Box pt="0.625rem">
{bulkLoadField.getQualifiedLabel()}
</Box>
</Box>
<RadioGroup name="valueType" value={valueType}>
<Box>
<Box display="flex" alignItems="center" sx={{height: "45px"}}>
<FormControlLabel value="column" control={<Radio size="small" onChange={(event, checked) => valueTypeChanged(checked)} />} label={"File column"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
{
valueType == "column" && <Box width="100%">
<Autocomplete
id={bulkLoadField.field.name}
renderInput={(params) => (<TextField {...params} label={""} value={selectedColumnInputValue} onChange={e => changeSelectedColumnInputValue(e)} fullWidth variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} sx={{"& .MuiOutlinedInput-root": {borderRadius: "0.75rem"}}} />)}
fullWidth
options={columnOptions}
multiple={false}
defaultValue={selectedColumn}
value={selectedColumn}
inputValue={selectedColumnInputValue}
onChange={columnChanged}
getOptionLabel={(option) => typeof (option) == "string" ? option : (option?.label ?? "")}
isOptionEqualToValue={(option, value) => option == null && value == null || option.value == value.value}
renderOption={(props, option, state) => (<li {...props}>{option?.label ?? ""}</li>)}
sx={{"& .MuiOutlinedInput-root": {padding: "0"}}}
/>
</Box>
}
</Box>
<Box display="flex" alignItems="center" sx={{height: "45px"}}>
<FormControlLabel value="defaultValue" control={<Radio size="small" onChange={(event, checked) => valueTypeChanged(!checked)} />} label={"Default value"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
{
valueType == "defaultValue" && actuallyDoingInitialLoadOfPossibleValue && <Box width="100%">Loading...</Box>
}
{
valueType == "defaultValue" && !actuallyDoingInitialLoadOfPossibleValue && <Box width="100%">
<QDynamicFormField
name={`${bulkLoadField.field.name}.defaultValue`}
displayFormat={""}
label={""}
formFieldObject={dynamicField}
type={dynamicField.type}
value={bulkLoadField.defaultValue}
onChangeCallback={defaultValueChanged}
/>
</Box>
}
</Box>
</Box>
{
bulkLoadField.warning &&
<Box fontSize={smallerFontSize} color={colors.warning.main} ml="145px" className="bulkLoadFieldError">
{bulkLoadField.warning}
</Box>
}
{
bulkLoadField.error &&
<Box fontSize={smallerFontSize} color={colors.error.main} ml="145px" className="bulkLoadFieldError">
{bulkLoadField.error}
</Box>
}
</RadioGroup>
<Box ml="1rem">
{
valueType == "column" && <>
<Box>
<FormControlLabel value="mapValues" control={<Checkbox size="small" defaultChecked={bulkLoadField.doValueMapping} onChange={(event, checked) => mapValuesChanged(checked)} />} label={"Map values"} sx={{minWidth: "140px", whiteSpace: "nowrap"}} />
</Box>
<Box fontSize={mainFontSize} mt="0.5rem">
Preview Values: <span style={{color: "gray"}}>{(fileDescription.getPreviewValues(selectedColumn?.value) ?? [""]).join(", ")}</span>
</Box>
</>
}
</Box>
</Box>
</Box>);
}

View File

@ -1,308 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import colors from "qqq/assets/theme/base/colors";
import QHierarchyAutoComplete, {Group, Option} from "qqq/components/misc/QHierarchyAutoComplete";
import BulkLoadFileMappingField from "qqq/components/processes/BulkLoadFileMappingField";
import {BulkLoadField, BulkLoadMapping, FileDescription} from "qqq/models/processes/BulkLoadModels";
import React, {useEffect, useReducer, useState} from "react";
interface BulkLoadMappingFieldsProps
{
bulkLoadMapping: BulkLoadMapping,
fileDescription: FileDescription,
forceParentUpdate?: () => void,
}
const ADD_SINGLE_FIELD_TOOLTIP = "Click to add this field to your mapping.";
const ADD_MANY_FIELD_TOOLTIP = "Click to add this field to your mapping as many times as you need.";
const ALREADY_ADDED_FIELD_TOOLTIP = "This field has already been added to your mapping.";
/***************************************************************************
** The section of the bulk load mapping screen with all the fields.
***************************************************************************/
export default function BulkLoadFileMappingFields({bulkLoadMapping, fileDescription, forceParentUpdate}: BulkLoadMappingFieldsProps): JSX.Element
{
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const [forceHierarchyAutoCompleteRerender, setForceHierarchyAutoCompleteRerender] = useState(0);
////////////////////////////////////////////
// build list of fields that can be added //
////////////////////////////////////////////
const [addFieldsGroup, setAddFieldsGroup] = useState({
label: bulkLoadMapping.tablesByPath[""]?.label,
value: "mainTable",
options: [],
subGroups: []
} as Group);
// const [addFieldsToggleStates, setAddFieldsToggleStates] = useState({} as { [name: string]: boolean });
const [addFieldsDisableStates, setAddFieldsDisableStates] = useState({} as { [name: string]: boolean });
const [tooltips, setTooltips] = useState({} as { [name: string]: string });
useEffect(() =>
{
const newDisableStates: { [name: string]: boolean } = {};
const newTooltips: { [name: string]: string } = {};
/////////////////////////////////////////////////////////////////////////////////////////////
// do the unused fields array first, as we've got some use-case where i think a field from //
// suggested mappings (or profiles?) are in this list, even though they shouldn't be? //
/////////////////////////////////////////////////////////////////////////////////////////////
for (let field of bulkLoadMapping.unusedFields)
{
const qualifiedName = field.getQualifiedName();
newTooltips[qualifiedName] = field.isMany() ? ADD_MANY_FIELD_TOOLTIP : ADD_SINGLE_FIELD_TOOLTIP;
}
//////////////////////////////////////////////////
// then do all the required & additional fields //
//////////////////////////////////////////////////
for (let field of [...(bulkLoadMapping.requiredFields ?? []), ...(bulkLoadMapping.additionalFields ?? [])])
{
const qualifiedName = field.getQualifiedName();
if (bulkLoadMapping.layout == "WIDE" && field.isMany())
{
newDisableStates[qualifiedName] = false;
newTooltips[qualifiedName] = ADD_MANY_FIELD_TOOLTIP;
}
else
{
newDisableStates[qualifiedName] = true;
newTooltips[qualifiedName] = ALREADY_ADDED_FIELD_TOOLTIP;
}
}
setAddFieldsDisableStates(newDisableStates);
setTooltips(newTooltips);
setForceHierarchyAutoCompleteRerender(forceHierarchyAutoCompleteRerender + 1);
}, [bulkLoadMapping, bulkLoadMapping.layout]);
///////////////////////////////////////////////
// initialize this structure on first render //
///////////////////////////////////////////////
if (addFieldsGroup.options.length == 0)
{
for (let qualifiedFieldName in bulkLoadMapping.fieldsByTablePrefix[""])
{
const bulkLoadField = bulkLoadMapping.fieldsByTablePrefix[""][qualifiedFieldName];
const field = bulkLoadField.field;
addFieldsGroup.options.push({label: field.label, value: field.name, bulkLoadField: bulkLoadField});
}
for (let prefix in bulkLoadMapping.fieldsByTablePrefix)
{
if (prefix == "")
{
continue;
}
const associationOptions: Option[] = [];
const tableStructure = bulkLoadMapping.tablesByPath[prefix];
addFieldsGroup.subGroups.push({label: tableStructure.label, value: tableStructure.associationPath, options: associationOptions});
for (let qualifiedFieldName in bulkLoadMapping.fieldsByTablePrefix[prefix])
{
const bulkLoadField = bulkLoadMapping.fieldsByTablePrefix[prefix][qualifiedFieldName];
const field = bulkLoadField.field;
associationOptions.push({label: field.label, value: field.name, bulkLoadField: bulkLoadField});
}
}
}
/***************************************************************************
**
***************************************************************************/
function removeField(bulkLoadField: BulkLoadField)
{
addFieldsDisableStates[bulkLoadField.getQualifiedName()] = false;
setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
if (bulkLoadMapping.layout == "WIDE" && bulkLoadField.isMany())
{
//////////////////////////////////////////////////////////////////////////
// ok, you can add more - so don't disable and don't change the tooltip //
//////////////////////////////////////////////////////////////////////////
}
else
{
tooltips[bulkLoadField.getQualifiedName()] = ADD_SINGLE_FIELD_TOOLTIP;
}
bulkLoadMapping.removeField(bulkLoadField);
forceUpdate();
forceParentUpdate();
setForceHierarchyAutoCompleteRerender(forceHierarchyAutoCompleteRerender + 1);
}
/***************************************************************************
**
***************************************************************************/
function handleToggleField(option: Option, group: Group, newValue: boolean)
{
const fieldKey = group.value == "mainTable" ? option.value : group.value + "." + option.value;
// addFieldsToggleStates[fieldKey] = newValue;
// setAddFieldsToggleStates(Object.assign({}, addFieldsToggleStates));
addFieldsDisableStates[fieldKey] = newValue;
setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
const bulkLoadField = bulkLoadMapping.fields[fieldKey];
if (bulkLoadField)
{
if (newValue)
{
bulkLoadMapping.addField(bulkLoadField);
}
else
{
bulkLoadMapping.removeField(bulkLoadField);
}
forceUpdate();
forceParentUpdate();
}
}
/***************************************************************************
**
***************************************************************************/
function handleAddField(option: Option, group: Group)
{
const fieldKey = group.value == "mainTable" ? option.value : group.value + "." + option.value;
const bulkLoadField = bulkLoadMapping.fields[fieldKey];
if (bulkLoadField)
{
bulkLoadMapping.addField(bulkLoadField);
// addFieldsDisableStates[fieldKey] = true;
// setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
if (bulkLoadMapping.layout == "WIDE" && bulkLoadField.isMany())
{
//////////////////////////////////////////////////////////////////////////
// ok, you can add more - so don't disable and don't change the tooltip //
//////////////////////////////////////////////////////////////////////////
}
else
{
addFieldsDisableStates[fieldKey] = true;
setAddFieldsDisableStates(Object.assign({}, addFieldsDisableStates));
tooltips[fieldKey] = ALREADY_ADDED_FIELD_TOOLTIP;
}
forceUpdate();
forceParentUpdate();
document.getElementById("addFieldsButton")?.scrollIntoView();
}
}
let buttonBackground = "none";
let buttonBorder = colors.grayLines.main;
let buttonColor = colors.gray.main;
const addFieldMenuButtonStyles = {
borderRadius: "0.75rem",
border: `1px solid ${buttonBorder}`,
color: buttonColor,
textTransform: "none",
fontWeight: 500,
fontSize: "0.875rem",
p: "0.5rem",
backgroundColor: buttonBackground,
"&:focus:not(:hover)": {
color: buttonColor,
backgroundColor: buttonBackground,
},
"&:hover": {
color: buttonColor,
backgroundColor: buttonBackground,
}
};
return (
<>
<h5>Required Fields</h5>
<Box pl={"1rem"}>
{
bulkLoadMapping.requiredFields.length == 0 &&
<i style={{fontSize: "0.875rem"}}>There are no required fields in this table.</i>
}
{bulkLoadMapping.requiredFields.map((bulkLoadField) => (
<BulkLoadFileMappingField
fileDescription={fileDescription}
key={bulkLoadField.getKey()}
bulkLoadField={bulkLoadField}
isRequired={true}
forceParentUpdate={forceParentUpdate}
/>
))}
</Box>
<Box mt="1rem">
<h5>Additional Fields</h5>
<Box pl={"1rem"}>
{bulkLoadMapping.additionalFields.map((bulkLoadField) => (
<BulkLoadFileMappingField
fileDescription={fileDescription}
key={bulkLoadField.getKey()}
bulkLoadField={bulkLoadField}
isRequired={false}
removeFieldCallback={() => removeField(bulkLoadField)}
forceParentUpdate={forceParentUpdate}
/>
))}
<Box display="flex" pt="1rem" pl="12.5rem">
<QHierarchyAutoComplete
idPrefix="addFieldAutocomplete"
defaultGroup={addFieldsGroup}
menuDirection="up"
buttonProps={{id: "addFieldsButton", sx: addFieldMenuButtonStyles}}
buttonChildren={<><Icon sx={{mr: "0.5rem"}}>add</Icon> Add Fields <Icon sx={{ml: "0.5rem"}}>keyboard_arrow_down</Icon></>}
isModeSelectOne
keepOpenAfterSelectOne
handleSelectedOption={handleAddField}
forceRerender={forceHierarchyAutoCompleteRerender}
disabledStates={addFieldsDisableStates}
tooltips={tooltips}
/>
</Box>
</Box>
</Box>
</>
);
}

View File

@ -1,566 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QFrontendStepMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFrontendStepMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Badge, Icon} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Grid from "@mui/material/Grid";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import {useFormikContext} from "formik";
import colors from "qqq/assets/theme/base/colors";
import {DynamicFormFieldLabel} from "qqq/components/forms/DynamicForm";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import MDTypography from "qqq/components/legacy/MDTypography";
import HelpContent from "qqq/components/misc/HelpContent";
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
import BulkLoadFileMappingFields from "qqq/components/processes/BulkLoadFileMappingFields";
import {BulkLoadField, BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels";
import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun";
import React, {forwardRef, useImperativeHandle, useReducer, useState} from "react";
import ProcessViewForm from "./ProcessViewForm";
interface BulkLoadMappingFormProps
{
processValues: any,
tableMetaData: QTableMetaData,
metaData: QInstance,
setActiveStepLabel: (label: string) => void,
frontendStep: QFrontendStepMetaData,
processMetaData: QProcessMetaData,
}
/***************************************************************************
** process component - screen where user does a bulk-load file mapping.
***************************************************************************/
const BulkLoadFileMappingForm = forwardRef(({processValues, tableMetaData, metaData, setActiveStepLabel, frontendStep, processMetaData}: BulkLoadMappingFormProps, ref) =>
{
const {setFieldValue} = useFormikContext();
const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord;
const [currentSavedBulkLoadProfile, setCurrentSavedBulkLoadProfile] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue));
const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper<QRecord>(currentSavedBulkLoadProfile));
const [fieldErrors, setFieldErrors] = useState({} as { [fieldName: string]: string });
const [noMappedFieldsError, setNoMappedFieldsError] = useState(null as string);
const [suggestedBulkLoadProfile] = useState(processValues.suggestedBulkLoadProfile as BulkLoadProfile);
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
const [bulkLoadMapping, setBulkLoadMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, processValues.bulkLoadProfile));
const [wrappedBulkLoadMapping] = useState(new Wrapper<BulkLoadMapping>(bulkLoadMapping));
const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview));
fileDescription.setHasHeaderRow(bulkLoadMapping.hasHeaderRow);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
/////////////////////////////////////////////////////////////////////////////////////////////////
// ok - so - ... Autocomplete, at least as we're using it for the layout field - doesn't like //
// to change its initial value. So, we want to work hard to force the Header sub-component to //
// re-render upon external changes to the layout (e.g., new profile being selected). //
// use this state-counter to make that happen (and let's please never speak of it again). //
/////////////////////////////////////////////////////////////////////////////////////////////////
const [rerenderHeader, setRerenderHeader] = useState(1);
////////////////////////////////////////////////////////
// ref-based callback for integration with ProcessRun //
////////////////////////////////////////////////////////
useImperativeHandle(ref, () =>
{
return {
preSubmit(): SubFormPreSubmitCallbackResultType
{
///////////////////////////////////////////////////////////////////////////////////////////////
// convert the BulkLoadMapping to a BulkLoadProfile - the thing that the backend understands //
///////////////////////////////////////////////////////////////////////////////////////////////
const {haveErrors: haveProfileErrors, profile} = wrappedBulkLoadMapping.get().toProfile();
const values: { [name: string]: any } = {};
////////////////////////////////////////////////////
// always re-submit the full profile //
// note mostly a copy in BulkLoadValueMappingForm //
////////////////////////////////////////////////////
values["version"] = profile.version;
values["fieldListJSON"] = JSON.stringify(profile.fieldList);
values["savedBulkLoadProfileId"] = wrappedCurrentSavedBulkLoadProfile.get()?.values?.get("id");
values["layout"] = wrappedBulkLoadMapping.get().layout;
values["hasHeaderRow"] = wrappedBulkLoadMapping.get().hasHeaderRow;
let haveLocalErrors = false;
const fieldErrors: { [fieldName: string]: string } = {};
if (!values["layout"])
{
haveLocalErrors = true;
fieldErrors["layout"] = "This field is required.";
}
if (values["hasHeaderRow"] == null || values["hasHeaderRow"] == undefined)
{
haveLocalErrors = true;
fieldErrors["hasHeaderRow"] = "This field is required.";
}
setFieldErrors(fieldErrors);
if(wrappedBulkLoadMapping.get().requiredFields.length == 0 && wrappedBulkLoadMapping.get().additionalFields.length == 0)
{
setNoMappedFieldsError("You must have at least 1 field.");
haveLocalErrors = true;
setTimeout(() => setNoMappedFieldsError(null), 2500);
}
else
{
setNoMappedFieldsError(null);
}
if(haveProfileErrors)
{
setTimeout(() =>
{
document.querySelector(".bulkLoadFieldError")?.scrollIntoView({behavior: "smooth", block: "center", inline: "center"});
}, 250);
}
return {maySubmit: !haveProfileErrors && !haveLocalErrors, values};
}
};
});
/***************************************************************************
**
***************************************************************************/
function bulkLoadProfileOnChangeCallback(profileRecord: QRecord | null)
{
setCurrentSavedBulkLoadProfile(profileRecord);
wrappedCurrentSavedBulkLoadProfile.set(profileRecord);
let newBulkLoadMapping: BulkLoadMapping;
if (profileRecord)
{
newBulkLoadMapping = BulkLoadMapping.fromSavedProfileRecord(processValues.tableStructure, profileRecord);
}
else
{
newBulkLoadMapping = new BulkLoadMapping(processValues.tableStructure);
}
handleNewBulkLoadMapping(newBulkLoadMapping);
}
/***************************************************************************
**
***************************************************************************/
function bulkLoadProfileResetToSuggestedMappingCallback()
{
handleNewBulkLoadMapping(BulkLoadMapping.fromBulkLoadProfile(processValues.tableStructure, suggestedBulkLoadProfile));
}
/***************************************************************************
**
***************************************************************************/
function handleNewBulkLoadMapping(newBulkLoadMapping: BulkLoadMapping)
{
const newRequiredFields: BulkLoadField[] = [];
for (let field of newBulkLoadMapping.requiredFields)
{
newRequiredFields.push(BulkLoadField.clone(field));
}
newBulkLoadMapping.requiredFields = newRequiredFields;
setBulkLoadMapping(newBulkLoadMapping);
wrappedBulkLoadMapping.set(newBulkLoadMapping);
setFieldValue("hasHeaderRow", newBulkLoadMapping.hasHeaderRow);
setFieldValue("layout", newBulkLoadMapping.layout);
setRerenderHeader(rerenderHeader + 1);
}
if (currentSavedBulkLoadProfile)
{
setActiveStepLabel(`File Mapping / ${currentSavedBulkLoadProfile.values.get("label")}`);
}
else
{
setActiveStepLabel("File Mapping");
}
return (<Box>
<Box py="1rem" display="flex">
<SavedBulkLoadProfiles
metaData={metaData}
tableMetaData={tableMetaData}
tableStructure={tableStructure}
currentSavedBulkLoadProfileRecord={currentSavedBulkLoadProfile}
currentMapping={bulkLoadMapping}
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
bulkLoadProfileResetToSuggestedMappingCallback={bulkLoadProfileResetToSuggestedMappingCallback}
fileDescription={fileDescription}
/>
</Box>
<BulkLoadMappingHeader
key={rerenderHeader}
bulkLoadMapping={bulkLoadMapping}
fileDescription={fileDescription}
tableStructure={tableStructure}
fileName={processValues.fileBaseName}
fieldErrors={fieldErrors}
frontendStep={frontendStep}
processMetaData={processMetaData}
forceParentUpdate={() => forceUpdate()}
/>
<Box mt="2rem">
<BulkLoadFileMappingFields
bulkLoadMapping={bulkLoadMapping}
fileDescription={fileDescription}
forceParentUpdate={() =>
{
setRerenderHeader(rerenderHeader + 1);
forceUpdate();
}}
/>
{
noMappedFieldsError && <Box color={colors.error.main} textAlign="right">{noMappedFieldsError}</Box>
}
</Box>
</Box>);
});
export default BulkLoadFileMappingForm;
interface BulkLoadMappingHeaderProps
{
fileDescription: FileDescription,
fileName: string,
bulkLoadMapping?: BulkLoadMapping,
fieldErrors: { [fieldName: string]: string },
tableStructure: BulkLoadTableStructure,
forceParentUpdate?: () => void,
frontendStep: QFrontendStepMetaData,
processMetaData: QProcessMetaData,
}
/***************************************************************************
** private subcomponent - the header section of the bulk load file mapping screen.
***************************************************************************/
function BulkLoadMappingHeader({fileDescription, fileName, bulkLoadMapping, fieldErrors, tableStructure, forceParentUpdate, frontendStep, processMetaData}: BulkLoadMappingHeaderProps): JSX.Element
{
const viewFields = [
new QFieldMetaData({name: "fileName", label: "File Name", type: "STRING"}),
new QFieldMetaData({name: "fileDetails", label: "File Details", type: "STRING"}),
];
const viewValues = {
"fileName": fileName,
"fileDetails": `${fileDescription.getColumnNames().length} column${fileDescription.getColumnNames().length == 1 ? "" : "s"}`
};
const hasHeaderRowFormField = {name: "hasHeaderRow", label: "Does the file have a header row?", type: "checkbox", isRequired: true, isEditable: true};
const layoutOptions = [
{label: "Flat", id: "FLAT"},
{label: "Tall", id: "TALL"},
{label: "Wide", id: "WIDE"},
];
if (!tableStructure.associations)
{
layoutOptions.splice(1);
}
const selectedLayout = layoutOptions.filter(o => o.id == bulkLoadMapping.layout)[0] ?? null;
/***************************************************************************
**
***************************************************************************/
function hasHeaderRowChanged(newValue: any)
{
bulkLoadMapping.hasHeaderRow = newValue;
fileDescription.hasHeaderRow = newValue;
bulkLoadMapping.handleChangeToHasHeaderRow(newValue, fileDescription);
fieldErrors.hasHeaderRow = null;
forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function layoutChanged(event: any, newValue: any)
{
bulkLoadMapping.switchLayout(newValue ? newValue.id : null);
fieldErrors.layout = null;
forceParentUpdate();
}
/***************************************************************************
**
***************************************************************************/
function getFormattedHelpContent(fieldName: string): JSX.Element
{
const field = frontendStep?.formFields?.find(f => f.name == fieldName);
let helpRoles = ["PROCESS_SCREEN", "ALL_SCREENS"];
let formattedHelpContent = <HelpContent helpContents={field?.helpContents} roles={helpRoles} helpContentKey={`process:${processMetaData?.name};field:${fieldName}`} />;
if (formattedHelpContent)
{
const mt = field && field.type == QFieldType.BOOLEAN ? "-0.5rem" : "0.5rem";
return <Box color="#757575" fontSize="0.875rem" mt={mt}>{formattedHelpContent}</Box>;
}
return null;
}
return (
<Box>
<h5>File Details</h5>
<Box ml="1rem">
<ProcessViewForm fields={viewFields} values={viewValues} columns={2} />
<BulkLoadMappingFilePreview fileDescription={fileDescription} bulkLoadMapping={bulkLoadMapping} />
<Grid container pt="1rem">
<Grid item xs={12} md={6}>
<DynamicFormFieldLabel name={hasHeaderRowFormField.name} label={`${hasHeaderRowFormField.label} *`} />
<QDynamicFormField name={hasHeaderRowFormField.name} displayFormat={""} label={""} formFieldObject={hasHeaderRowFormField} type={"checkbox"} value={bulkLoadMapping.hasHeaderRow} onChangeCallback={hasHeaderRowChanged} />
{
fieldErrors.hasHeaderRow &&
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" mt="0.25rem">
{<div className="fieldErrorMessage">{fieldErrors.hasHeaderRow}</div>}
</MDTypography>
}
{getFormattedHelpContent("hasHeaderRow")}
</Grid>
<Grid item xs={12} md={6}>
<DynamicFormFieldLabel name={"layout"} label={"File Layout *"} />
<Autocomplete
id={"layout"}
renderInput={(params) => (<TextField {...params} label={""} fullWidth variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} sx={{"& .MuiOutlinedInput-root": {borderRadius: "0.75rem"}}} />)}
options={layoutOptions}
multiple={false}
defaultValue={selectedLayout}
onChange={layoutChanged}
getOptionLabel={(option) => typeof (option) == "string" ? option : (option?.label ?? "")}
isOptionEqualToValue={(option, value) => option == null && value == null || option.id == value.id}
renderOption={(props, option, state) => (<li {...props}>{option?.label ?? ""}</li>)}
disableClearable
sx={{"& .MuiOutlinedInput-root": {padding: "0"}}}
/>
{
fieldErrors.layout &&
<MDTypography component="div" variant="caption" color="error" fontWeight="regular" mt="0.25rem">
{<div className="fieldErrorMessage">{fieldErrors.layout}</div>}
</MDTypography>
}
{getFormattedHelpContent("layout")}
</Grid>
</Grid>
</Box>
</Box>
);
}
interface BulkLoadMappingFilePreviewProps
{
fileDescription: FileDescription,
bulkLoadMapping?: BulkLoadMapping
}
/***************************************************************************
** private subcomponent - the file-preview section of the bulk load file mapping screen.
***************************************************************************/
function BulkLoadMappingFilePreview({fileDescription, bulkLoadMapping}: BulkLoadMappingFilePreviewProps): JSX.Element
{
const rows: number[] = [];
for (let i = 0; i < fileDescription.bodyValuesPreview[0].length; i++)
{
rows.push(i);
}
/***************************************************************************
**
***************************************************************************/
function getValue(i: number, j: number)
{
const value = fileDescription.bodyValuesPreview[j][i];
if (value == null)
{
return "";
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// this was useful at one point in time when we had an object coming back for xlsx files with many different data types //
// we'd see a .string attribute, which would have the value we'd want to show. not using it now, but keep in case //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// @ts-ignore
if (value && value.string)
{
// @ts-ignore
return (value.string);
}
return `${value}`;
}
/***************************************************************************
**
***************************************************************************/
function getHeaderColor(count: number): string
{
if (count > 0)
{
return "blue";
}
return "black";
}
/***************************************************************************
**
***************************************************************************/
function getCursor(count: number): string
{
if (count > 0)
{
return "pointer";
}
return "default";
}
/***************************************************************************
**
***************************************************************************/
function getColumnTooltip(fields: BulkLoadField[])
{
return (<Box>
This column is mapped to the field{fields.length == 1 ? "" : "s"}:
<ul style={{marginLeft: "1rem"}}>
{fields.map((field, i) => <li key={i}>{field.getQualifiedLabel()}</li>)}
</ul>
</Box>);
}
return (
<Box sx={{"& table, & td": {border: "1px solid black", borderCollapse: "collapse", padding: "0 0.25rem", fontSize: "0.875rem", whiteSpace: "nowrap"}}}>
<Box sx={{width: "100%", overflow: "auto"}}>
<table cellSpacing="0" width="100%">
<thead>
<tr style={{backgroundColor: "#d3d3d3", height: "1.75rem"}}>
<td></td>
{fileDescription.headerLetters.map((letter, index) =>
{
const fields = bulkLoadMapping.getFieldsForColumnIndex(index);
const count = fields.length;
let dupeWarning = <></>
if(fileDescription.hasHeaderRow && fileDescription.duplicateHeaderIndexes[index])
{
dupeWarning = <Tooltip title="This column header is a duplicate. Only the first occurrance of it will be used." placement="top" enterDelay={500}>
<Icon color="warning" sx={{p: "0.125rem", mr: "0.25rem"}}>warning</Icon>
</Tooltip>
}
return (<td key={letter} style={{textAlign: "center", color: getHeaderColor(count), cursor: getCursor(count)}}>
<>
{
count > 0 &&
<Tooltip title={getColumnTooltip(fields)} placement="top" enterDelay={500}>
<Box>
{dupeWarning}
{letter}
<Badge badgeContent={count} variant={"standard"} color="secondary" sx={{marginTop: ".75rem"}}><Icon></Icon></Badge>
</Box>
</Tooltip>
}
{
count == 0 && <Box>{dupeWarning}{letter}</Box>
}
</>
</td>);
})}
</tr>
</thead>
<tbody>
<tr>
<td style={{backgroundColor: "#d3d3d3", textAlign: "center"}}>1</td>
{fileDescription.headerValues.map((value, index) =>
{
const fields = bulkLoadMapping.getFieldsForColumnIndex(index);
const count = fields.length;
const tdStyle = {color: getHeaderColor(count), cursor: getCursor(count), backgroundColor: ""};
if(fileDescription.hasHeaderRow)
{
tdStyle.backgroundColor = "#ebebeb";
if(count > 0)
{
return <td key={value} style={tdStyle}>
<Tooltip title={getColumnTooltip(fields)} placement="top" enterDelay={500}><Box>{value}</Box></Tooltip>
</td>
}
else
{
return <td key={value} style={tdStyle}>{value}</td>
}
}
else
{
return <td key={value} style={tdStyle}>{value}</td>
}
}
)}
</tr>
{rows.map((i) => (
<tr key={i}>
<td style={{backgroundColor: "#d3d3d3", textAlign: "center"}}>{i + 2}</td>
{fileDescription.headerLetters.map((letter, j) => <td key={j}>{getValue(i, j)}</td>)}
</tr>
))}
</tbody>
</table>
</Box>
</Box>
);
}

View File

@ -1,102 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import Box from "@mui/material/Box";
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
import {BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels";
import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun";
import React, {forwardRef, useImperativeHandle, useState} from "react";
interface BulkLoadValueMappingFormProps
{
processValues: any,
tableMetaData: QTableMetaData,
metaData: QInstance
}
/***************************************************************************
** For review & result screens of bulk load - this process component shows
** the SavedBulkLoadProfiles button.
***************************************************************************/
const BulkLoadProfileForm = forwardRef(({processValues, tableMetaData, metaData}: BulkLoadValueMappingFormProps, ref) =>
{
const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord;
const [savedBulkLoadProfileRecord, setSavedBulkLoadProfileRecord] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue))
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
const [bulkLoadProfile, setBulkLoadProfile] = useState(processValues.bulkLoadProfile as BulkLoadProfile);
const [currentMapping, setCurrentMapping] = useState(BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile))
const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper<QRecord>(savedBulkLoadProfileRecord));
const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview));
fileDescription.setHasHeaderRow(currentMapping.hasHeaderRow);
useImperativeHandle(ref, () =>
{
return {
preSubmit(): SubFormPreSubmitCallbackResultType
{
const values: { [name: string]: any } = {};
values["savedBulkLoadProfileId"] = wrappedCurrentSavedBulkLoadProfile.get()?.values?.get("id");
return ({maySubmit: true, values});
}
};
});
/***************************************************************************
**
***************************************************************************/
function bulkLoadProfileOnChangeCallback(profileRecord: QRecord | null)
{
setSavedBulkLoadProfileRecord(profileRecord);
wrappedCurrentSavedBulkLoadProfile.set(profileRecord);
const newBulkLoadMapping = BulkLoadMapping.fromSavedProfileRecord(tableStructure, profileRecord);
setCurrentMapping(newBulkLoadMapping);
}
return (<Box>
<Box py="1rem" display="flex">
<SavedBulkLoadProfiles
metaData={metaData}
tableMetaData={tableMetaData}
tableStructure={tableStructure}
currentSavedBulkLoadProfileRecord={savedBulkLoadProfileRecord}
currentMapping={currentMapping}
allowSelectingProfile={false}
fileDescription={fileDescription}
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
/>
</Box>
</Box>);
});
export default BulkLoadProfileForm;

View File

@ -1,233 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import colors from "qqq/assets/theme/base/colors";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import SavedBulkLoadProfiles from "qqq/components/misc/SavedBulkLoadProfiles";
import {BulkLoadMapping, BulkLoadProfile, BulkLoadTableStructure, FileDescription, Wrapper} from "qqq/models/processes/BulkLoadModels";
import {SubFormPreSubmitCallbackResultType} from "qqq/pages/processes/ProcessRun";
import React, {forwardRef, useEffect, useImperativeHandle, useReducer, useState} from "react";
interface BulkLoadValueMappingFormProps
{
processValues: any,
setActiveStepLabel: (label: string) => void,
tableMetaData: QTableMetaData,
metaData: QInstance,
formFields: any[]
}
/***************************************************************************
** process component used in bulk-load - on a screen that gets looped for
** each field whose values are being mapped.
***************************************************************************/
const BulkLoadValueMappingForm = forwardRef(({processValues, setActiveStepLabel, tableMetaData, metaData, formFields}: BulkLoadValueMappingFormProps, ref) =>
{
const [field, setField] = useState(processValues.valueMappingField ? new QFieldMetaData(processValues.valueMappingField) : null);
const [fieldFullName, setFieldFullName] = useState(processValues.valueMappingFullFieldName);
const [fileValues, setFileValues] = useState((processValues.fileValues ?? []) as string[]);
const [valueErrors, setValueErrors] = useState({} as { [fileValue: string]: any });
const [bulkLoadProfile, setBulkLoadProfile] = useState(processValues.bulkLoadProfile as BulkLoadProfile);
const savedBulkLoadProfileRecordProcessValue = processValues.savedBulkLoadProfileRecord;
const [savedBulkLoadProfileRecord, setSavedBulkLoadProfileRecord] = useState(savedBulkLoadProfileRecordProcessValue == null ? null : new QRecord(savedBulkLoadProfileRecordProcessValue));
const [wrappedCurrentSavedBulkLoadProfile] = useState(new Wrapper<QRecord>(savedBulkLoadProfileRecord));
const [tableStructure] = useState(processValues.tableStructure as BulkLoadTableStructure);
const [currentMapping, setCurrentMapping] = useState(initializeCurrentBulkLoadMapping());
const [wrappedBulkLoadMapping] = useState(new Wrapper<BulkLoadMapping>(currentMapping));
const [fileDescription] = useState(new FileDescription(processValues.headerValues, processValues.headerLetters, processValues.bodyValuesPreview));
fileDescription.setHasHeaderRow(currentMapping.hasHeaderRow);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
/*******************************************************************************
**
*******************************************************************************/
function initializeCurrentBulkLoadMapping(): BulkLoadMapping
{
const bulkLoadMapping = BulkLoadMapping.fromBulkLoadProfile(tableStructure, bulkLoadProfile);
if (!bulkLoadMapping.valueMappings[fieldFullName])
{
bulkLoadMapping.valueMappings[fieldFullName] = {};
}
return (bulkLoadMapping);
}
useEffect(() =>
{
if (processValues.valueMappingField)
{
setField(new QFieldMetaData(processValues.valueMappingField));
}
else
{
setField(null);
}
}, [processValues.valueMappingField]);
////////////////////////////////////////////////////////
// ref-based callback for integration with ProcessRun //
////////////////////////////////////////////////////////
useImperativeHandle(ref, () =>
{
return {
preSubmit(): SubFormPreSubmitCallbackResultType
{
const values: { [name: string]: any } = {};
let anyErrors = false;
const mappedValues = currentMapping.valueMappings[fieldFullName];
if (field.isRequired)
{
for (let fileValue of fileValues)
{
valueErrors[fileValue] = null;
if (mappedValues[fileValue] == null || mappedValues[fileValue] == undefined || mappedValues[fileValue] == "")
{
valueErrors[fileValue] = "A value is required for this mapping";
anyErrors = true;
}
}
}
///////////////////////////////////////////////////
// always re-submit the full profile //
// note mostly a copy in BulkLoadFileMappingForm //
///////////////////////////////////////////////////
const {haveErrors, profile} = wrappedBulkLoadMapping.get().toProfile();
values["version"] = profile.version;
values["fieldListJSON"] = JSON.stringify(profile.fieldList);
values["savedBulkLoadProfileId"] = wrappedCurrentSavedBulkLoadProfile.get()?.values?.get("id");
values["layout"] = wrappedBulkLoadMapping.get().layout;
values["hasHeaderRow"] = wrappedBulkLoadMapping.get().hasHeaderRow;
values["mappedValuesJSON"] = JSON.stringify(mappedValues);
return ({maySubmit: !anyErrors, values});
}
};
});
if (!field)
{
//////////////////////////////////////////////////////////////////////////////////////
// this happens like between steps - render empty rather than a flash of half-stuff //
//////////////////////////////////////////////////////////////////////////////////////
return (<Box></Box>);
}
/***************************************************************************
**
***************************************************************************/
function mappedValueChanged(fileValue: string, newValue: any)
{
valueErrors[fileValue] = null;
if(newValue == null)
{
delete currentMapping.valueMappings[fieldFullName][fileValue];
}
else
{
currentMapping.valueMappings[fieldFullName][fileValue] = newValue;
}
forceUpdate();
}
/***************************************************************************
**
***************************************************************************/
function bulkLoadProfileOnChangeCallback(profileRecord: QRecord | null)
{
setSavedBulkLoadProfileRecord(profileRecord);
wrappedCurrentSavedBulkLoadProfile.set(profileRecord);
const newBulkLoadMapping = BulkLoadMapping.fromSavedProfileRecord(tableStructure, profileRecord);
setCurrentMapping(newBulkLoadMapping);
wrappedBulkLoadMapping.set(newBulkLoadMapping);
}
setActiveStepLabel(`Value Mapping: ${field.label} (${processValues.valueMappingFieldIndex + 1} of ${processValues.fieldNamesToDoValueMapping?.length})`);
return (<Box>
<Box py="1rem" display="flex">
<SavedBulkLoadProfiles
metaData={metaData}
tableMetaData={tableMetaData}
tableStructure={tableStructure}
currentSavedBulkLoadProfileRecord={savedBulkLoadProfileRecord}
currentMapping={currentMapping}
allowSelectingProfile={false}
bulkLoadProfileOnChangeCallback={bulkLoadProfileOnChangeCallback}
fileDescription={fileDescription}
/>
</Box>
{
fileValues.map((fileValue, i) => (
<Box key={i} py="0.5rem" sx={{borderBottom: "0px solid lightgray", width: "100%", overflow: "auto"}}>
<Box display="grid" gridTemplateColumns="40% auto 60%" fontSize="1rem" gap="0.5rem">
<Box mt="0.5rem" textAlign="right">{fileValue}</Box>
<Box mt="0.625rem"><Icon>arrow_forward</Icon></Box>
<Box maxWidth="300px">
<QDynamicFormField
name={`${fieldFullName}.value.${i}`}
displayFormat={""}
label={""}
formFieldObject={formFields[i]}
type={formFields[i].type}
value={currentMapping.valueMappings[fieldFullName][fileValue]}
onChangeCallback={(newValue) => mappedValueChanged(fileValue, newValue)}
/>
{
valueErrors[fileValue] &&
<Box fontSize={"0.875rem"} mt={"-0.75rem"} color={colors.error.main}>
{valueErrors[fileValue]}
</Box>
}
</Box>
</Box>
</Box>
))
}
</Box>);
});
export default BulkLoadValueMappingForm;

View File

@ -84,7 +84,7 @@ function ProcessSummaryResults({
);
return (
<Box m={{xs: 0, md: 3}} mt={"3rem!important"}>
<Box m={3} mt={6}>
<Grid container>
<Grid item xs={0} lg={2} />
<Grid item xs={12} lg={8}>

View File

@ -1,71 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {AdornmentType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/AdornmentType";
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import Grid from "@mui/material/Grid";
import MDTypography from "qqq/components/legacy/MDTypography";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
interface ProcessViewFormProps
{
fields: QFieldMetaData[];
values: { [fieldName: string]: any };
columns?: number;
}
ProcessViewForm.defaultProps = {
columns: 2
};
/***************************************************************************
** a "view form" within a process step
**
***************************************************************************/
export default function ProcessViewForm({fields, values, columns}: ProcessViewFormProps): JSX.Element
{
const sm = Math.floor(12 / columns);
return <Grid container>
{fields.map((field: QFieldMetaData) => (
field.hasAdornment(AdornmentType.ERROR) ? (
values[field.name] && (
<Grid item xs={12} sm={sm} key={field.name} display="flex" py={1} pr={2}>
<MDTypography variant="button" fontWeight="regular">
{ValueUtils.getValueForDisplay(field, values[field.name], undefined, "view")}
</MDTypography>
</Grid>
)
) : (
<Grid item xs={12} sm={sm} key={field.name} display="flex" py={1} pr={2}>
<MDTypography variant="button" fontWeight="bold">
{field.label}
: &nbsp;
</MDTypography>
<MDTypography variant="button" fontWeight="regular" color="text">
{ValueUtils.getValueForDisplay(field, values[field.name], undefined, "view")}
</MDTypography>
</Grid>
)))
}
</Grid>;
}

View File

@ -24,45 +24,29 @@ import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstan
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Button, FormControlLabel, ListItem, Radio, RadioGroup, Typography} from "@mui/material";
import Box from "@mui/material/Box";
import {Box, Button, FormControlLabel, ListItem, Radio, RadioGroup, Typography} from "@mui/material";
import Grid from "@mui/material/Grid";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import List from "@mui/material/List";
import ListItemText from "@mui/material/ListItemText";
import React, {useState} from "react";
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 Client from "qqq/utils/qqq/Client";
import TableUtils from "qqq/utils/qqq/TableUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useEffect, useState} from "react";
interface Props
{
qInstance: QInstance,
process: QProcessMetaData,
table: QTableMetaData,
processValues: any,
step: QFrontendStepMetaData,
previewRecords: QRecord[],
formValues: any,
doFullValidationRadioChangedHandler: any,
loadingRecords?: boolean
}
////////////////////////////////////////////////////////////////////////////
// e.g., for bulk-load, where we want to show associations under a record //
// the processValue will have these data, to drive this screen. //
////////////////////////////////////////////////////////////////////////////
interface AssociationPreview
{
tableName: string;
widgetName: string;
associationName: string;
qInstance: QInstance;
process: QProcessMetaData;
table: QTableMetaData;
processValues: any;
step: QFrontendStepMetaData;
previewRecords: QRecord[];
formValues: any;
doFullValidationRadioChangedHandler: any
}
/*******************************************************************************
@ -71,76 +55,21 @@ interface AssociationPreview
** results when they are available.
*******************************************************************************/
function ValidationReview({
qInstance, process, table = null, processValues, step, previewRecords = [], formValues, doFullValidationRadioChangedHandler, loadingRecords
qInstance, process, table = null, processValues, step, previewRecords = [], formValues, doFullValidationRadioChangedHandler,
}: Props): JSX.Element
{
const [previewRecordIndex, setPreviewRecordIndex] = useState(0);
const [sourceTableMetaData, setSourceTableMetaData] = useState(null as QTableMetaData);
const [previewTableMetaData, setPreviewTableMetaData] = useState(null as QTableMetaData);
const [childTableMetaData, setChildTableMetaData] = useState({} as { [name: string]: QTableMetaData });
const [associationPreviewsByWidgetName, setAssociationPreviewsByWidgetName] = useState({} as { [widgetName: string]: AssociationPreview });
if (processValues.sourceTable && !sourceTableMetaData)
if(processValues.sourceTable && !sourceTableMetaData)
{
(async () =>
{
const sourceTableMetaData = await Client.getInstance().loadTableMetaData(processValues.sourceTable);
const sourceTableMetaData = await Client.getInstance().loadTableMetaData(processValues.sourceTable)
setSourceTableMetaData(sourceTableMetaData);
})();
}
////////////////////////////////////////////////////////////////////////////////////////
// load meta-data and set up associations-data structure, if so directed from backend //
////////////////////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (processValues.formatPreviewRecordUsingTableLayout && !previewTableMetaData)
{
(async () =>
{
const previewTableMetaData = await Client.getInstance().loadTableMetaData(processValues.formatPreviewRecordUsingTableLayout);
setPreviewTableMetaData(previewTableMetaData);
})();
}
try
{
const previewRecordAssociatedTableNames: string[] = processValues.previewRecordAssociatedTableNames ?? [];
const previewRecordAssociatedWidgetNames: string[] = processValues.previewRecordAssociatedWidgetNames ?? [];
const previewRecordAssociationNames: string[] = processValues.previewRecordAssociationNames ?? [];
const associationPreviewsByWidgetName: { [widgetName: string]: AssociationPreview } = {};
for (let i = 0; i < Math.min(previewRecordAssociatedTableNames.length, previewRecordAssociatedWidgetNames.length, previewRecordAssociationNames.length); i++)
{
const associationPreview = {tableName: previewRecordAssociatedTableNames[i], widgetName: previewRecordAssociatedWidgetNames[i], associationName: previewRecordAssociationNames[i]};
associationPreviewsByWidgetName[associationPreview.widgetName] = associationPreview;
}
setAssociationPreviewsByWidgetName(associationPreviewsByWidgetName);
if (Object.keys(associationPreviewsByWidgetName))
{
(async () =>
{
for (let key in associationPreviewsByWidgetName)
{
const associationPreview = associationPreviewsByWidgetName[key];
childTableMetaData[associationPreview.tableName] = await Client.getInstance().loadTableMetaData(associationPreview.tableName);
setChildTableMetaData(Object.assign({}, childTableMetaData));
}
})();
}
}
catch (e)
{
console.log(`Error setting up association previews: ${e}`);
}
}, []);
/***************************************************************************
**
***************************************************************************/
const updatePreviewRecordIndex = (offset: number) =>
{
let newIndex = previewRecordIndex + offset;
@ -156,10 +85,6 @@ function ValidationReview({
setPreviewRecordIndex(newIndex);
};
/***************************************************************************
**
***************************************************************************/
const buildDoFullValidationRadioListItem = (value: "true" | "false", labelText: string, tooltipHTML: JSX.Element): JSX.Element =>
{
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
@ -230,7 +155,7 @@ function ValidationReview({
"false",
"Skip Validation. Submit the records for immediate processing", (
<div>
If you choose this option, the input records will immediately be processed.
If you choose this option, the records input records will immediately be processed.
You will be told how many records were successfully processed, and which ones had issues after the processing is completed.
<br />
<br />
@ -266,7 +191,6 @@ function ValidationReview({
</List>
);
const recordPreviewWidget = step.recordListFields && (
<Box border="1px solid rgb(70%, 70%, 70%)" borderRadius="10px" p={2} mt={2}>
<Box mx={2} mt={-5} p={1} sx={{width: "fit-content", borderColor: "rgb(70%, 70%, 70%)", borderWidth: "2px", borderStyle: "solid", borderRadius: ".25em", backgroundColor: "#FFFFFF"}} width="initial" color="white">
@ -276,47 +200,43 @@ function ValidationReview({
<MDTypography color="body" variant="body2" component="div" mb={2}>
<Box display="flex">
{
loadingRecords ? <i>Loading...</i> : <>
{
processValues?.previewMessage && previewRecords && previewRecords.length > 0 ? (
<>
<i>{processValues?.previewMessage}</i>
<CustomWidthTooltip
title={(
<div>
Note that the number of preview records available may be fewer than the total number of records being processed.
</div>
)}
>
<IconButton sx={{py: 0}}><Icon fontSize="small">info_outlined</Icon></IconButton>
</CustomWidthTooltip>
</>
) : (
<>
<i>No record previews are available at this time.</i>
<CustomWidthTooltip
title={(
<div>
{
processValues.validationSummary ? (
<>
It appears as though this process does not contain any valid records.
</>
) : (
<>
If you choose to Perform Validation, and there are any valid records, then you will see a preview here.
</>
)
}
</div>
)}
>
<IconButton sx={{py: 0}}><Icon fontSize="small">info_outlined</Icon></IconButton>
</CustomWidthTooltip>
</>
)
}
</>
processValues?.previewMessage && previewRecords && previewRecords.length > 0 ? (
<>
<i>{processValues?.previewMessage}</i>
<CustomWidthTooltip
title={(
<div>
Note that the number of preview records available may be fewer than the total number of records being processed.
</div>
)}
>
<IconButton sx={{py: 0}}><Icon fontSize="small">info_outlined</Icon></IconButton>
</CustomWidthTooltip>
</>
) : (
<>
<i>No record previews are available at this time.</i>
<CustomWidthTooltip
title={(
<div>
{
processValues.validationSummary ? (
<>
It appears as though this process does not contain any valid records.
</>
) : (
<>
If you choose to Perform Validation, and there are any valid records, then you will see a preview here.
</>
)
}
</div>
)}
>
<IconButton sx={{py: 0}}><Icon fontSize="small">info_outlined</Icon></IconButton>
</CustomWidthTooltip>
</>
)
}
</Box>
</MDTypography>
@ -324,27 +244,16 @@ function ValidationReview({
<Box sx={{maxHeight: "calc(100vh - 640px)", overflow: "auto", minHeight: "300px", marginRight: "-40px"}}>
<Box sx={{paddingRight: "40px"}}>
{
previewRecords && !processValues.formatPreviewRecordUsingTableLayout && previewRecords[previewRecordIndex] && step.recordListFields.map((field) => (
previewRecords && previewRecords[previewRecordIndex] && step.recordListFields.map((field) => (
<Box key={field.name} style={{marginBottom: "12px"}}>
<b>{`${field.label}:`}</b>
{" "}
&nbsp;
&nbsp;
{" "}
{ValueUtils.getDisplayValue(field, previewRecords[previewRecordIndex], "view")}
</Box>
))
}
{
previewRecords && processValues.formatPreviewRecordUsingTableLayout && previewRecords[previewRecordIndex] &&
<PreviewRecordUsingTableLayout
index={previewRecordIndex}
record={previewRecords[previewRecordIndex]}
tableMetaData={previewTableMetaData}
qInstance={qInstance}
associationPreviewsByWidgetName={associationPreviewsByWidgetName}
childTableMetaData={childTableMetaData}
/>
}
</Box>
</Box>
{
@ -364,7 +273,7 @@ function ValidationReview({
);
return (
<Box m={{xs: 0, md: 3}} mt={"3rem!important"}>
<Box m={3}>
<Grid container spacing={2}>
<Grid item xs={12} lg={6}>
<MDTypography color="body" variant="button">
@ -379,84 +288,4 @@ function ValidationReview({
);
}
interface PreviewRecordUsingTableLayoutProps
{
index: number
record: QRecord,
tableMetaData: QTableMetaData,
qInstance: QInstance,
associationPreviewsByWidgetName: { [widgetName: string]: AssociationPreview },
childTableMetaData: { [name: string]: QTableMetaData },
}
function PreviewRecordUsingTableLayout({record, tableMetaData, qInstance, associationPreviewsByWidgetName, childTableMetaData, index}: PreviewRecordUsingTableLayoutProps): JSX.Element
{
if (!tableMetaData)
{
return (<i>Loading...</i>);
}
const renderedSections: JSX.Element[] = [];
const tableSections = TableUtils.getSectionsForRecordSidebar(tableMetaData);
for (let i = 0; i < tableSections.length; i++)
{
const section = tableSections[i];
if (section.isHidden)
{
continue;
}
if (section.fieldNames)
{
renderedSections.push(<Box mb="1rem">
<Box><h4>{section.label}</h4></Box>
<Box ml="1rem">
{renderSectionOfFields(section.name, section.fieldNames, tableMetaData, false, record, undefined, {label: {fontWeight: "500"}})}
</Box>
</Box>);
}
else if (section.widgetName)
{
const widget = qInstance.widgets.get(section.widgetName);
if (widget)
{
let data: ChildRecordListData = null;
if (associationPreviewsByWidgetName[section.widgetName])
{
const associationPreview = associationPreviewsByWidgetName[section.widgetName];
const associationRecords = record.associatedRecords?.get(associationPreview.associationName) ?? [];
data = {
canAddChildRecord: false,
childTableMetaData: childTableMetaData[associationPreview.tableName],
defaultValuesForNewChildRecords: {},
disabledFieldsForNewChildRecords: {},
queryOutput: {records: associationRecords},
totalRows: associationRecords.length,
tablePath: "",
title: "",
viewAllLink: "",
};
renderedSections.push(<Box mb="1rem">
{
data && <Box>
<Box mb="0.5rem"><h4>{section.label}</h4></Box>
<Box pl="1rem">
<RecordGridWidget key={index} data={data} widgetMetaData={widget} disableRowClick gridOnly={true} gridDensity={"compact"} />
</Box>
</Box>
}
</Box>);
}
}
}
}
return <>{renderedSections}</>;
}
export default ValidationReview;

View File

@ -1,153 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import Box from "@mui/material/Box";
import colors from "qqq/assets/theme/base/colors";
import {validateCriteria} from "qqq/components/query/FilterCriteriaRow";
import XIcon from "qqq/components/query/XIcon";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import React, {useState} from "react";
interface AdvancedQueryPreviewProps
{
tableMetaData: QTableMetaData;
queryFilter: QQueryFilter;
isEditable: boolean;
isQueryTooComplex: boolean;
removeCriteriaByIndexCallback: (index: number) => void;
}
/*******************************************************************************
** Box shown on query screen (and more??) to preview what a query looks like,
** as an "advanced" style/precursor-to-writing-your-own-query thing.
*******************************************************************************/
export default function AdvancedQueryPreview({tableMetaData, queryFilter, isEditable, isQueryTooComplex, removeCriteriaByIndexCallback}: AdvancedQueryPreviewProps): JSX.Element
{
const [mouseOverElement, setMouseOverElement] = useState(null as string);
/*******************************************************************************
**
*******************************************************************************/
function handleMouseOverElement(name: string)
{
setMouseOverElement(name);
}
/*******************************************************************************
**
*******************************************************************************/
function handleMouseOutElement()
{
setMouseOverElement(null);
}
/*******************************************************************************
** format the current query as a string for showing on-screen as a preview.
*******************************************************************************/
const queryToAdvancedString = (thisQueryFilter: QQueryFilter) =>
{
if (queryFilter == null || !queryFilter.criteria)
{
return (<span></span>);
}
let counter = 0;
return (
<React.Fragment>
{thisQueryFilter.criteria?.map((criteria, i) =>
{
const {criteriaIsValid} = validateCriteria(criteria, null);
if (criteriaIsValid)
{
counter++;
return (
<span key={i} style={{marginBottom: "0.125rem"}} onMouseOver={() => handleMouseOverElement(`queryPreview-${i}`)} onMouseOut={() => handleMouseOutElement()}>
{counter > 1 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{thisQueryFilter.booleanOperator}&nbsp;</span> : <span />}
{FilterUtils.criteriaToHumanString(tableMetaData, criteria, true)}
{isEditable && !isQueryTooComplex && (
mouseOverElement == `queryPreview-${i}` && <span className={`advancedQueryPreviewX-${counter - 1}`}>
<XIcon position="forAdvancedQueryPreview" onClick={() => removeCriteriaByIndexCallback(i)} /></span>
)}
{counter > 1 && i == thisQueryFilter.criteria?.length - 1 && thisQueryFilter.subFilters?.length > 0 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{thisQueryFilter.booleanOperator}&nbsp;</span> : <span />}
</span>
);
}
else
{
return (<span />);
}
})}
{thisQueryFilter.subFilters?.length > 0 && (thisQueryFilter.subFilters.map((filter: QQueryFilter, j) =>
{
return (
<React.Fragment key={j}>
{j > 0 ? <span style={{marginLeft: "0.25rem", marginRight: "0.25rem"}}>{thisQueryFilter.booleanOperator}&nbsp;</span> : <span></span>}
<span style={{display: "flex", marginRight: "0.20rem"}}>(</span>
{queryToAdvancedString(filter)}
<span style={{display: "flex", marginRight: "0.20rem"}}>)</span>
</React.Fragment>
);
}))}
</React.Fragment>
);
};
const moreSX = isEditable ?
{
borderTop: `1px solid ${colors.grayLines.main}`,
boxShadow: "inset 0px 0px 4px 2px #EFEFED",
borderRadius: "0 0 0.75rem 0.75rem",
} :
{
borderRadius: "0.75rem",
border: `1px solid ${colors.grayLines.main}`,
}
return (
<Box whiteSpace="nowrap" display="flex" flexShrink={1} flexGrow={1} alignItems="center">
{
<Box
className="advancedQueryString"
display="inline-block"
width="100%"
sx={{fontSize: "1rem", background: "#FFFFFF"}}
minHeight={"2.5rem"}
p={"0.5rem"}
pb={"0.125rem"}
{...moreSX}
>
<Box display="flex" flexWrap="wrap" fontSize="0.875rem">
{queryToAdvancedString(queryFilter)}
</Box>
</Box>
}
</Box>
)
}

View File

@ -1,66 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. 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 {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {FilterVariableExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/FilterVariableExpression";
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import Tooltip from "@mui/material/Tooltip";
import CriteriaDateField from "qqq/components/query/CriteriaDateField";
import React, {SyntheticEvent, useState} from "react";
export type Expression = FilterVariableExpression;
interface AssignFilterButtonProps
{
valueIndex: number;
field: QFieldMetaData;
valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any) => void;
}
CriteriaDateField.defaultProps = {
valueIndex: 0,
label: "Value",
idPrefix: "value-"
};
export default function AssignFilterVariable({valueIndex, field, valueChangeHandler}: AssignFilterButtonProps): JSX.Element
{
const [isValueAVariable, setIsValueAVariable] = useState(false);
const handleVariableButtonOnClick = () =>
{
setIsValueAVariable(!isValueAVariable);
const expression = new FilterVariableExpression({fieldName: field.name, valueIndex: valueIndex});
valueChangeHandler(null, valueIndex, expression);
};
return <Box display="flex" alignItems="flex-end">
<Box>
<Tooltip title={`Use a variable as the value for the ${field.label} field`} placement="bottom">
<Icon fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer", position: "relative", top: "2px"}} onClick={handleVariableButtonOnClick}>functions</Icon>
</Tooltip>
</Box>
</Box>;
}

View File

@ -1,832 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QTableSection} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableSection";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {QFilterOrderBy} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterOrderBy";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
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 Icon from "@mui/material/Icon";
import ToggleButton from "@mui/material/ToggleButton";
import ToggleButtonGroup from "@mui/material/ToggleButtonGroup";
import Tooltip from "@mui/material/Tooltip";
import {GridApiPro} from "@mui/x-data-grid-pro/models/gridApiPro";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import AdvancedQueryPreview from "qqq/components/query/AdvancedQueryPreview";
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
import FieldListMenu from "qqq/components/query/FieldListMenu";
import {validateCriteria} from "qqq/components/query/FilterCriteriaRow";
import QuickFilter, {quickFilterButtonStyles} from "qqq/components/query/QuickFilter";
import XIcon from "qqq/components/query/XIcon";
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import TableUtils from "qqq/utils/qqq/TableUtils";
import React, {forwardRef, useContext, useImperativeHandle, useReducer, useState} from "react";
interface BasicAndAdvancedQueryControlsProps
{
metaData: QInstance;
tableMetaData: QTableMetaData;
savedViewsComponent: JSX.Element;
columnMenuComponent: JSX.Element;
quickFilterFieldNames: string[];
setQuickFilterFieldNames: (names: string[]) => void;
queryFilter: QQueryFilter;
setQueryFilter: (queryFilter: QQueryFilter) => void;
gridApiRef: React.MutableRefObject<GridApiPro>;
/////////////////////////////////////////////////////////////////////////////////////////////
// this prop is used as a way to recognize changes in the query filter internal structure, //
// since the queryFilter object (reference) doesn't get updated //
/////////////////////////////////////////////////////////////////////////////////////////////
queryFilterJSON: string;
queryScreenUsage: QueryScreenUsage;
allowVariables?: boolean;
mode: string;
setMode: (mode: string) => void;
}
let debounceTimeout: string | number | NodeJS.Timeout;
/*******************************************************************************
** function to generate an element that says how a filter is sorted.
*******************************************************************************/
export function getCurrentSortIndicator(queryFilter: QQueryFilter, tableMetaData: QTableMetaData, toggleSortDirection: (event: React.MouseEvent<HTMLSpanElement, MouseEvent>) => void)
{
if (queryFilter && queryFilter.orderBys && queryFilter.orderBys.length > 0)
{
const orderBy = queryFilter.orderBys[0];
const orderByFieldName = orderBy.fieldName;
const [field, fieldTable] = TableUtils.getFieldAndTable(tableMetaData, orderByFieldName);
const fieldLabel = fieldTable.name == tableMetaData.name ? field.label : `${fieldTable.label}: ${field.label}`;
return <>Sort: {fieldLabel} <Icon onClick={toggleSortDirection} sx={{ml: "0.5rem"}}>{orderBy.isAscending ? "arrow_upward" : "arrow_downward"}</Icon></>;
}
else
{
return <>Sort...</>;
}
}
/*******************************************************************************
** Component to provide the basic & advanced query-filter controls for the
** RecordQueryOrig screen.
**
** Done as a forwardRef, so RecordQueryOrig can call some functions, e.g., when user
** does things on that screen, that we need to know about in here.
*******************************************************************************/
const BasicAndAdvancedQueryControls = forwardRef((props: BasicAndAdvancedQueryControlsProps, ref) =>
{
const {metaData, tableMetaData, savedViewsComponent, columnMenuComponent, quickFilterFieldNames, setQuickFilterFieldNames, setQueryFilter, queryFilter, gridApiRef, queryFilterJSON, mode, setMode, queryScreenUsage} = props;
/////////////////////
// state variables //
/////////////////////
const [defaultQuickFilterFieldNames, setDefaultQuickFilterFieldNames] = useState(getDefaultQuickFilterFieldNames(tableMetaData));
const [defaultQuickFilterFieldNameMap, setDefaultQuickFilterFieldNameMap] = useState(Object.fromEntries(defaultQuickFilterFieldNames.map(k => [k, true])));
const [addQuickFilterMenu, setAddQuickFilterMenu] = useState(null);
const [addQuickFilterOpenCounter, setAddQuickFilterOpenCounter] = useState(0);
const [showClearFiltersWarning, setShowClearFiltersWarning] = useState(false);
const [mouseOverElement, setMouseOverElement] = useState(null as string);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const {accentColor} = useContext(QContext);
/////////////////////////////////////////////////
// temporary, until we implement sub-filtering //
/////////////////////////////////////////////////
const [isQueryTooComplex, setIsQueryTooComplex] = useState(false);
//////////////////////////////////////////////////////////////////////////////////
// make some functions available to our parent - so it can tell us to do things //
//////////////////////////////////////////////////////////////////////////////////
useImperativeHandle(ref, () =>
{
return {
ensureAllFilterCriteriaAreActiveQuickFilters(currentFilter: QQueryFilter, reason: string)
{
ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, currentFilter, reason);
},
addField(fieldName: string)
{
addQuickFilterField({fieldName: fieldName}, "columnMenu");
},
getCurrentMode()
{
return (mode);
}
};
});
/*******************************************************************************
**
*******************************************************************************/
function handleMouseOverElement(name: string)
{
setMouseOverElement(name);
}
/*******************************************************************************
**
*******************************************************************************/
function handleMouseOutElement()
{
setMouseOverElement(null);
}
/*******************************************************************************
** for a given field, set its default operator for quick-filter dropdowns.
*******************************************************************************/
function getDefaultOperatorForField(field: QFieldMetaData)
{
// todo - sometimes i want contains instead of equals on strings (client.name, for example...)
let defaultOperator = field?.possibleValueSourceName ? QCriteriaOperator.IN : QCriteriaOperator.EQUALS;
if (field?.type == QFieldType.DATE_TIME)
{
defaultOperator = QCriteriaOperator.GREATER_THAN;
}
else if (field?.type == QFieldType.BOOLEAN)
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// for booleans, if we set a default, since none of them have values, then they are ALWAYS selected, which isn't what we want. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
defaultOperator = null;
}
return defaultOperator;
}
/*******************************************************************************
** Callback passed into the QuickFilter component, to update the criteria
** after user makes changes to it or to clear it out.
*******************************************************************************/
const updateQuickCriteria = (newCriteria: QFilterCriteria, needDebounce = false, doClearCriteria = false) =>
{
let found = false;
let foundIndex = null;
for (let i = 0; i < queryFilter?.criteria?.length; i++)
{
if (queryFilter.criteria[i].fieldName == newCriteria.fieldName)
{
queryFilter.criteria[i] = newCriteria;
found = true;
foundIndex = i;
break;
}
}
if (doClearCriteria)
{
if (found)
{
queryFilter.criteria.splice(foundIndex, 1);
setQueryFilter(queryFilter);
}
return;
}
if (!found)
{
if (!queryFilter.criteria)
{
queryFilter.criteria = [];
}
queryFilter.criteria.push(newCriteria);
found = true;
}
if (found)
{
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(() =>
{
setQueryFilter(queryFilter);
}, needDebounce ? 500 : 1);
forceUpdate();
}
};
/*******************************************************************************
** Get the QFilterCriteriaWithId object to pass in to the QuickFilter component
** for a given field name.
*******************************************************************************/
const getQuickCriteriaParam = (fieldName: string): QFilterCriteriaWithId | null | "tooComplex" =>
{
const matches: QFilterCriteriaWithId[] = [];
for (let i = 0; i < queryFilter?.criteria?.length; i++)
{
if (queryFilter.criteria[i].fieldName == fieldName)
{
matches.push(queryFilter.criteria[i] as QFilterCriteriaWithId);
}
}
if (matches.length == 0)
{
return (null);
}
else if (matches.length == 1)
{
return (matches[0]);
}
else
{
return "tooComplex";
}
};
/*******************************************************************************
** Event handler for QuickFilter component, to remove a quick filter field from
** the screen.
*******************************************************************************/
const handleRemoveQuickFilterField = (fieldName: string): void =>
{
const index = quickFilterFieldNames.indexOf(fieldName);
if (index >= 0)
{
//////////////////////////////////////
// remove this field from the query //
//////////////////////////////////////
const criteria = new QFilterCriteria(fieldName, null, []);
updateQuickCriteria(criteria, false, true);
quickFilterFieldNames.splice(index, 1);
setQuickFilterFieldNames(quickFilterFieldNames);
}
};
/*******************************************************************************
** Event handler for button that opens the add-quick-filter menu
*******************************************************************************/
const openAddQuickFilterMenu = (event: any) =>
{
setAddQuickFilterMenu(event.currentTarget);
setAddQuickFilterOpenCounter(addQuickFilterOpenCounter + 1);
};
/*******************************************************************************
** Handle closing the add-quick-filter menu
*******************************************************************************/
const closeAddQuickFilterMenu = () =>
{
setAddQuickFilterMenu(null);
};
/*******************************************************************************
** Add a quick-filter field to the screen, from either the user selecting one,
** or from a new query being activated, etc.
*******************************************************************************/
const addQuickFilterField = (newValue: any, reason: "blur" | "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | "columnMenu" | "activatedView" | string) =>
{
console.log(`Adding quick filter field as: ${JSON.stringify(newValue)}`);
if (reason == "blur")
{
//////////////////////////////////////////////////////////////////
// this keeps a click out of the menu from selecting the option //
//////////////////////////////////////////////////////////////////
return;
}
const fieldName = newValue ? newValue.fieldName : null;
if (fieldName)
{
if (defaultQuickFilterFieldNameMap[fieldName])
{
return;
}
if (quickFilterFieldNames.indexOf(fieldName) == -1)
{
/////////////////////////////////
// add the field if we need to //
/////////////////////////////////
quickFilterFieldNames.push(fieldName);
setQuickFilterFieldNames(quickFilterFieldNames);
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// only do this when user has added the field (e.g., not when adding it because of a selected view or filter-in-url) //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (reason != "modeToggleClicked" && reason != "defaultFilterLoaded" && reason != "savedFilterSelected" && reason != "activatedView")
{
setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5);
}
}
else if (reason == "columnMenu")
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if field was already on-screen, but user clicked an option from the columnMenu, then open the quick-filter field //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
setTimeout(() => document.getElementById(`quickFilter.${fieldName}`)?.click(), 5);
}
closeAddQuickFilterMenu();
}
};
/*******************************************************************************
**
*******************************************************************************/
const handleFieldListMenuSelection = (field: QFieldMetaData, table: QTableMetaData): void =>
{
let fullFieldName = field.name;
if (table && table.name != tableMetaData.name)
{
fullFieldName = `${table.name}.${field.name}`;
}
addQuickFilterField({fieldName: fullFieldName}, "selectedFromAddFilterMenu");
};
/*******************************************************************************
** event handler for the Filter Builder button - e.g., opens the parent's grid's
** filter panel
*******************************************************************************/
const openFilterBuilder = (e: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>) =>
{
if (!isQueryTooComplex)
{
gridApiRef.current.showFilterPanel();
}
};
/*******************************************************************************
** event handler for the clear-filters modal
*******************************************************************************/
const handleClearFiltersAction = (event: React.KeyboardEvent<HTMLDivElement>, isYesButton: boolean = false) =>
{
if (isYesButton || event.key == "Enter")
{
setShowClearFiltersWarning(false);
setQueryFilter(new QQueryFilter([], queryFilter.orderBys));
}
};
/*******************************************************************************
**
*******************************************************************************/
const removeCriteriaByIndex = (index: number) =>
{
queryFilter.criteria.splice(index, 1);
setQueryFilter(queryFilter);
};
/*******************************************************************************
** event handler for toggling between modes - basic & advanced.
*******************************************************************************/
const modeToggleClicked = (newValue: string | null) =>
{
if (newValue)
{
if (newValue == "basic")
{
////////////////////////////////////////////////////////////////////////////////
// we're always allowed to go to advanced - //
// but if we're trying to go to basic, make sure the filter isn't too complex //
////////////////////////////////////////////////////////////////////////////////
const {canFilterWorkAsBasic} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter);
if (!canFilterWorkAsBasic)
{
console.log("Query cannot work as basic - so - not allowing toggle to basic.");
return;
}
////////////////////////////////////////////////////////////////////////////////////////////////
// when going to basic, make sure all fields in the current query are active as quick-filters //
////////////////////////////////////////////////////////////////////////////////////////////////
if (queryFilter && queryFilter.criteria)
{
ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "modeToggleClicked", "basic");
}
}
//////////////////////////////////////////////////////////////////////////////////////
// note - this is a callback to the parent - as it is responsible for this state... //
//////////////////////////////////////////////////////////////////////////////////////
setMode(newValue);
}
};
/*******************************************************************************
** make sure that all fields in the current query are on-screen as quick-filters
** (that is, if the query can be basic)
*******************************************************************************/
const ensureAllFilterCriteriaAreActiveQuickFilters = (tableMetaData: QTableMetaData, queryFilter: QQueryFilter, reason: "modeToggleClicked" | "defaultFilterLoaded" | "savedFilterSelected" | string, newMode?: string) =>
{
if (!tableMetaData || !queryFilter)
{
return;
}
//////////////////////////////////////////////
// set a flag if the query is 'too complex' //
//////////////////////////////////////////////
setIsQueryTooComplex(queryFilter.subFilters?.length > 0);
const {canFilterWorkAsBasic} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter);
if (!canFilterWorkAsBasic)
{
console.log("query is too complex for basic - so - switching to advanced");
modeToggleClicked("advanced");
forceUpdate();
return;
}
const modeToUse = newMode ?? mode;
if (modeToUse == "basic")
{
for (let i = 0; i < queryFilter?.criteria?.length; i++)
{
const criteria = queryFilter.criteria[i];
if (criteria && criteria.fieldName)
{
addQuickFilterField(criteria, reason);
}
}
}
};
/*******************************************************************************
** count how many valid criteria are in the query - for showing badge
*******************************************************************************/
const countValidCriteria = (queryFilter: QQueryFilter): number =>
{
let count = 0;
for (let i = 0; i < queryFilter?.criteria?.length; i++)
{
const {criteriaIsValid} = validateCriteria(queryFilter.criteria[i], null);
if (criteriaIsValid)
{
count++;
}
}
/////////////////////////////////////////////////////////////
// recursively add any children filters to the total count //
/////////////////////////////////////////////////////////////
for (let i = 0; i < queryFilter.subFilters?.length; i++)
{
count += countValidCriteria(queryFilter.subFilters[i]);
}
return count;
};
/*******************************************************************************
** Event handler for setting the sort from that menu
*******************************************************************************/
const handleSetSort = (field: QFieldMetaData, table: QTableMetaData, isAscending: boolean = true): void =>
{
const fullFieldName = table && table.name != tableMetaData.name ? `${table.name}.${field.name}` : field.name;
queryFilter.orderBys = [new QFilterOrderBy(fullFieldName, isAscending)];
setQueryFilter(queryFilter);
forceUpdate();
};
/*******************************************************************************
** event handler for a click on a field's up or down arrow in the sort menu
*******************************************************************************/
const handleSetSortArrowClick = (field: QFieldMetaData, table: QTableMetaData, event: any): void =>
{
event.stopPropagation();
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// make sure this is an event handler for one of our icons (not something else in the dom here in our end-adornments) //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const isAscending = event.target.innerHTML == "arrow_upward";
const isDescending = event.target.innerHTML == "arrow_downward";
if (isAscending || isDescending)
{
handleSetSort(field, table, isAscending);
}
};
/*******************************************************************************
** event handler for clicking the current sort up/down arrow, to toggle direction.
*******************************************************************************/
function toggleSortDirection(event: React.MouseEvent<HTMLSpanElement, MouseEvent>): void
{
event.stopPropagation();
try
{
queryFilter.orderBys[0].isAscending = !queryFilter.orderBys[0].isAscending;
setQueryFilter(queryFilter);
forceUpdate();
}
catch (e)
{
console.log(`Error toggling sort: ${e}`);
}
}
/////////////////////////////////
// set up the sort menu button //
/////////////////////////////////
let sortButtonContents = getCurrentSortIndicator(queryFilter, tableMetaData, toggleSortDirection);
////////////////////////////////////////////////////////////////////////////////////////////////////////////
// this is being used as a version of like forcing that we get re-rendered if the query filter changes... //
////////////////////////////////////////////////////////////////////////////////////////////////////////////
const [lastIndex, setLastIndex] = useState(queryFilterJSON);
if (queryFilterJSON != lastIndex)
{
ensureAllFilterCriteriaAreActiveQuickFilters(tableMetaData, queryFilter, "defaultFilterLoaded");
setLastIndex(queryFilterJSON);
}
///////////////////////////////////////////////////
// set some status flags based on current filter //
///////////////////////////////////////////////////
const hasValidFilters = queryFilter && countValidCriteria(queryFilter) > 0;
const {canFilterWorkAsBasic, canFilterWorkAsAdvanced, reasonsWhyItCannot} = FilterUtils.canFilterWorkAsBasic(tableMetaData, queryFilter);
let reasonWhyBasicIsDisabled = null;
if (canFilterWorkAsAdvanced && reasonsWhyItCannot && reasonsWhyItCannot.length > 0)
{
reasonWhyBasicIsDisabled = <>
Your current Filter cannot be managed using Basic mode because:
<ul style={{marginLeft: "1rem"}}>
{reasonsWhyItCannot.map((reason, i) => <li key={i}>{reason}</li>)}
</ul>
</>;
}
if (isQueryTooComplex)
{
reasonWhyBasicIsDisabled = <>
Your current Filter is too complex to modify because it contains Sub-filters.
</>;
}
const borderGray = colors.grayLines.main;
const sortMenuComponent = (
<FieldListMenu
idPrefix="sort"
tableMetaData={tableMetaData}
placeholder="Search Fields"
buttonProps={{disableRipple: true, sx: {textTransform: "none", color: colors.gray.main, paddingRight: 0}}}
buttonChildren={sortButtonContents}
isModeSelectOne={true}
handleSelectedField={handleSetSort}
fieldEndAdornment={<Box whiteSpace="nowrap"><Icon>arrow_upward</Icon><Icon>arrow_downward</Icon></Box>}
handleAdornmentClick={handleSetSortArrowClick}
/>);
const filterBuilderMouseEvents =
{
onMouseOver: () => handleMouseOverElement("filterBuilderButton"),
onMouseOut: () => handleMouseOutElement()
};
return (
<Box pb={mode == "advanced" ? "0.25rem" : "0"}>
{/* First row: Saved Views button (with Columns button in the middle of it), then space-between, then basic|advanced toggle */}
<Box display="flex" justifyContent="space-between" pt={"0.5rem"} pb={"0.5rem"}>
<Box display="flex">
{savedViewsComponent}
{columnMenuComponent}
</Box>
<Box>
<Tooltip title={reasonWhyBasicIsDisabled}>
<ToggleButtonGroup
value={mode}
exclusive
onChange={(event, newValue) => modeToggleClicked(newValue)}
size="small"
sx={{pl: 0.5, width: "10rem"}}
>
<ToggleButton value="basic" disabled={!canFilterWorkAsBasic}>Basic</ToggleButton>
<ToggleButton value="advanced">Advanced</ToggleButton>
</ToggleButtonGroup>
</Tooltip>
</Box>
</Box>
{/* Second row: Basic or advanced mode - with sort-by control on the right (of each) */}
<Box pb={"0.25rem"}>
{
///////////////////////////////////////////////////////////////////////////////////
// basic mode - wrapping-list of fields & add-field button, then sort-by control //
///////////////////////////////////////////////////////////////////////////////////
mode == "basic" &&
<Box display="flex" alignItems="flex-start" flexShrink={1} flexGrow={1}>
<Box width="100px" flexShrink={1} flexGrow={1}>
<>
{
tableMetaData && defaultQuickFilterFieldNames?.map((fieldName) =>
{
const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
let defaultOperator = getDefaultOperatorForField(field);
return (<QuickFilter
key={fieldName}
allowVariables={props.allowVariables}
fullFieldName={fieldName}
tableMetaData={tableMetaData}
updateCriteria={updateQuickCriteria}
criteriaParam={getQuickCriteriaParam(fieldName)}
fieldMetaData={field}
defaultOperator={defaultOperator}
queryScreenUsage={queryScreenUsage}
handleRemoveQuickFilterField={null} />);
})
}
{/* vertical rule */}
<Box display="inline-block" borderLeft={`1px solid ${borderGray}`} height="1.75rem" width="1px" marginRight="0.5rem" position="relative" top="0.5rem" />
{
tableMetaData && quickFilterFieldNames?.map((fieldName) =>
{
const [field] = TableUtils.getFieldAndTable(tableMetaData, fieldName);
let defaultOperator = getDefaultOperatorForField(field);
return (defaultQuickFilterFieldNameMap[fieldName] ? null : <QuickFilter
key={fieldName}
fullFieldName={fieldName}
tableMetaData={tableMetaData}
updateCriteria={updateQuickCriteria}
criteriaParam={getQuickCriteriaParam(fieldName)}
fieldMetaData={field}
allowVariables={props.allowVariables}
defaultOperator={defaultOperator}
queryScreenUsage={queryScreenUsage}
handleRemoveQuickFilterField={handleRemoveQuickFilterField} />);
})
}
{
tableMetaData && <FieldListMenu
key={JSON.stringify(quickFilterFieldNames)} // use a unique key each time we open it, because we don't want the user's last selection to stick.
idPrefix="addQuickFilter"
tableMetaData={tableMetaData}
fieldNamesToHide={[...(defaultQuickFilterFieldNames ?? []), ...(quickFilterFieldNames ?? [])]}
placeholder="Search Fields"
buttonProps={{sx: quickFilterButtonStyles, startIcon: (<Icon>add</Icon>)}}
buttonChildren={"Add Filter"}
isModeSelectOne={true}
handleSelectedField={handleFieldListMenuSelection}
/>
}
</>
</Box>
<Box>
{sortMenuComponent}
</Box>
</Box>
}
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
// advanced mode - 2 rows - one for Filter Builder button & sort control, 2nd row for the filter-detail box //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
metaData && tableMetaData && mode == "advanced" &&
<Box borderRadius="0.75rem" border={`1px solid ${borderGray}`}>
<Box display="flex" justifyContent="space-between" alignItems="center">
<Box p="0.5rem">
<Tooltip enterDelay={500} title="Build an advanced Filter" placement="top">
<>
<Button
className="filterBuilderButton"
onClick={(e) => openFilterBuilder(e)}
{...filterBuilderMouseEvents}
startIcon={<Icon>build</Icon>}
sx={{borderRadius: "0.75rem", padding: "0.5rem", pl: "1rem", fontSize: "0.875rem", fontWeight: 500, border: `1px solid ${accentColor}`, textTransform: "none"}}
>
Filter Builder
{
countValidCriteria(queryFilter) > 0 &&
<Box {...filterBuilderMouseEvents} sx={{backgroundColor: accentColor, marginLeft: "0.25rem", minWidth: "1rem", fontSize: "0.75rem"}} borderRadius="50%" color="#FFFFFF" position="relative" top="-2px" className="filterBuilderCountBadge">
{countValidCriteria(queryFilter)}
</Box>
}
</Button>
{
hasValidFilters && mouseOverElement == "filterBuilderButton" && <span {...filterBuilderMouseEvents} className="filterBuilderXIcon"><XIcon shade="accent" position="default" onClick={() => setShowClearFiltersWarning(true)} /></span>
}
</>
</Tooltip>
<Dialog open={showClearFiltersWarning} onClose={() => setShowClearFiltersWarning(false)} onKeyPress={(e) => handleClearFiltersAction(e)}>
<DialogTitle id="alert-dialog-title">Confirm</DialogTitle>
<DialogContent>
<DialogContentText>Are you sure you want to remove all conditions from the current filter?</DialogContentText>
</DialogContent>
<DialogActions>
<QCancelButton label="No" disabled={false} onClickHandler={() => setShowClearFiltersWarning(false)} />
<QSaveButton label="Yes" iconName="check" disabled={false} onClickHandler={() => handleClearFiltersAction(null, true)} />
</DialogActions>
</Dialog>
</Box>
<Box pr={"0.5rem"}>
{sortMenuComponent}
</Box>
</Box>
<AdvancedQueryPreview tableMetaData={tableMetaData} queryFilter={queryFilter} isEditable={true} isQueryTooComplex={isQueryTooComplex} removeCriteriaByIndexCallback={removeCriteriaByIndex} />
</Box>
}
</Box>
</Box>
);
});
export function getDefaultQuickFilterFieldNames(table: QTableMetaData): string[]
{
const defaultQuickFilterFieldNames: string[] = [];
//////////////////////////////////////////////////////////////////////////////////////////////////
// check if there's materialDashboard tableMetaData, and if it has defaultQuickFilterFieldNames //
//////////////////////////////////////////////////////////////////////////////////////////////////
const mdbMetaData = table?.supplementalTableMetaData?.get("materialDashboard");
if (mdbMetaData)
{
if (mdbMetaData?.defaultQuickFilterFieldNames?.length)
{
for (let i = 0; i < mdbMetaData.defaultQuickFilterFieldNames.length; i++)
{
defaultQuickFilterFieldNames.push(mdbMetaData.defaultQuickFilterFieldNames[i]);
}
}
}
/////////////////////////////////////////////
// if still none, then look for T1 section //
/////////////////////////////////////////////
if (defaultQuickFilterFieldNames.length == 0)
{
if (table.sections)
{
const t1Sections = table.sections.filter((s: QTableSection) => s.tier == "T1");
if (t1Sections.length)
{
for (let i = 0; i < t1Sections.length; i++)
{
if (t1Sections[i].fieldNames)
{
for (let j = 0; j < t1Sections[i].fieldNames.length; j++)
{
defaultQuickFilterFieldNames.push(t1Sections[i].fieldNames[j]);
}
}
}
}
}
}
return (defaultQuickFilterFieldNames);
}
export default BasicAndAdvancedQueryControls;

View File

@ -21,7 +21,6 @@
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {FilterVariableExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/FilterVariableExpression";
import {NowExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/NowExpression";
import {NowWithOffsetExpression, NowWithOffsetOperator, NowWithOffsetUnit} from "@kingsrook/qqq-frontend-core/lib/model/query/NowWithOffsetExpression";
import {ThisOrLastPeriodExpression, ThisOrLastPeriodOperator, ThisOrLastPeriodUnit} from "@kingsrook/qqq-frontend-core/lib/model/query/ThisOrLastPeriodExpression";
@ -35,14 +34,14 @@ import MenuItem from "@mui/material/MenuItem";
import {styled} from "@mui/material/styles";
import TextField from "@mui/material/TextField";
import Tooltip, {tooltipClasses, TooltipProps} from "@mui/material/Tooltip";
import React, {SyntheticEvent, useEffect, useReducer, useState} from "react";
import AdvancedDateTimeFilterValues from "qqq/components/query/AdvancedDateTimeFilterValues";
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
import {EvaluatedExpression} from "qqq/components/query/EvaluatedExpression";
import {makeTextField} from "qqq/components/query/FilterCriteriaRowValues";
import React, {SyntheticEvent, useReducer, useState} from "react";
export type Expression = NowWithOffsetExpression | ThisOrLastPeriodExpression | NowExpression | FilterVariableExpression;
export type Expression = NowWithOffsetExpression | ThisOrLastPeriodExpression | NowExpression;
interface CriteriaDateFieldProps
@ -53,7 +52,6 @@ interface CriteriaDateFieldProps
field: QFieldMetaData;
criteria: QFilterCriteriaWithId;
valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any) => void;
allowVariables?: boolean;
}
CriteriaDateField.defaultProps = {
@ -62,30 +60,19 @@ CriteriaDateField.defaultProps = {
idPrefix: "value-"
};
export const NoWrapTooltip = styled(({className, children, ...props}: TooltipProps) => (
<Tooltip {...props} classes={{popper: className}}>{children}</Tooltip>
))({
[`& .${tooltipClasses.tooltip}`]: {
whiteSpace: "nowrap"
},
});
export default function CriteriaDateField({valueIndex, label, idPrefix, field, criteria, valueChangeHandler, allowVariables}: CriteriaDateFieldProps): JSX.Element
export default function CriteriaDateField({valueIndex, label, idPrefix, field, criteria, valueChangeHandler}: CriteriaDateFieldProps): JSX.Element
{
const [relativeDateTimeOpen, setRelativeDateTimeOpen] = useState(false);
const [relativeDateTimeMenuAnchorElement, setRelativeDateTimeMenuAnchorElement] = useState(null);
const [forceAdvancedDateTimeDialogOpen, setForceAdvancedDateTimeDialogOpen] = useState(false);
const [forceAdvancedDateTimeDialogOpen, setForceAdvancedDateTimeDialogOpen] = useState(false)
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const openRelativeDateTimeMenu = (event: React.MouseEvent<HTMLElement>) =>
{
setRelativeDateTimeOpen(true);
setRelativeDateTimeMenuAnchorElement(event.currentTarget);
};
const closeRelativeDateTimeMenu = () =>
{
setRelativeDateTimeOpen(false);
setRelativeDateTimeMenuAnchorElement(null);
};
@ -150,12 +137,20 @@ export default function CriteriaDateField({valueIndex, label, idPrefix, field, c
const isExpression = criteria.values && criteria.values[valueIndex] && criteria.values[valueIndex].type;
const currentExpression = isExpression ? criteria.values[valueIndex] : null;
const NoWrapTooltip = styled(({className, children, ...props}: TooltipProps) => (
<Tooltip {...props} classes={{popper: className}}>{children}</Tooltip>
))({
[`& .${tooltipClasses.tooltip}`]: {
whiteSpace: "nowrap"
},
});
const tooltipMenuItemFromExpression = (valueIndex: number, tooltipPlacement: "left" | "right", expression: Expression) =>
{
let startOfPrefix = "";
if (expression.type == "ThisOrLastPeriod")
if(expression.type == "ThisOrLastPeriod")
{
if (field.type == QFieldType.DATE_TIME || expression.timeUnit != "DAYS")
if(field.type == QFieldType.DATE_TIME || expression.timeUnit != "DAYS")
{
startOfPrefix = "start of ";
}
@ -196,120 +191,84 @@ export default function CriteriaDateField({valueIndex, label, idPrefix, field, c
setTimeout(() => setForceAdvancedDateTimeDialogOpen(false), 100);
}
const makeFilterVariableTextField = (expression: FilterVariableExpression, valueIndex: number = 0, label = "Value", idPrefix = "value-") =>
{
const clearValue = (event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>, index: number) =>
{
valueChangeHandler(event, index, "");
document.getElementById(`${idPrefix}${criteria.id}`).focus();
};
const inputProps2: any = {};
inputProps2.endAdornment = (
<InputAdornment position="end">
<IconButton sx={{visibility: expression ? "visible" : "hidden"}} onClick={(event) => clearValue(event, valueIndex)}>
<Icon>closer</Icon>
</IconButton>
</InputAdornment>
);
return <NoWrapTooltip title={<EvaluatedExpression field={field} expression={expression} />} placement="bottom" enterDelay={1000} sx={{marginLeft: "-75px !important", marginTop: "-8px !important"}}><TextField
id={`${idPrefix}${criteria.id}`}
label={label}
variant="standard"
autoComplete="off"
InputProps={{disabled: true, readOnly: true, unselectable: "off", ...inputProps2}}
InputLabelProps={{shrink: true}}
value="${VARIABLE}"
fullWidth
/></NoWrapTooltip>;
};
return <Box display="flex" alignItems="flex-end">
{
isExpression ?
currentExpression?.type == "FilterVariableExpression" ? (
makeFilterVariableTextField(criteria.values[valueIndex], valueIndex, label, idPrefix)
) : (
makeDateTimeExpressionTextField(criteria.values[valueIndex], valueIndex, label, idPrefix)
)
: makeTextField(field, criteria, valueChangeHandler, valueIndex, label, idPrefix, allowVariables)
isExpression ? makeDateTimeExpressionTextField(criteria.values[valueIndex], valueIndex, label, idPrefix)
: makeTextField(field, criteria, valueChangeHandler, valueIndex, label, idPrefix)
}
{
(!isExpression || currentExpression?.type != "FilterVariableExpression") && (
<><Box>
<Tooltip title={`Choose a common relative ${field.type == QFieldType.DATE ? "date" : "date-time"} expression`} placement="bottom">
<Icon fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer", position: "relative", top: "2px"}} onClick={openRelativeDateTimeMenu}>date_range</Icon>
</Tooltip>
<Menu
open={relativeDateTimeOpen}
anchorEl={relativeDateTimeMenuAnchorElement}
transformOrigin={{horizontal: "left", vertical: "top"}}
onClose={closeRelativeDateTimeMenu}
>
{field.type == QFieldType.DATE ?
<Box display="flex">
<Box>
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 7, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 14, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 30, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 90, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 180, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 1, "YEARS"))}
<Divider />
<Tooltip title="Define a custom expression" placement="left">
<MenuItem onClick={doForceAdvancedDateTimeDialogOpen}>Custom</MenuItem>
</Tooltip>
</Box>
<Box>
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "WEEKS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "WEEKS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "MONTHS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "MONTHS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "YEARS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "YEARS"))}
</Box>
<Box>
<Tooltip title={`Choose a common relative ${field.type == QFieldType.DATE ? "date" : "date-time"} expression`} placement="bottom">
<Icon fontSize="small" color="info" sx={{mx: 0.25, cursor: "pointer", position: "relative", top: "2px"}} onClick={openRelativeDateTimeMenu}>date_range</Icon>
</Tooltip>
<Menu
open={relativeDateTimeMenuAnchorElement}
anchorEl={relativeDateTimeMenuAnchorElement}
transformOrigin={{horizontal: "left", vertical: "top"}}
onClose={closeRelativeDateTimeMenu}
>
{
field.type == QFieldType.DATE ?
<Box display="flex">
<Box>
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 7, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 14, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 30, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 90, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 180, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 1, "YEARS"))}
<Divider />
<Tooltip title="Define a custom expression" placement="left">
<MenuItem onClick={doForceAdvancedDateTimeDialogOpen}>Custom</MenuItem>
</Tooltip>
</Box>
:
<Box display="flex">
<Box>
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 1, "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 12, "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 24, "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 7, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 14, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 30, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 90, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 180, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 1, "YEARS"))}
<Divider />
<Tooltip title="Define a custom expression" placement="left">
<MenuItem onClick={doForceAdvancedDateTimeDialogOpen}>Custom</MenuItem>
</Tooltip>
</Box>
<Box>
{tooltipMenuItemFromExpression(valueIndex, "right", newNowExpression())}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "WEEKS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "WEEKS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "MONTHS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "MONTHS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "YEARS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "YEARS"))}
</Box>
</Box>}
</Menu>
</Box><Box>
<AdvancedDateTimeFilterValues type={field.type} expression={currentExpression} onSave={(expression: any) => saveNewDateTimeExpression(valueIndex, expression)} forcedOpen={forceAdvancedDateTimeDialogOpen} />
</Box></>
)
}
<Box>
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "WEEKS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "WEEKS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "MONTHS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "MONTHS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "YEARS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "YEARS"))}
</Box>
</Box>
:
<Box display="flex">
<Box>
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 1, "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 12, "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 24, "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 7, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 14, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 30, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 90, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 180, "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "left", newNowWithOffsetExpression("MINUS", 1, "YEARS"))}
<Divider />
<Tooltip title="Define a custom expression" placement="left">
<MenuItem onClick={doForceAdvancedDateTimeDialogOpen}>Custom</MenuItem>
</Tooltip>
</Box>
<Box>
{tooltipMenuItemFromExpression(valueIndex, "right", newNowExpression())}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "HOURS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "DAYS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "WEEKS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "WEEKS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "MONTHS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "MONTHS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("THIS", "YEARS"))}
{tooltipMenuItemFromExpression(valueIndex, "right", newThisOrLastPeriodExpression("LAST", "YEARS"))}
</Box>
</Box>
}
</Menu>
</Box>
<Box>
<AdvancedDateTimeFilterValues type={field.type} expression={currentExpression} onSave={(expression: any) => saveNewDateTimeExpression(valueIndex, expression)} forcedOpen={forceAdvancedDateTimeDialogOpen} />
</Box>
</Box>;
}

View File

@ -21,9 +21,7 @@
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {FormControlLabel, FormGroup} from "@mui/material";
import Box from "@mui/material/Box";
import {Box, FormControlLabel, FormGroup} from "@mui/material";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
@ -58,7 +56,7 @@ export const CustomColumnsPanel = forwardRef<any, GridColumnsPanelProps>(
const someRef = createRef();
const textRef = useRef(null);
const [didInitialFocus, setDidInitialFocus] = useState(false);
const [didInitialFocus, setDidInitialFocus] = useState(false)
const [openGroups, setOpenGroups] = useState(props.initialOpenedGroups || {});
const openGroupsBecauseOfFilter = {} as { [name: string]: boolean };
@ -73,9 +71,9 @@ export const CustomColumnsPanel = forwardRef<any, GridColumnsPanelProps>(
console.log(`Open groups: ${JSON.stringify(openGroups)}`);
if (!didInitialFocus)
if(!didInitialFocus)
{
if (textRef.current)
if(textRef.current)
{
textRef.current.select();
setDidInitialFocus(true);
@ -191,11 +189,11 @@ export const CustomColumnsPanel = forwardRef<any, GridColumnsPanelProps>(
///////////////////////////////////////////////////////////////////////////////////////////////////////
// always sort columns by label. note, in future may offer different sorts - here's where to do it. //
///////////////////////////////////////////////////////////////////////////////////////////////////////
const sortedColumns = [...columns];
const sortedColumns = [... columns];
sortedColumns.sort((a, b): number =>
{
return a.headerName.localeCompare(b.headerName);
});
})
for (let i = 0; i < sortedColumns.length; i++)
{
@ -363,7 +361,7 @@ export const CustomColumnsPanel = forwardRef<any, GridColumnsPanelProps>(
const changeFilterText = (newValue: string) =>
{
setFilterText(newValue);
props.filterTextChanger(newValue);
props.filterTextChanger(newValue)
};
const filterTextChanged = (event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) =>

View File

@ -28,8 +28,8 @@ import Box from "@mui/material/Box";
import Button from "@mui/material/Button/Button";
import Icon from "@mui/material/Icon/Icon";
import {GridFilterPanelProps, GridSlotsComponentsProps} from "@mui/x-data-grid-pro";
import {FilterCriteriaRow, getDefaultCriteriaValue} from "qqq/components/query/FilterCriteriaRow";
import React, {forwardRef, useReducer} from "react";
import {FilterCriteriaRow, getDefaultCriteriaValue} from "qqq/components/query/FilterCriteriaRow";
declare module "@mui/x-data-grid"
@ -49,7 +49,7 @@ declare module "@mui/x-data-grid"
export class QFilterCriteriaWithId extends QFilterCriteria
{
id: number;
id: number
}
@ -62,7 +62,6 @@ export const CustomFilterPanel = forwardRef<any, GridFilterPanelProps>(
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const queryFilter = props.queryFilter;
// console.log(`CustomFilterPanel: filter: ${JSON.stringify(queryFilter)}`);
function focusLastField()
@ -125,7 +124,7 @@ export const CustomFilterPanel = forwardRef<any, GridFilterPanelProps>(
}
}
if (queryFilter.criteria.length == 1 && !queryFilter.criteria[0].fieldName)
if(queryFilter.criteria.length == 1 && !queryFilter.criteria[0].fieldName)
{
focusLastField();
}
@ -143,7 +142,7 @@ export const CustomFilterPanel = forwardRef<any, GridFilterPanelProps>(
{
queryFilter.criteria[index] = newCriteria;
clearTimeout(debounceTimeout);
clearTimeout(debounceTimeout)
debounceTimeout = setTimeout(() => props.updateFilter(queryFilter), needDebounce ? 500 : 1);
forceUpdate();
@ -179,8 +178,6 @@ export const CustomFilterPanel = forwardRef<any, GridFilterPanelProps>(
updateCriteria={(newCriteria, needDebounce) => updateCriteria(newCriteria, index, needDebounce)}
removeCriteria={() => removeCriteria(index)}
updateBooleanOperator={(newValue) => updateBooleanOperator(newValue)}
allowVariables={props.allowVariables}
queryScreenUsage={props.queryScreenUsage}
/>
{/*JSON.stringify(criteria)*/}
</Box>

View File

@ -1,122 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {TablePagination} from "@mui/material";
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import {GridRowsProp} from "@mui/x-data-grid-pro";
import React from "react";
import CustomWidthTooltip from "qqq/components/tooltips/CustomWidthTooltip";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
interface CustomPaginationProps
{
tableMetaData: QTableMetaData;
rows: GridRowsProp[];
totalRecords: number;
distinctRecords: number;
pageNumber: number;
rowsPerPage: number;
loading: boolean;
isJoinMany: boolean;
handlePageChange: (value: number) => void;
handleRowsPerPageChange: (value: number) => void;
}
/*******************************************************************************
** DataGrid custom component - for pagination!
*******************************************************************************/
export default function CustomPaginationComponent({tableMetaData, rows, totalRecords, distinctRecords, pageNumber, rowsPerPage, loading, isJoinMany, handlePageChange, handleRowsPerPageChange}: CustomPaginationProps): JSX.Element
{
// @ts-ignore
const defaultLabelDisplayedRows = ({from, to, count}) =>
{
const tooltipHTML = <>
The number of rows shown on this screen may be greater than the number of {tableMetaData?.label} records
that match your query, because you have included fields from other tables which may have
more than one record associated with each {tableMetaData?.label}.
</>
let distinctPart = isJoinMany ? (<Box display="inline" component="span" textAlign="right">
&nbsp;({ValueUtils.safeToLocaleString(distinctRecords)} distinct<CustomWidthTooltip title={tooltipHTML}>
<IconButton sx={{p: 0, pl: 0.25, mb: 0.25}}><Icon fontSize="small" sx={{fontSize: "1.125rem !important", color: "#9f9f9f"}}>info_outlined</Icon></IconButton>
</CustomWidthTooltip>
)
</Box>) : <></>;
if (tableMetaData && !tableMetaData.capabilities.has(Capability.TABLE_COUNT))
{
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// to avoid a non-countable table showing (this is what data-grid did) 91-100 even if there were only 95 records, //
// we'll do this... not quite good enough, but better than the original //
////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (rows.length > 0 && rows.length < to - from)
{
to = from + rows.length;
}
return (`Showing ${from.toLocaleString()} to ${to.toLocaleString()}`);
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// treat -1 as the sentinel that it's set as below -- remember, we did that so that 'to' would have a value in here when there's no count. //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (count !== null && count !== undefined && count !== -1)
{
if (count === 0)
{
return (loading ? "Counting..." : "No rows");
}
return <span>
Showing {from.toLocaleString()} to {to.toLocaleString()} of
{
count == -1 ?
<>more than {to.toLocaleString()}</>
: <> {count.toLocaleString()}{distinctPart}</>
}
</span>;
}
else
{
return ("Counting...");
}
};
return (
<TablePagination
component="div"
sx={{minWidth: "450px"}}
// note - passing null here makes the 'to' param in the defaultLabelDisplayedRows also be null,
// so pass a sentinel value of -1...
count={totalRecords === null || totalRecords === undefined ? -1 : totalRecords}
page={pageNumber}
rowsPerPageOptions={[10, 25, 50, 100, 250]}
rowsPerPage={rowsPerPage}
onPageChange={(event, value) => handlePageChange(value)}
onRowsPerPageChange={(event) => handleRowsPerPageChange(Number(event.target.value))}
labelDisplayedRows={defaultLabelDisplayedRows}
/>
);
}

View File

@ -21,9 +21,9 @@
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import React, {useEffect, useState} from "react";
import {Expression} from "qqq/components/query/CriteriaDateField";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useEffect, useState} from "react";
/*******************************************************************************
** Helper component to show value inside tooltips that ticks up every second.
@ -50,18 +50,13 @@ export function EvaluatedExpression({field, expression}: EvaluatedExpressionProp
return () => clearInterval(interval);
}, []);
return <span style={{fontVariantNumeric: "tabular-nums"}}>{`${evaluateExpression(timeForEvaluations, field, expression)}`}</span>;
return <>{`${evaluateExpression(timeForEvaluations, field, expression)}`}</>;
}
const HOUR_MS = 60 * 60 * 1000;
const DAY_MS = 24 * 60 * 60 * 1000;
const evaluateExpression = (time: Date, field: QFieldMetaData, expression: Expression): string =>
{
if (expression.type == "FilterVariableExpression")
{
return (expression.toString());
}
let rs: Date = null;
if (expression.type == "NowWithOffset")
{

View File

@ -1,136 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QQueryFilter} from "@kingsrook/qqq-frontend-core/lib/model/query/QQueryFilter";
import MenuItem from "@mui/material/MenuItem";
import {GridColDef, GridExportMenuItemProps} from "@mui/x-data-grid-pro";
import QContext from "QContext";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {useContext} from "react";
interface QExportMenuItemProps extends GridExportMenuItemProps<{}>
{
tableMetaData: QTableMetaData;
totalRecords: number
columnsModel: GridColDef[];
columnVisibilityModel: { [index: string]: boolean };
queryFilter: QQueryFilter;
format: string;
}
/*******************************************************************************
** Component to serve as an item in the Export menu
*******************************************************************************/
export default function ExportMenuItem(props: QExportMenuItemProps)
{
const {format, tableMetaData, totalRecords, columnsModel, columnVisibilityModel, queryFilter, hideMenu} = props;
const {recordAnalytics} = useContext(QContext);
return (
<MenuItem
disabled={totalRecords === 0}
onClick={() =>
{
recordAnalytics({category: "tableEvents", action: "export", label: tableMetaData.label});
///////////////////////////////////////////////////////////////////////////////
// build the list of visible fields. note, not doing them in-order (in case //
// the user did drag & drop), because column order model isn't right yet //
// so just doing them to match columns (which were pKey, then sorted) //
///////////////////////////////////////////////////////////////////////////////
const visibleFields: string[] = [];
columnsModel.forEach((gridColumn) =>
{
const fieldName = gridColumn.field;
if (columnVisibilityModel[fieldName] !== false)
{
visibleFields.push(fieldName);
}
});
//////////////////////////////////////
// construct the url for the export //
//////////////////////////////////////
const dateString = ValueUtils.formatDateTimeForFileName(new Date());
const filename = `${tableMetaData.label} Export ${dateString}.${format}`;
const url = `/data/${tableMetaData.name}/export/${filename}`;
const encodedFilterJSON = encodeURIComponent(JSON.stringify(queryFilter));
//////////////////////////////////////////////////////////////////////////////////////
// open a window (tab) with a little page that says the file is being generated. //
// then have that page load the url for the export. //
// If there's an error, it'll appear in that window. else, the file will download. //
//////////////////////////////////////////////////////////////////////////////////////
const exportWindow = window.open("", "_blank");
exportWindow.document.write(`<html lang="en">
<head>
<style>
* { font-family: "SF Pro Display","Roboto","Helvetica","Arial",sans-serif; }
</style>
<title>${filename}</title>
<script>
setTimeout(() =>
{
//////////////////////////////////////////////////////////////////////////////////////////////////
// need to encode and decode this value, so set it in the form here, instead of literally below //
//////////////////////////////////////////////////////////////////////////////////////////////////
document.getElementById("filter").value = decodeURIComponent("${encodedFilterJSON}");
document.getElementById("exportForm").submit();
}, 1);
</script>
</head>
<body>
Generating file <u>${filename}</u>${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}...
<form id="exportForm" method="post" action="${url}" >
<input type="hidden" name="fields" value="${visibleFields.join(",")}">
<input type="hidden" name="filter" id="filter">
</form>
</body>
</html>`);
/*
// todo - probably better - generate the report in an iframe...
// only open question is, giving user immediate feedback, and knowing when the stream has started and/or stopped
// maybe a busy-loop that would check iframe's url (e.g., after posting should change, maybe?)
const iframe = document.getElementById("exportIFrame");
const form = iframe.querySelector("form");
form.action = url;
form.target = "exportIFrame";
(iframe.querySelector("#authorizationInput") as HTMLInputElement).value = qController.getAuthorizationHeaderValue();
form.submit();
*/
///////////////////////////////////////////
// Hide the export menu after the export //
///////////////////////////////////////////
hideMenu?.();
}}
>
Export
{` ${format.toUpperCase()}`}
</MenuItem>
);
}

View File

@ -1,736 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import FormControlLabel from "@mui/material/FormControlLabel";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import List from "@mui/material/List/List";
import ListItem, {ListItemProps} from "@mui/material/ListItem/ListItem";
import Menu from "@mui/material/Menu";
import Switch from "@mui/material/Switch";
import TextField from "@mui/material/TextField";
import React, {useState} from "react";
interface FieldListMenuProps
{
idPrefix: string;
heading?: string;
placeholder?: string;
tableMetaData: QTableMetaData;
showTableHeaderEvenIfNoExposedJoins: boolean;
fieldNamesToHide?: string[];
buttonProps: any;
buttonChildren: JSX.Element | string;
isModeSelectOne?: boolean;
handleSelectedField?: (field: QFieldMetaData, table: QTableMetaData) => void;
isModeToggle?: boolean;
toggleStates?: {[fieldName: string]: boolean};
handleToggleField?: (field: QFieldMetaData, table: QTableMetaData, newValue: boolean) => void;
fieldEndAdornment?: JSX.Element
handleAdornmentClick?: (field: QFieldMetaData, table: QTableMetaData, event: React.MouseEvent<any>) => void;
}
FieldListMenu.defaultProps = {
showTableHeaderEvenIfNoExposedJoins: false,
isModeSelectOne: false,
isModeToggle: false,
};
interface TableWithFields
{
table?: QTableMetaData;
fields: QFieldMetaData[];
}
/*******************************************************************************
** Component to render a list of fields from a table (and its join tables)
** which can be interacted with...
*******************************************************************************/
export default function FieldListMenu({idPrefix, heading, placeholder, tableMetaData, showTableHeaderEvenIfNoExposedJoins, buttonProps, buttonChildren, isModeSelectOne, fieldNamesToHide, handleSelectedField, isModeToggle, toggleStates, handleToggleField, fieldEndAdornment, handleAdornmentClick}: FieldListMenuProps): JSX.Element
{
const [menuAnchorElement, setMenuAnchorElement] = useState(null);
const [searchText, setSearchText] = useState("");
const [focusedIndex, setFocusedIndex] = useState(null as number);
const [fieldsByTable, setFieldsByTable] = useState([] as TableWithFields[]);
const [collapsedTables, setCollapsedTables] = useState({} as {[tableName: string]: boolean});
const [lastMouseOverXY, setLastMouseOverXY] = useState({x: 0, y: 0});
const [timeOfLastArrow, setTimeOfLastArrow] = useState(0)
//////////////////
// check usages //
//////////////////
if(isModeSelectOne)
{
if(!handleSelectedField)
{
throw("In FieldListMenu, if isModeSelectOne=true, then a callback for handleSelectedField must be provided.");
}
}
if(isModeToggle)
{
if(!toggleStates)
{
throw("In FieldListMenu, if isModeToggle=true, then a model for toggleStates must be provided.");
}
if(!handleToggleField)
{
throw("In FieldListMenu, if isModeToggle=true, then a callback for handleToggleField must be provided.");
}
}
/////////////////////
// init some stuff //
/////////////////////
if (fieldsByTable.length == 0)
{
collapsedTables[tableMetaData.name] = false;
if (tableMetaData.exposedJoins?.length > 0)
{
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if we have exposed joins, put the table meta data with its fields, and then all of the join tables & fields too //
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
fieldsByTable.push({table: tableMetaData, fields: getTableFieldsAsAlphabeticalArray(tableMetaData)});
for (let i = 0; i < tableMetaData.exposedJoins?.length; i++)
{
const joinTable = tableMetaData.exposedJoins[i].joinTable;
fieldsByTable.push({table: joinTable, fields: getTableFieldsAsAlphabeticalArray(joinTable)});
collapsedTables[joinTable.name] = false;
}
}
else
{
///////////////////////////////////////////////////////////
// no exposed joins - just the table (w/o its meta-data) //
///////////////////////////////////////////////////////////
fieldsByTable.push({fields: getTableFieldsAsAlphabeticalArray(tableMetaData)});
}
setFieldsByTable(fieldsByTable);
setCollapsedTables(collapsedTables);
}
/*******************************************************************************
**
*******************************************************************************/
function getTableFieldsAsAlphabeticalArray(table: QTableMetaData): QFieldMetaData[]
{
const fields: QFieldMetaData[] = [];
table.fields.forEach(field =>
{
let fullFieldName = field.name;
if(table.name != tableMetaData.name)
{
fullFieldName = `${table.name}.${field.name}`;
}
if(fieldNamesToHide && fieldNamesToHide.indexOf(fullFieldName) > -1)
{
return;
}
fields.push(field)
});
fields.sort((a, b) => a.label.localeCompare(b.label));
return (fields);
}
const fieldsByTableToShow: TableWithFields[] = [];
let maxFieldIndex = 0;
fieldsByTable.forEach((tableWithFields) =>
{
let fieldsToShowForThisTable = tableWithFields.fields.filter(doesFieldMatchSearchText);
if (fieldsToShowForThisTable.length > 0)
{
fieldsByTableToShow.push({table: tableWithFields.table, fields: fieldsToShowForThisTable});
maxFieldIndex += fieldsToShowForThisTable.length;
}
});
/*******************************************************************************
**
*******************************************************************************/
function getShownFieldAndTableByIndex(targetIndex: number): {field: QFieldMetaData, table: QTableMetaData}
{
let index = -1;
for (let i = 0; i < fieldsByTableToShow.length; i++)
{
const tableWithField = fieldsByTableToShow[i];
for (let j = 0; j < tableWithField.fields.length; j++)
{
index++;
if(index == targetIndex)
{
return {field: tableWithField.fields[j], table: tableWithField.table}
}
}
}
return (null);
}
/*******************************************************************************
** event handler for keys presses
*******************************************************************************/
function keyDown(event: any)
{
// console.log(`Event key: ${event.key}`);
setTimeout(() => document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus());
if(isModeSelectOne && event.key == "Enter" && focusedIndex != null)
{
setTimeout(() =>
{
event.stopPropagation();
closeMenu();
const {field, table} = getShownFieldAndTableByIndex(focusedIndex);
if (field)
{
handleSelectedField(field, table ?? tableMetaData);
}
});
return;
}
const keyOffsetMap: { [key: string]: number } = {
"End": 10000,
"Home": -10000,
"ArrowDown": 1,
"ArrowUp": -1,
"PageDown": 5,
"PageUp": -5,
};
const offset = keyOffsetMap[event.key];
if (offset)
{
event.stopPropagation();
setTimeOfLastArrow(new Date().getTime());
if (isModeSelectOne)
{
let startIndex = focusedIndex;
if (offset > 0)
{
/////////////////
// a down move //
/////////////////
if(startIndex == null)
{
startIndex = -1;
}
let goalIndex = startIndex + offset;
if(goalIndex > maxFieldIndex - 1)
{
goalIndex = maxFieldIndex - 1;
}
doSetFocusedIndex(goalIndex, true);
}
else
{
////////////////
// an up move //
////////////////
let goalIndex = startIndex + offset;
if(goalIndex < 0)
{
goalIndex = 0;
}
doSetFocusedIndex(goalIndex, true);
}
}
}
}
/*******************************************************************************
**
*******************************************************************************/
function doSetFocusedIndex(i: number, tryToScrollIntoView: boolean): void
{
if (isModeSelectOne)
{
setFocusedIndex(i);
console.log(`Setting index to ${i}`);
if (tryToScrollIntoView)
{
const element = document.getElementById(`field-list-dropdown-${idPrefix}-${i}`);
element?.scrollIntoView({block: "center"});
}
}
}
/*******************************************************************************
**
*******************************************************************************/
function setFocusedField(field: QFieldMetaData, table: QTableMetaData, tryToScrollIntoView: boolean)
{
let index = -1;
for (let i = 0; i < fieldsByTableToShow.length; i++)
{
const tableWithField = fieldsByTableToShow[i];
for (let j = 0; j < tableWithField.fields.length; j++)
{
const loopField = tableWithField.fields[j];
index++;
const tableMatches = (table == null || table.name == tableWithField.table.name);
if (tableMatches && field.name == loopField.name)
{
doSetFocusedIndex(index, tryToScrollIntoView);
return;
}
}
}
}
/*******************************************************************************
** event handler for mouse-over the menu
*******************************************************************************/
function handleMouseOver(event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLDivElement> | React.MouseEvent<HTMLLIElement>, field: QFieldMetaData, table: QTableMetaData)
{
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// so we're trying to fix the case where, if you put your mouse over an element, but then press up/down arrows, //
// where the mouse will become over a different element after the scroll, and the focus will follow the mouse instead of keyboard. //
// the last x/y isn't really useful, because the mouse generally isn't left exactly where it was when the mouse-over happened (edge of the element) //
// but the keyboard last-arrow time that we capture, that's what's actually being useful in here //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if(event.clientX == lastMouseOverXY.x && event.clientY == lastMouseOverXY.y)
{
// console.log("mouse didn't move, so, doesn't count");
return;
}
const now = new Date().getTime();
// console.log(`Compare now [${now}] to last arrow [${timeOfLastArrow}] (diff: [${now - timeOfLastArrow}])`);
if(now < timeOfLastArrow + 300)
{
// console.log("An arrow event happened less than 300 mills ago, so doesn't count.");
return;
}
// console.log("yay, mouse over...");
setFocusedField(field, table, false);
setLastMouseOverXY({x: event.clientX, y: event.clientY});
}
/*******************************************************************************
** event handler for text input changes
*******************************************************************************/
function updateSearch(event: React.ChangeEvent<HTMLInputElement>)
{
setSearchText(event?.target?.value ?? "");
doSetFocusedIndex(0, true);
}
/*******************************************************************************
**
*******************************************************************************/
function doesFieldMatchSearchText(field: QFieldMetaData): boolean
{
if (searchText == "")
{
return (true);
}
const columnLabelMinusTable = field.label.replace(/.*: /, "");
if (columnLabelMinusTable.toLowerCase().startsWith(searchText.toLowerCase()))
{
return (true);
}
try
{
////////////////////////////////////////////////////////////
// try to match word-boundary followed by the filter text //
// e.g., "name" would match "First Name" or "Last Name" //
////////////////////////////////////////////////////////////
const re = new RegExp("\\b" + searchText.toLowerCase());
if (columnLabelMinusTable.toLowerCase().match(re))
{
return (true);
}
}
catch (e)
{
//////////////////////////////////////////////////////////////////////////////////
// in case text is an invalid regex... well, at least do a starts-with match... //
//////////////////////////////////////////////////////////////////////////////////
if (columnLabelMinusTable.toLowerCase().startsWith(searchText.toLowerCase()))
{
return (true);
}
}
const tableLabel = field.label.replace(/:.*/, "");
if (tableLabel)
{
try
{
////////////////////////////////////////////////////////////
// try to match word-boundary followed by the filter text //
// e.g., "name" would match "First Name" or "Last Name" //
////////////////////////////////////////////////////////////
const re = new RegExp("\\b" + searchText.toLowerCase());
if (tableLabel.toLowerCase().match(re))
{
return (true);
}
}
catch (e)
{
//////////////////////////////////////////////////////////////////////////////////
// in case text is an invalid regex... well, at least do a starts-with match... //
//////////////////////////////////////////////////////////////////////////////////
if (tableLabel.toLowerCase().startsWith(searchText.toLowerCase()))
{
return (true);
}
}
}
return (false);
}
/*******************************************************************************
**
*******************************************************************************/
function openMenu(event: any)
{
setFocusedIndex(null);
setMenuAnchorElement(event.currentTarget);
setTimeout(() =>
{
document.getElementById(`field-list-dropdown-${idPrefix}-textField`).focus();
doSetFocusedIndex(0, true);
});
}
/*******************************************************************************
**
*******************************************************************************/
function closeMenu()
{
setMenuAnchorElement(null);
}
/*******************************************************************************
** Event handler for toggling a field in toggle mode
*******************************************************************************/
function handleFieldToggle(event: React.ChangeEvent<HTMLInputElement>, field: QFieldMetaData, table: QTableMetaData)
{
event.stopPropagation();
handleToggleField(field, table, event.target.checked);
}
/*******************************************************************************
** Event handler for toggling a table in toggle mode
*******************************************************************************/
function handleTableToggle(event: React.ChangeEvent<HTMLInputElement>, table: QTableMetaData)
{
event.stopPropagation();
const fieldsList = [...table.fields.values()];
for (let i = 0; i < fieldsList.length; i++)
{
const field = fieldsList[i];
if(doesFieldMatchSearchText(field))
{
handleToggleField(field, table, event.target.checked);
}
}
}
/////////////////////////////////////////////////////////
// compute the table-level toggle state & count values //
/////////////////////////////////////////////////////////
const tableToggleStates: {[tableName: string]: boolean} = {};
const tableToggleCounts: {[tableName: string]: number} = {};
if(isModeToggle)
{
const {allOn, count} = getTableToggleState(tableMetaData, true);
tableToggleStates[tableMetaData.name] = allOn;
tableToggleCounts[tableMetaData.name] = count;
for (let i = 0; i < tableMetaData.exposedJoins?.length; i++)
{
const join = tableMetaData.exposedJoins[i];
const {allOn, count} = getTableToggleState(join.joinTable, false);
tableToggleStates[join.joinTable.name] = allOn;
tableToggleCounts[join.joinTable.name] = count;
}
}
/*******************************************************************************
**
*******************************************************************************/
function getTableToggleState(table: QTableMetaData, isMainTable: boolean): {allOn: boolean, count: number}
{
const fieldsList = [...table.fields.values()];
let allOn = true;
let count = 0;
for (let i = 0; i < fieldsList.length; i++)
{
const field = fieldsList[i];
const name = isMainTable ? field.name : `${table.name}.${field.name}`;
if(!toggleStates[name])
{
allOn = false;
}
else
{
count++;
}
}
return ({allOn: allOn, count: count});
}
/*******************************************************************************
**
*******************************************************************************/
function toggleCollapsedTable(tableName: string)
{
collapsedTables[tableName] = !collapsedTables[tableName]
setCollapsedTables(Object.assign({}, collapsedTables));
}
/*******************************************************************************
**
*******************************************************************************/
function doHandleAdornmentClick(field: QFieldMetaData, table: QTableMetaData, event: React.MouseEvent<any>)
{
console.log("In doHandleAdornmentClick");
closeMenu();
handleAdornmentClick(field, table, event);
}
let index = -1;
const textFieldId = `field-list-dropdown-${idPrefix}-textField`;
let listItemPadding = isModeToggle ? "0.125rem": "0.5rem";
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
// for z-indexes, we set each table header to i+1, then the fields in that table to i (so they go behind it) //
// then we increment i by 2 for the next table (so the next header goes above the previous header) //
// this fixes a thing where, if one table's name wrapped to 2 lines, then when the next table below it would //
// come up, if it was only 1 line, then the second line from the previous one would bleed through. //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////
let zIndex = 1;
return (
<>
<Button onClick={openMenu} {...buttonProps}>
{buttonChildren}
</Button>
<Menu
anchorEl={menuAnchorElement}
anchorOrigin={{vertical: "bottom", horizontal: "left"}}
transformOrigin={{vertical: "top", horizontal: "left"}}
open={menuAnchorElement != null}
onClose={closeMenu}
onKeyDown={keyDown} // this is added here so arrow-key-up/down events don't make the whole menu become "focused" (blue outline). it works.
keepMounted
>
<Box width={isModeToggle ? "305px" : "265px"} borderRadius={2} className={`fieldListMenuBody fieldListMenuBody-${idPrefix}`}>
{
heading &&
<Box px={1} py={0.5} fontWeight={"700"}>
{heading}
</Box>
}
<Box p={1} pt={0.5}>
<TextField id={textFieldId} variant="outlined" placeholder={placeholder ?? "Search Fields"} fullWidth value={searchText} onChange={updateSearch} onKeyDown={keyDown} inputProps={{sx: {pr: "2rem"}}} />
{
searchText != "" && <IconButton sx={{position: "absolute", right: "0.5rem", top: "0.5rem"}} onClick={() =>
{
updateSearch(null);
document.getElementById(textFieldId).focus();
}}><Icon fontSize="small">close</Icon></IconButton>
}
</Box>
<Box maxHeight={"445px"} overflow="auto" mr={"-0.5rem"} sx={{scrollbarGutter: "stable"}}>
<List sx={{px: "0.5rem", cursor: "default"}}>
{
fieldsByTableToShow.map((tableWithFields) =>
{
let headerContents = null;
const headerTable = tableWithFields.table || tableMetaData;
if(tableWithFields.table || showTableHeaderEvenIfNoExposedJoins)
{
headerContents = (<b>{headerTable.label} Fields</b>);
}
if(isModeToggle)
{
headerContents = (<FormControlLabel
sx={{display: "flex", alignItems: "flex-start", "& .MuiFormControlLabel-label": {lineHeight: "1.4", fontWeight: "500 !important"}}}
control={<Switch
size="small"
sx={{top: "1px"}}
checked={tableToggleStates[headerTable.name]}
onChange={(event) => handleTableToggle(event, headerTable)}
/>}
label={<span style={{marginTop: "0.25rem", display: "inline-block"}}><b>{headerTable.label} Fields</b>&nbsp;<span style={{fontWeight: 400}}>({tableToggleCounts[headerTable.name]})</span></span>} />)
}
if(isModeToggle)
{
headerContents = (
<>
<IconButton
onClick={() => toggleCollapsedTable(headerTable.name)}
sx={{justifyContent: "flex-start", fontSize: "0.875rem", pt: 0.5, pb: 0, mr: "0.25rem"}}
disableRipple={true}
>
<Icon sx={{fontSize: "1.5rem !important", position: "relative", top: "2px"}}>{collapsedTables[headerTable.name] ? "expand_less" : "expand_more"}</Icon>
</IconButton>
{headerContents}
</>
)
}
let marginLeft = "unset";
if(isModeToggle)
{
marginLeft = "-1rem";
}
zIndex += 2;
return (
<React.Fragment key={tableWithFields.table?.name ?? "theTable"}>
<>
{headerContents && <ListItem sx={{position: "sticky", top: -1, zIndex: zIndex+1, padding: listItemPadding, ml: marginLeft, display: "flex", alignItems: "flex-start", backgroundImage: "linear-gradient(to bottom, rgba(255,255,255,1), rgba(255,255,255,1) 90%, rgba(255,255,255,0))"}}>{headerContents}</ListItem>}
{
tableWithFields.fields.map((field) =>
{
index++;
const key = `${tableWithFields.table?.name}-${field.name}`
if(collapsedTables[headerTable.name])
{
return (<React.Fragment key={key} />);
}
let style = {};
if (index == focusedIndex)
{
style = {backgroundColor: "#EFEFEF"};
}
const onClick: ListItemProps = {};
if (isModeSelectOne)
{
onClick.onClick = () =>
{
closeMenu();
handleSelectedField(field, tableWithFields.table ?? tableMetaData);
}
}
let label: JSX.Element | string = field.label;
const fullFieldName = tableWithFields.table && tableWithFields.table.name != tableMetaData.name ? `${tableWithFields.table.name}.${field.name}` : field.name;
if(fieldEndAdornment)
{
label = <Box width="100%" display="inline-flex" justifyContent="space-between">
{label}
<Box onClick={(event) => doHandleAdornmentClick(field, tableWithFields.table, event)}>
{fieldEndAdornment}
</Box>
</Box>;
}
let contents = <>{label}</>;
let paddingLeft = "0.5rem";
if (isModeToggle)
{
contents = (<FormControlLabel
sx={{display: "flex", alignItems: "flex-start", "& .MuiFormControlLabel-label": {lineHeight: "1.4", color: "#606060", fontWeight: "500 !important"}}}
control={<Switch
size="small"
sx={{top: "-3px"}}
checked={toggleStates[fullFieldName]}
onChange={(event) => handleFieldToggle(event, field, tableWithFields.table)}
/>}
label={label} />);
paddingLeft = "2.5rem";
}
return <ListItem
key={key}
id={`field-list-dropdown-${idPrefix}-${index}`}
sx={{color: "#757575", p: 1, borderRadius: ".5rem", padding: listItemPadding, pl: paddingLeft, scrollMarginTop: "3rem", zIndex: zIndex, background: "#FFFFFF", ...style}}
onMouseOver={(event) => handleMouseOver(event, field, tableWithFields.table)}
{...onClick}
>{contents}</ListItem>;
})
}
</>
</React.Fragment>
);
})
}
{
index == -1 && <ListItem sx={{p: "0.5rem"}}><i>No fields found.</i></ListItem>
}
</List>
</Box>
</Box>
</Menu>
</>
);
}

View File

@ -24,7 +24,7 @@ import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstan
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import Autocomplete from "@mui/material/Autocomplete";
import Autocomplete, {AutocompleteRenderOptionState} from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import FormControl from "@mui/material/FormControl/FormControl";
import Icon from "@mui/material/Icon/Icon";
@ -33,11 +33,9 @@ import MenuItem from "@mui/material/MenuItem";
import Select, {SelectChangeEvent} from "@mui/material/Select/Select";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import FieldAutoComplete from "qqq/components/misc/FieldAutoComplete";
import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues";
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import React, {ReactNode, SyntheticEvent, useState} from "react";
import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
export enum ValueMode
@ -54,27 +52,6 @@ export enum ValueMode
PVS_MULTI = "PVS_MULTI",
}
export const getValueModeRequiredCount = (valueMode: ValueMode): number =>
{
switch (valueMode)
{
case ValueMode.NONE:
return (0);
case ValueMode.SINGLE:
case ValueMode.SINGLE_DATE:
case ValueMode.SINGLE_DATE_TIME:
case ValueMode.PVS_SINGLE:
return (1);
case ValueMode.DOUBLE:
case ValueMode.DOUBLE_DATE:
case ValueMode.DOUBLE_DATE_TIME:
return (2);
case ValueMode.MULTI:
case ValueMode.PVS_MULTI:
return (null);
}
};
export interface OperatorOption
{
label: string;
@ -184,7 +161,7 @@ export const getOperatorOptions = (tableMetaData: QTableMetaData, fieldName: str
}
return (operatorOptions);
};
}
interface FilterCriteriaRowProps
@ -198,82 +175,47 @@ interface FilterCriteriaRowProps
updateCriteria: (newCriteria: QFilterCriteria, needDebounce: boolean) => void;
removeCriteria: () => void;
updateBooleanOperator: (newValue: string) => void;
queryScreenUsage?: QueryScreenUsage;
allowVariables?: boolean;
}
FilterCriteriaRow.defaultProps =
{};
FilterCriteriaRow.defaultProps = {};
export function validateCriteria(criteria: QFilterCriteria, operatorSelectedValue?: OperatorOption): { criteriaIsValid: boolean, criteriaStatusTooltip: string }
function makeFieldOptionsForTable(tableMetaData: QTableMetaData, fieldOptions: any[], isJoinTable: boolean)
{
let criteriaIsValid = true;
let criteriaStatusTooltip = "This condition is fully defined and is part of your filter.";
function isNotSet(value: any)
const sortedFields = [...tableMetaData.fields.values()].sort((a, b) => a.label.localeCompare(b.label));
for (let i = 0; i < sortedFields.length; i++)
{
return (value === null || value == undefined || String(value).trim() === "");
const fieldName = isJoinTable ? `${tableMetaData.name}.${sortedFields[i].name}` : sortedFields[i].name;
fieldOptions.push({field: sortedFields[i], table: tableMetaData, fieldName: fieldName});
}
if (!criteria)
{
criteriaIsValid = false;
criteriaStatusTooltip = "This condition is not defined.";
return {criteriaIsValid, criteriaStatusTooltip};
}
if (!criteria.fieldName)
{
criteriaIsValid = false;
criteriaStatusTooltip = "You must select a field to begin to define this condition.";
}
else if (!criteria.operator)
{
criteriaIsValid = false;
criteriaStatusTooltip = "You must select an operator to continue to define this condition.";
}
else
{
if (criteria.operator == QCriteriaOperator.IS_BLANK || criteria.operator == QCriteriaOperator.IS_NOT_BLANK)
{
//////////////////////////////////
// don't need to look at values //
//////////////////////////////////
}
else if (criteria.operator == QCriteriaOperator.BETWEEN || criteria.operator == QCriteriaOperator.NOT_BETWEEN)
{
if (criteria.values.length < 2 || isNotSet(criteria.values[0]) || isNotSet(criteria.values[1]))
{
criteriaIsValid = false;
criteriaStatusTooltip = "You must enter two values to complete the definition of this condition.";
}
}
else if (criteria.operator == QCriteriaOperator.IN || criteria.operator == QCriteriaOperator.NOT_IN)
{
if (criteria.values.length < 1 || isNotSet(criteria.values[0]))
{
criteriaIsValid = false;
criteriaStatusTooltip = "You must enter one or more values complete the definition of this condition.";
}
}
else
{
if (!criteria.values || isNotSet(criteria.values[0]))
{
criteriaIsValid = false;
criteriaStatusTooltip = "You must enter a value to complete the definition of this condition.";
}
}
}
return {criteriaIsValid, criteriaStatusTooltip};
}
export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, booleanOperator, updateCriteria, removeCriteria, updateBooleanOperator, queryScreenUsage, allowVariables}: FilterCriteriaRowProps): JSX.Element
export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria, booleanOperator, updateCriteria, removeCriteria, updateBooleanOperator}: FilterCriteriaRowProps): JSX.Element
{
// console.log(`FilterCriteriaRow: criteria: ${JSON.stringify(criteria)}`);
const [operatorSelectedValue, setOperatorSelectedValue] = useState(null as OperatorOption);
const [operatorInputValue, setOperatorInputValue] = useState("");
///////////////////////////////////////////////////////////////
// set up the array of options for the fields Autocomplete //
// also, a groupBy function, in case there are exposed joins //
///////////////////////////////////////////////////////////////
const fieldOptions: any[] = [];
makeFieldOptionsForTable(tableMetaData, fieldOptions, false);
let fieldsGroupBy = null;
if (tableMetaData.exposedJoins && tableMetaData.exposedJoins.length > 0)
{
for (let i = 0; i < tableMetaData.exposedJoins.length; i++)
{
const exposedJoin = tableMetaData.exposedJoins[i];
if (metaData.tables.has(exposedJoin.joinTable.name))
{
fieldsGroupBy = (option: any) => `${option.table.label} fields`;
makeFieldOptionsForTable(exposedJoin.joinTable, fieldOptions, true);
}
}
}
////////////////////////////////////////////////////////////
// set up array of options for operator dropdown //
// only call the function to do it if we have a field set //
@ -286,7 +228,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
let defaultFieldValue;
let field = null;
let fieldTable = null;
if (criteria && criteria.fieldName)
if(criteria && criteria.fieldName)
{
[field, fieldTable] = FilterUtils.getField(tableMetaData, criteria.fieldName);
if (field && fieldTable)
@ -305,9 +247,9 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
let newOperatorSelectedValue = operatorOptions.filter(option =>
{
if (option.value == criteria.operator)
if(option.value == criteria.operator)
{
if (option.implicitValues)
if(option.implicitValues)
{
return (JSON.stringify(option.implicitValues) == JSON.stringify(criteria.values));
}
@ -318,7 +260,7 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
}
return (false);
})[0];
if (newOperatorSelectedValue?.label !== operatorSelectedValue?.label)
if(newOperatorSelectedValue?.label !== operatorSelectedValue?.label)
{
setOperatorSelectedValue(newOperatorSelectedValue);
setOperatorInputValue(newOperatorSelectedValue?.label);
@ -381,33 +323,15 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
{
criteria.operator = newValue ? newValue.value : null;
if (newValue)
if(newValue)
{
setOperatorSelectedValue(newValue);
setOperatorInputValue(newValue.label);
if (newValue.implicitValues)
if(newValue.implicitValues)
{
criteria.values = newValue.implicitValues;
}
//////////////////////////////////////////////////////////////////////////////////////////////////
// we've seen cases where switching operators can sometimes put a null in as the first value... //
// that just causes a bad time (e.g., null pointers in Autocomplete), so, get rid of that. //
//////////////////////////////////////////////////////////////////////////////////////////////////
if (criteria.values && criteria.values.length == 1 && criteria.values[0] == null)
{
criteria.values = [];
}
if (newValue.valueMode && !newValue.implicitValues)
{
const requiredValueCount = getValueModeRequiredCount(newValue.valueMode);
if (requiredValueCount != null && criteria.values.length > requiredValueCount)
{
criteria.values.splice(requiredValueCount);
}
}
}
else
{
@ -426,12 +350,12 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
// @ts-ignore
const value = newValue !== undefined ? newValue : event ? event.target.value : null;
if (!criteria.values)
if(!criteria.values)
{
criteria.values = [];
}
if (valueIndex == "all")
if(valueIndex == "all")
{
criteria.values = value;
}
@ -459,19 +383,111 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
return (false);
};
function isFieldOptionEqual(option: any, value: any)
{
return option.fieldName === value.fieldName;
}
function getFieldOptionLabel(option: any)
{
/////////////////////////////////////////////////////////////////////////////////////////
// note - we're using renderFieldOption below for the actual select-box options, which //
// are always jut field label (as they are under groupings that show their table name) //
/////////////////////////////////////////////////////////////////////////////////////////
if(option && option.field && option.table)
{
if(option.table.name == tableMetaData.name)
{
return (option.field.label);
}
else
{
return (option.table.label + ": " + option.field.label);
}
}
return ("");
}
//////////////////////////////////////////////////////////////////////////////////////////////
// for options, we only want the field label (contrast with what we show in the input box, //
// which comes out of getFieldOptionLabel, which is the table-label prefix for join fields) //
//////////////////////////////////////////////////////////////////////////////////////////////
function renderFieldOption(props: React.HTMLAttributes<HTMLLIElement>, option: any, state: AutocompleteRenderOptionState): ReactNode
{
let label = ""
if(option && option.field)
{
label = (option.field.label);
}
return (<li {...props}>{label}</li>);
}
function isOperatorOptionEqual(option: OperatorOption, value: OperatorOption)
{
return (option?.value == value?.value && JSON.stringify(option?.implicitValues) == JSON.stringify(value?.implicitValues));
}
const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue);
let criteriaIsValid = true;
let criteriaStatusTooltip = "This condition is fully defined and is part of your filter.";
const tooltipEnterDelay = 750;
function isNotSet(value: any)
{
return (value === null || value == undefined || String(value).trim() === "");
}
if(!criteria.fieldName)
{
criteriaIsValid = false;
criteriaStatusTooltip = "You must select a field to begin to define this condition.";
}
else if(!criteria.operator)
{
criteriaIsValid = false;
criteriaStatusTooltip = "You must select an operator to continue to define this condition.";
}
else
{
if(operatorSelectedValue)
{
if (operatorSelectedValue.valueMode == ValueMode.NONE || operatorSelectedValue.implicitValues)
{
//////////////////////////////////
// don't need to look at values //
//////////////////////////////////
}
else if(operatorSelectedValue.valueMode == ValueMode.DOUBLE || operatorSelectedValue.valueMode == ValueMode.DOUBLE_DATE || operatorSelectedValue.valueMode == ValueMode.DOUBLE_DATE_TIME)
{
if(criteria.values.length < 2 || isNotSet(criteria.values[0]) || isNotSet(criteria.values[1]))
{
criteriaIsValid = false;
criteriaStatusTooltip = "You must enter two values to complete the definition of this condition.";
}
}
else if(operatorSelectedValue.valueMode == ValueMode.MULTI || operatorSelectedValue.valueMode == ValueMode.PVS_MULTI)
{
if(criteria.values.length < 1 || isNotSet(criteria.values[0]))
{
criteriaIsValid = false;
criteriaStatusTooltip = "You must enter one or more values complete the definition of this condition.";
}
}
else
{
if(!criteria.values || isNotSet(criteria.values[0]))
{
criteriaIsValid = false;
criteriaStatusTooltip = "You must enter a value to complete the definition of this condition.";
}
}
}
}
return (
<Box className="filterCriteriaRow" pt={0.5} display="flex" alignItems="flex-end" pr={0.5}>
<Box className="filterCriteriaRow" pt={0.5} display="flex" alignItems="flex-end">
<Box display="inline-block">
<Tooltip title="Remove this condition from your filter" enterDelay={tooltipEnterDelay} placement="left">
<Tooltip title="Remove this condition from your filter" enterDelay={750} placement="left">
<IconButton onClick={removeCriteria}><Icon fontSize="small">close</Icon></IconButton>
</Tooltip>
</Box>
@ -486,12 +502,24 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
: <span />}
</Box>
<Box display="inline-block" width={250} className="fieldColumn">
<FieldAutoComplete id={`field-${id}`} metaData={metaData} tableMetaData={tableMetaData} defaultValue={defaultFieldValue} handleFieldChange={handleFieldChange}
autocompleteSlotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}}
<Autocomplete
id={`field-${id}`}
renderInput={(params) => (<TextField {...params} label={"Field"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
// @ts-ignore
defaultValue={defaultFieldValue}
options={fieldOptions}
onChange={handleFieldChange}
isOptionEqualToValue={(option, value) => isFieldOptionEqual(option, value)}
groupBy={fieldsGroupBy}
getOptionLabel={(option) => getFieldOptionLabel(option)}
renderOption={(props, option, state) => renderFieldOption(props, option, state)}
autoSelect={true}
autoHighlight={true}
slotProps={{popper: {className: "filterCriteriaRowColumnPopper", style: {padding: 0, width: "250px"}}}}
/>
</Box>
<Box display="inline-block" width={200} className="operatorColumn">
<Tooltip title={criteria.fieldName == null ? "You must select a field before you can select an operator" : null} enterDelay={tooltipEnterDelay}>
<Tooltip title={criteria.fieldName == null ? "You must select a field before you can select an operator" : null} enterDelay={750}>
<Autocomplete
id={"criteriaOperator"}
renderInput={(params) => (<TextField {...params} label={"Operator"} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
@ -516,12 +544,10 @@ export function FilterCriteriaRow({id, index, tableMetaData, metaData, criteria,
field={field}
table={fieldTable}
valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)}
queryScreenUsage={queryScreenUsage}
allowVariables={allowVariables}
/>
</Box>
<Box display="inline-block">
<Tooltip title={criteriaStatusTooltip} enterDelay={tooltipEnterDelay} placement="bottom">
<Box display="inline-block" pl={0.5} pr={1}>
<Tooltip title={criteriaStatusTooltip} enterDelay={750} placement="right">
{
criteriaIsValid
? <Icon color="success">check</Icon>

View File

@ -23,25 +23,19 @@
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {FilterVariableExpression} from "@kingsrook/qqq-frontend-core/lib/model/query/FilterVariableExpression";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import InputAdornment from "@mui/material/InputAdornment/InputAdornment";
import TextField from "@mui/material/TextField";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import React, {SyntheticEvent, useReducer} from "react";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import AssignFilterVariable from "qqq/components/query/AssignFilterVariable";
import CriteriaDateField, {NoWrapTooltip} from "qqq/components/query/CriteriaDateField";
import CriteriaDateField from "qqq/components/query/CriteriaDateField";
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
import {EvaluatedExpression} from "qqq/components/query/EvaluatedExpression";
import FilterCriteriaPaster from "qqq/components/query/FilterCriteriaPaster";
import {OperatorOption, ValueMode} from "qqq/components/query/FilterCriteriaRow";
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React, {SyntheticEvent, useReducer} from "react";
import {flushSync} from "react-dom";
interface Props
{
@ -50,20 +44,10 @@ interface Props
field: QFieldMetaData;
table: QTableMetaData;
valueChangeHandler: (event: React.ChangeEvent | SyntheticEvent, valueIndex?: number | "all", newValue?: any) => void;
initiallyOpenMultiValuePvs?: boolean;
queryScreenUsage?: QueryScreenUsage;
allowVariables?: boolean;
}
FilterCriteriaRowValues.defaultProps =
{
initiallyOpenMultiValuePvs: false
};
FilterCriteriaRowValues.defaultProps = {};
/***************************************************************************
* get the type to use for an <input> from a QFieldMetaData
***************************************************************************/
export const getTypeForTextField = (field: QFieldMetaData): string =>
{
let type = "search";
@ -84,15 +68,8 @@ export const getTypeForTextField = (field: QFieldMetaData): string =>
return (type);
};
/***************************************************************************
* Make an <input type=text> (actually, might be a different type, but that's
* the gist of it), for a field.
***************************************************************************/
export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWithId, valueChangeHandler?: (event: (React.ChangeEvent | React.SyntheticEvent), valueIndex?: (number | "all"), newValue?: any) => void, valueIndex: number = 0, label = "Value", idPrefix = "value-", allowVariables = false) =>
export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWithId, valueChangeHandler?: (event: (React.ChangeEvent | React.SyntheticEvent), valueIndex?: (number | "all"), newValue?: any) => void, valueIndex: number = 0, label = "Value", idPrefix = "value-") =>
{
const isExpression = criteria.values && criteria.values[valueIndex] && criteria.values[valueIndex].type;
const inputId = `${idPrefix}${criteria.id}`;
let type = getTypeForTextField(field);
const inputLabelProps: any = {};
@ -107,70 +84,12 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
value = ValueUtils.formatDateTimeValueForForm(value);
}
/***************************************************************************
* Event handler for the clear 'x'.
***************************************************************************/
const clearValue = (event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>, index: number) =>
{
valueChangeHandler(event, index, "");
document.getElementById(inputId).focus();
document.getElementById(`${idPrefix}${criteria.id}`).focus();
};
/*******************************************************************************
** Event handler for key-down events - specifically added here, to stop pressing
** 'tab' in a date or date-time from closing the quick-filter...
*******************************************************************************/
const handleKeyDown = (e: any) =>
{
if (field.type == QFieldType.DATE || field.type == QFieldType.DATE_TIME)
{
if (e.code == "Tab")
{
console.log("Tab on date or date-time - don't close me, just move to the next sub-field!...");
e.stopPropagation();
}
}
};
/***************************************************************************
* make a version of the text field for when the criteria's value is set to
* be a "variable"
***************************************************************************/
const makeFilterVariableTextField = (expression: FilterVariableExpression, valueIndex: number = 0, label = "Value", idPrefix = "value-") =>
{
const clearValue = (event: React.MouseEvent<HTMLAnchorElement> | React.MouseEvent<HTMLButtonElement>, index: number) =>
{
valueChangeHandler(event, index, "");
document.getElementById(`${idPrefix}${criteria.id}`).focus();
};
const inputProps2: any = {};
inputProps2.endAdornment = (
<InputAdornment position="end">
<IconButton sx={{visibility: expression ? "visible" : "hidden"}} onClick={(event) => clearValue(event, valueIndex)}>
<Icon>closer</Icon>
</IconButton>
</InputAdornment>
);
return <NoWrapTooltip title={<EvaluatedExpression field={field} expression={expression} />} placement="bottom" enterDelay={1000} sx={{marginLeft: "-75px !important", marginTop: "-8px !important"}}><TextField
id={`${idPrefix}${criteria.id}`}
label={label}
variant="standard"
autoComplete="off"
InputProps={{disabled: true, readOnly: true, unselectable: "off", ...inputProps2}}
InputLabelProps={{shrink: true}}
value="${VARIABLE}"
fullWidth
/></NoWrapTooltip>;
};
///////////////////////////////////////////////////////////////////////////
// set up an 'x' icon as an end-adornment, to clear value from the field //
///////////////////////////////////////////////////////////////////////////
const inputProps: any = {};
inputProps.endAdornment = (
<InputAdornment position="end">
@ -180,99 +99,29 @@ export const makeTextField = (field: QFieldMetaData, criteria: QFilterCriteriaWi
</InputAdornment>
);
/***************************************************************************
* onChange event handler. deals with, if the field has a to upper/lower
* case rule on it, to apply that transform, and adjust the cursor.
* See: https://giacomocerquone.com/blog/keep-input-cursor-still
***************************************************************************/
function onChange(event: any)
{
const beforeStart = event.target.selectionStart;
const beforeEnd = event.target.selectionEnd;
let isToUpperCase = DynamicFormUtils.isToUpperCase(field);
let isToLowerCase = DynamicFormUtils.isToLowerCase(field);
if (isToUpperCase || isToLowerCase)
{
flushSync(() =>
{
let newValue = event.currentTarget.value;
if (isToUpperCase)
{
newValue = newValue.toUpperCase();
}
if (isToLowerCase)
{
newValue = newValue.toLowerCase();
}
event.currentTarget.value = newValue;
});
const input = document.getElementById(inputId);
if (input)
{
// @ts-ignore
input.setSelectionRange(beforeStart, beforeEnd);
}
}
valueChangeHandler(event, valueIndex);
}
////////////////////////
// return the element //
////////////////////////
return <Box sx={{margin: 0, padding: 0, display: "flex"}}>
{
isExpression ? (
makeFilterVariableTextField(criteria.values[valueIndex], valueIndex, label, idPrefix)
) : (
<TextField
id={inputId}
label={label}
variant="standard"
autoComplete="off"
type={type}
onChange={onChange}
onKeyDown={handleKeyDown}
value={value}
InputLabelProps={inputLabelProps}
InputProps={inputProps}
fullWidth
autoFocus={true}
/>
)
}
{
allowVariables && (
<AssignFilterVariable field={field} valueChangeHandler={valueChangeHandler} valueIndex={valueIndex} />
)
}
</Box>;
return <TextField
id={`${idPrefix}${criteria.id}`}
label={label}
variant="standard"
autoComplete="off"
type={type}
onChange={(event) => valueChangeHandler(event, valueIndex)}
value={value}
InputLabelProps={inputLabelProps}
InputProps={inputProps}
fullWidth
/>;
};
/***************************************************************************
* Component that is the "values" portion of a FilterCriteria Row in the
* advanced query filter editor.
***************************************************************************/
function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler, initiallyOpenMultiValuePvs, queryScreenUsage, allowVariables}: Props): JSX.Element
function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueChangeHandler}: Props): JSX.Element
{
const [, forceUpdate] = useReducer((x) => x + 1, 0);
if (!operatorOption)
{
return null;
return <br />;
}
/***************************************************************************
* Callback for the Save button from the paste-values modal
***************************************************************************/
function saveNewPasterValues(newValues: any[])
{
if (criteria.values)
@ -296,38 +145,33 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
forceUpdate();
}
const isExpression = criteria.values && criteria.values[0] && criteria.values[0].type;
//////////////////////////////////////////////////////////////////////////////
// render different form element9s) based on operator option's "value mode" //
//////////////////////////////////////////////////////////////////////////////
switch (operatorOption.valueMode)
{
case ValueMode.NONE:
return null;
return <br />;
case ValueMode.SINGLE:
return makeTextField(field, criteria, valueChangeHandler, 0, undefined, undefined, allowVariables);
return makeTextField(field, criteria, valueChangeHandler);
case ValueMode.SINGLE_DATE:
return <CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} allowVariables={allowVariables} />;
return <CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} />;
case ValueMode.DOUBLE_DATE:
return <Box>
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={0} label="From" idPrefix="from-" allowVariables={allowVariables} />
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={1} label="To" idPrefix="to-" allowVariables={allowVariables} />
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={0} label="From" idPrefix="from-" />
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={1} label="To" idPrefix="to-" />
</Box>;
case ValueMode.SINGLE_DATE_TIME:
return <CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} allowVariables={allowVariables} />;
return <CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} />;
case ValueMode.DOUBLE_DATE_TIME:
return <Box>
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={0} label="From" idPrefix="from-" allowVariables={allowVariables} />
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={1} label="To" idPrefix="to-" allowVariables={allowVariables} />
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={0} label="From" idPrefix="from-" />
<CriteriaDateField field={field} valueChangeHandler={valueChangeHandler} criteria={criteria} valueIndex={1} label="To" idPrefix="to-" />
</Box>;
case ValueMode.DOUBLE:
return <Box>
<Box width="50%" display="inline-block">
{makeTextField(field, criteria, valueChangeHandler, 0, "From", "from-", allowVariables)}
{makeTextField(field, criteria, valueChangeHandler, 0, "From", "from-")}
</Box>
<Box width="50%" display="inline-block">
{makeTextField(field, criteria, valueChangeHandler, 1, "To", "to-", allowVariables)}
{makeTextField(field, criteria, valueChangeHandler, 1, "To", "to-")}
</Box>
</Box>;
case ValueMode.MULTI:
@ -360,29 +204,18 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
{
selectedPossibleValue = criteria.values[0];
}
return <Box display="flex">
{
isExpression ? (
makeTextField(field, criteria, valueChangeHandler, 0, undefined, undefined, allowVariables)
) : (
<Box width={"100%"}>
<DynamicSelect
fieldPossibleValueProps={{tableName: table.name, fieldName: field.name, initialDisplayValue: selectedPossibleValue?.label}}
overrideId={field.name + "-single-" + criteria.id}
key={field.name + "-single-" + criteria.id}
fieldLabel="Value"
initialValue={selectedPossibleValue?.id}
inForm={false}
onChange={(value: any) => valueChangeHandler(null, 0, value)}
variant="standard"
useCase="filter"
/>
</Box>
)
}
{
allowVariables && !isExpression && <Box mt={2.0}><AssignFilterVariable field={field} valueChangeHandler={valueChangeHandler} valueIndex={0} /></Box>
}
return <Box mb={-1.5}>
<DynamicSelect
tableName={table.name}
fieldName={field.name}
overrideId={field.name + "-single-" + criteria.id}
key={field.name + "-single-" + criteria.id}
fieldLabel="Value"
initialValue={selectedPossibleValue?.id}
initialDisplayValue={selectedPossibleValue?.label}
inForm={false}
onChange={(value: any) => valueChangeHandler(null, 0, value)}
/>
</Box>;
case ValueMode.PVS_MULTI:
console.log("Doing pvs multi: " + criteria.values);
@ -398,19 +231,17 @@ function FilterCriteriaRowValues({operatorOption, criteria, field, table, valueC
initialValues = criteria.values;
}
}
return <Box>
return <Box mb={-1.5}>
<DynamicSelect
fieldPossibleValueProps={{tableName: table.name, fieldName: field.name, initialDisplayValue: null}}
tableName={table.name}
fieldName={field.name}
overrideId={field.name + "-multi-" + criteria.id}
key={field.name + "-multi-" + criteria.id}
isMultiple
fieldLabel="Values"
initialValues={initialValues}
initiallyOpen={false /*initiallyOpenMultiValuePvs*/}
inForm={false}
onChange={(value: any) => valueChangeHandler(null, "all", value)}
variant="standard"
useCase="filter"
/>
</Box>;
}

View File

@ -1,134 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {Capability} from "@kingsrook/qqq-frontend-core/lib/model/metaData/Capability";
import {QInstance} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QInstance";
import {QProcessMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QProcessMetaData";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import Divider from "@mui/material/Divider";
import Icon from "@mui/material/Icon";
import ListItemIcon from "@mui/material/ListItemIcon";
import Menu from "@mui/material/Menu";
import MenuItem from "@mui/material/MenuItem";
import React, {useState} from "react";
import {useNavigate} from "react-router-dom";
import {QActionsMenuButton} from "qqq/components/buttons/DefaultButtons";
interface QueryScreenActionMenuProps
{
metaData: QInstance;
tableMetaData: QTableMetaData;
tableProcesses: QProcessMetaData[];
bulkLoadClicked: () => void;
bulkEditClicked: () => void;
bulkDeleteClicked: () => void;
processClicked: (process: QProcessMetaData) => void;
}
QueryScreenActionMenu.defaultProps = {
};
export default function QueryScreenActionMenu({metaData, tableMetaData, tableProcesses, bulkLoadClicked, bulkEditClicked, bulkDeleteClicked, processClicked}: QueryScreenActionMenuProps): JSX.Element
{
const [anchorElement, setAnchorElement] = useState(null)
const navigate = useNavigate();
const openActionsMenu = (event: any) =>
{
setAnchorElement(event.currentTarget);
}
const closeActionsMenu = () =>
{
setAnchorElement(null);
}
const pushDividerIfNeeded = (menuItems: JSX.Element[]) =>
{
if (menuItems.length > 0)
{
menuItems.push(<Divider key="divider" />);
}
};
const runSomething = (handler: () => void) =>
{
closeActionsMenu();
handler();
}
const menuItems: JSX.Element[] = [];
if (tableMetaData.capabilities.has(Capability.TABLE_INSERT) && tableMetaData.insertPermission)
{
menuItems.push(<MenuItem key="bulkLoad" onClick={() => runSomething(bulkLoadClicked)}><ListItemIcon><Icon>library_add</Icon></ListItemIcon>Bulk Load</MenuItem>);
}
if (tableMetaData.capabilities.has(Capability.TABLE_UPDATE) && tableMetaData.editPermission)
{
menuItems.push(<MenuItem key="bulkEdit" onClick={() => runSomething(bulkEditClicked)}><ListItemIcon><Icon>edit</Icon></ListItemIcon>Bulk Edit</MenuItem>);
}
if (tableMetaData.capabilities.has(Capability.TABLE_DELETE) && tableMetaData.deletePermission)
{
menuItems.push(<MenuItem key="bulkDelete" onClick={() => runSomething(bulkDeleteClicked)}><ListItemIcon><Icon>delete</Icon></ListItemIcon>Bulk Delete</MenuItem>);
}
const runRecordScriptProcess = metaData?.processes.get("runRecordScript");
if (runRecordScriptProcess)
{
const process = runRecordScriptProcess;
menuItems.push(<MenuItem key={process.name} onClick={() => runSomething(() => processClicked(process))}><ListItemIcon><Icon>{process.iconName ?? "arrow_forward"}</Icon></ListItemIcon>{process.label}</MenuItem>);
}
menuItems.push(<MenuItem key="developerMode" onClick={() => navigate(`${metaData.getTablePathByName(tableMetaData.name)}/dev`)}><ListItemIcon><Icon>code</Icon></ListItemIcon>Developer Mode</MenuItem>);
if (tableProcesses && tableProcesses.length)
{
pushDividerIfNeeded(menuItems);
}
tableProcesses.sort((a, b) => a.label.localeCompare(b.label));
tableProcesses.map((process) =>
{
menuItems.push(<MenuItem key={process.name} onClick={() => runSomething(() => processClicked(process))}><ListItemIcon><Icon>{process.iconName ?? "arrow_forward"}</Icon></ListItemIcon>{process.label}</MenuItem>);
});
if (menuItems.length === 0)
{
menuItems.push(<MenuItem key="notAvaialableNow" disabled><ListItemIcon><Icon>block</Icon></ListItemIcon><i>No actions available</i></MenuItem>);
}
return (
<>
<QActionsMenuButton isOpen={anchorElement} onClickHandler={openActionsMenu} />
<Menu
anchorEl={anchorElement}
anchorOrigin={{vertical: "bottom", horizontal: "right",}}
transformOrigin={{vertical: "top", horizontal: "right",}}
open={anchorElement != null}
onClose={closeActionsMenu}
keepMounted
>
{menuItems}
</Menu>
</>
)
}

View File

@ -1,579 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2023. 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 {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import {QFieldType} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldType";
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QCriteriaOperator} from "@kingsrook/qqq-frontend-core/lib/model/query/QCriteriaOperator";
import {QFilterCriteria} from "@kingsrook/qqq-frontend-core/lib/model/query/QFilterCriteria";
import {Tooltip} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Menu from "@mui/material/Menu";
import TextField from "@mui/material/TextField";
import QContext from "QContext";
import {QFilterCriteriaWithId} from "qqq/components/query/CustomFilterPanel";
import {getDefaultCriteriaValue, getOperatorOptions, getValueModeRequiredCount, OperatorOption, validateCriteria} from "qqq/components/query/FilterCriteriaRow";
import FilterCriteriaRowValues from "qqq/components/query/FilterCriteriaRowValues";
import XIcon from "qqq/components/query/XIcon";
import {QueryScreenUsage} from "qqq/pages/records/query/RecordQuery";
import FilterUtils from "qqq/utils/qqq/FilterUtils";
import TableUtils from "qqq/utils/qqq/TableUtils";
import React, {SyntheticEvent, useContext, useReducer, useState} from "react";
export type CriteriaParamType = QFilterCriteriaWithId | null | "tooComplex";
interface QuickFilterProps
{
tableMetaData: QTableMetaData;
fullFieldName: string;
fieldMetaData: QFieldMetaData;
criteriaParam: CriteriaParamType;
updateCriteria: (newCriteria: QFilterCriteria, needDebounce: boolean, doRemoveCriteria: boolean) => void;
defaultOperator?: QCriteriaOperator;
handleRemoveQuickFilterField?: (fieldName: string) => void;
queryScreenUsage?: QueryScreenUsage;
allowVariables?: boolean;
}
QuickFilter.defaultProps =
{
defaultOperator: QCriteriaOperator.EQUALS,
handleRemoveQuickFilterField: null
};
let seedId = new Date().getTime() % 173237;
export const quickFilterButtonStyles = {
fontSize: "0.75rem",
fontWeight: 600,
color: "#757575",
textTransform: "none",
borderRadius: "2rem",
border: "1px solid #757575",
minWidth: "3.5rem",
minHeight: "auto",
padding: "0.375rem 0.625rem", whiteSpace: "nowrap",
marginBottom: "0.5rem"
};
/*******************************************************************************
** Test if a CriteriaParamType represents an actual query criteria - or, if it's
** null or the "tooComplex" placeholder.
*******************************************************************************/
const criteriaParamIsCriteria = (param: CriteriaParamType): boolean =>
{
return (param != null && param != "tooComplex");
};
/*******************************************************************************
** Test of an OperatorOption equals a query Criteria - that is - that the
** operators within them are equal - AND - if the OperatorOption has implicit
** values (e.g., the booleans), then those options equal the criteria's options.
*******************************************************************************/
const doesOperatorOptionEqualCriteria = (operatorOption: OperatorOption, criteria: QFilterCriteriaWithId): boolean =>
{
if (operatorOption.value == criteria.operator)
{
if (operatorOption.implicitValues)
{
if (JSON.stringify(operatorOption.implicitValues) == JSON.stringify(criteria.values))
{
return (true);
}
else
{
return (false);
}
}
return (true);
}
return (false);
};
/*******************************************************************************
** Get the object to use as the selected OperatorOption (e.g., value for that
** autocomplete), given an array of options, the query's active criteria in this
** field, and the default operator to use for this field
*******************************************************************************/
const getOperatorSelectedValue = (operatorOptions: OperatorOption[], criteria: QFilterCriteriaWithId, defaultOperator: QCriteriaOperator, return0thOptionInsteadOfNull: boolean = false): OperatorOption =>
{
if (criteria)
{
const filteredOptions = operatorOptions.filter(o => doesOperatorOptionEqualCriteria(o, criteria));
if (filteredOptions.length > 0)
{
return (filteredOptions[0]);
}
}
const filteredOptions = operatorOptions.filter(o => o.value == defaultOperator);
if (filteredOptions.length > 0)
{
return (filteredOptions[0]);
}
if(return0thOptionInsteadOfNull)
{
console.log("Returning 0th operator instead of null - this isn't expected, but has been seen to happen - so here's some additional debugging:");
try
{
console.log("Operator options: " + JSON.stringify(operatorOptions));
console.log("Criteria: " + JSON.stringify(criteria));
console.log("Default Operator: " + JSON.stringify(defaultOperator));
}
catch(e)
{
console.log(`Error in debug output: ${e}`);
}
return operatorOptions[0];
}
return (null);
};
/*******************************************************************************
** Component to render a QuickFilter - that is - a button, with a Menu under it,
** with Operator and Value controls.
*******************************************************************************/
export default function QuickFilter({tableMetaData, fullFieldName, fieldMetaData, criteriaParam, updateCriteria, defaultOperator, handleRemoveQuickFilterField, queryScreenUsage, allowVariables}: QuickFilterProps): JSX.Element
{
const operatorOptions = fieldMetaData ? getOperatorOptions(tableMetaData, fullFieldName) : [];
const [_, tableForField] = TableUtils.getFieldAndTable(tableMetaData, fullFieldName);
const [isOpen, setIsOpen] = useState(false);
const [anchorEl, setAnchorEl] = useState(null);
const [isMouseOver, setIsMouseOver] = useState(false);
////////////////////////////////////////////////////////////////////////////////////////////////////////
// copy the criteriaParam to a new object in here - so changes won't apply until user closes the menu //
////////////////////////////////////////////////////////////////////////////////////////////////////////
const [criteria, setCriteria] = useState(criteriaParamIsCriteria(criteriaParam) ? Object.assign({}, criteriaParam) as QFilterCriteriaWithId : null);
const [id, setId] = useState(criteriaParamIsCriteria(criteriaParam) ? (criteriaParam as QFilterCriteriaWithId).id : ++seedId);
const [operatorSelectedValue, setOperatorSelectedValue] = useState(getOperatorSelectedValue(operatorOptions, criteria, defaultOperator, true));
const [operatorInputValue, setOperatorInputValue] = useState(operatorSelectedValue?.label);
const {criteriaIsValid, criteriaStatusTooltip} = validateCriteria(criteria, operatorSelectedValue);
const {accentColor} = useContext(QContext);
//////////////////////
// ole' faithful... //
//////////////////////
const [, forceUpdate] = useReducer((x) => x + 1, 0);
/*******************************************************************************
**
*******************************************************************************/
function handleMouseOverElement()
{
setIsMouseOver(true);
}
/*******************************************************************************
**
*******************************************************************************/
function handleMouseOutElement()
{
setIsMouseOver(false);
}
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
// handle a change to the criteria from outside this component (e.g., the prop isn't the same as the state) //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (criteriaParamIsCriteria(criteriaParam) && JSON.stringify(criteriaParam) !== JSON.stringify(criteria))
{
if (isOpen)
{
////////////////////////////////////////////////////////////////////////////////
// this was firing too-often for case where: there was a criteria originally //
////////////////////////////////////////////////////////////////////////////////
console.log("Not handling outside change (A), because dropdown is-open");
}
else
{
////////////////////////////////////////////////////////////////////////////////////////////////////////
// copy the criteriaParam to a new object in here - so changes won't apply until user closes the menu //
////////////////////////////////////////////////////////////////////////////////////////////////////////
const newCriteria = Object.assign({}, criteriaParam) as QFilterCriteriaWithId;
setCriteria(newCriteria);
const operatorOption = operatorOptions.filter(o => o.value == newCriteria.operator)[0];
setOperatorSelectedValue(operatorOption);
setOperatorInputValue(operatorOption.label);
}
}
/*******************************************************************************
** Test if we need to construct a new criteria object
** This is (at least for some cases) for when the criteria gets changed
** from outside of this component - e.g., a reset on the query screen
*******************************************************************************/
const criteriaNeedsReset = (): boolean =>
{
if (criteria != null && criteriaParam == null)
{
const defaultOperatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0];
if (criteria.operator !== defaultOperatorOption?.value || JSON.stringify(criteria.values) !== JSON.stringify(getDefaultCriteriaValue()))
{
if (isOpen)
{
//////////////////////////////////////////////////////////////////////////////////
// this was firing too-often for case where: there was no criteria originally, //
// so, by adding this is-open check, we eliminated those. //
//////////////////////////////////////////////////////////////////////////////////
console.log("Not handling outside change (B), because dropdown is-open");
return (false);
}
return (true);
}
}
return (false);
};
/*******************************************************************************
** Construct a new criteria object - resetting the values tied to the operator
** autocomplete at the same time.
*******************************************************************************/
const makeNewCriteria = (): QFilterCriteria =>
{
const operatorOption = operatorOptions.filter(o => o.value == defaultOperator)[0];
const criteria = new QFilterCriteriaWithId(fullFieldName, operatorOption?.value, getDefaultCriteriaValue());
criteria.id = id;
setOperatorSelectedValue(operatorOption);
setOperatorInputValue(operatorOption?.label);
setCriteria(criteria);
return (criteria);
};
/*******************************************************************************
** event handler to open the menu in response to the button being clicked.
*******************************************************************************/
const handleOpenMenu = (event: any) =>
{
setIsOpen(!isOpen);
setAnchorEl(event.currentTarget);
setTimeout(() =>
{
const element = document.getElementById("value-" + criteria.id);
element?.focus();
});
};
/*******************************************************************************
** handler for the Menu when being closed
*******************************************************************************/
const closeMenu = () =>
{
//////////////////////////////////////////////////////////////////////////////////
// when closing the menu, that's when we'll update the criteria from the caller //
//////////////////////////////////////////////////////////////////////////////////
updateCriteria(criteria, false, false);
setIsOpen(false);
setAnchorEl(null);
};
/*******************************************************************************
** event handler for operator Autocomplete having its value changed
*******************************************************************************/
const handleOperatorChange = (event: any, newValue: any, reason: string) =>
{
criteria.operator = newValue ? newValue.value : null;
if (newValue)
{
setOperatorSelectedValue(newValue);
setOperatorInputValue(newValue.label);
if (newValue.implicitValues)
{
criteria.values = newValue.implicitValues;
}
//////////////////////////////////////////////////////////////////////////////////////////////////
// we've seen cases where switching operators can sometimes put a null in as the first value... //
// that just causes a bad time (e.g., null pointers in Autocomplete), so, get rid of that. //
//////////////////////////////////////////////////////////////////////////////////////////////////
if (criteria.values && criteria.values.length == 1 && criteria.values[0] == null)
{
criteria.values = [];
}
if (newValue.valueMode && !newValue.implicitValues)
{
const requiredValueCount = getValueModeRequiredCount(newValue.valueMode);
if (requiredValueCount != null && criteria.values.length > requiredValueCount)
{
criteria.values.splice(requiredValueCount);
}
}
}
else
{
setOperatorSelectedValue(null);
setOperatorInputValue("");
}
setCriteria(criteria);
forceUpdate();
};
/*******************************************************************************
** implementation of isOptionEqualToValue for Autocomplete - compares both the
** value (e.g., what operator it is) and the implicitValues within the option
*******************************************************************************/
function isOperatorOptionEqual(option: OperatorOption, value: OperatorOption)
{
return (option?.value == value?.value && JSON.stringify(option?.implicitValues) == JSON.stringify(value?.implicitValues));
}
/*******************************************************************************
** event handler for the value field (of all types), when it changes
*******************************************************************************/
const handleValueChange = (event: React.ChangeEvent | SyntheticEvent, valueIndex: number | "all" = 0, newValue?: any) =>
{
// @ts-ignore
const value = newValue !== undefined ? newValue : event ? event.target.value : null;
console.log("IN HERE");
if (!criteria.values)
{
criteria.values = [];
}
if (valueIndex == "all")
{
criteria.values = value;
}
else
{
criteria.values[valueIndex] = value;
}
setCriteria(criteria);
forceUpdate();
};
/*******************************************************************************
** a noop event handler, e.g., for a too-complex
*******************************************************************************/
const noop = () =>
{
};
/*******************************************************************************
** event handler that responds to 'x' button that removes the criteria from the
** quick-filter, resetting it to a new filter.
*******************************************************************************/
const resetCriteria = (e: React.MouseEvent<HTMLSpanElement>) =>
{
if (criteriaIsValid)
{
e.stopPropagation();
const newCriteria = makeNewCriteria();
updateCriteria(newCriteria, false, true);
}
};
/*******************************************************************************
** event handler for clicking the (x) icon that turns off this quick filter field.
** hands off control to the function that was passed in (e.g., from RecordQueryOrig).
*******************************************************************************/
const handleTurningOffQuickFilterField = () =>
{
closeMenu();
if (handleRemoveQuickFilterField)
{
handleRemoveQuickFilterField(criteria?.fieldName);
}
};
////////////////////////////////////////////////////////////////////////////////////
// if no field was input (e.g., record-query is still loading), return null early //
////////////////////////////////////////////////////////////////////////////////////
if (!fieldMetaData)
{
return (null);
}
//////////////////////////////////////////////////////////////////////////////////////////
// if there should be a selected value in the operator autocomplete, and it's different //
// from the last selected one, then set the state vars that control that autocomplete //
//////////////////////////////////////////////////////////////////////////////////////////
const maybeNewOperatorSelectedValue = getOperatorSelectedValue(operatorOptions, criteria, defaultOperator);
if (JSON.stringify(maybeNewOperatorSelectedValue) !== JSON.stringify(operatorSelectedValue))
{
setOperatorSelectedValue(maybeNewOperatorSelectedValue);
setOperatorInputValue(maybeNewOperatorSelectedValue?.label);
}
/////////////////////////////////////////////////////////////////////////////////////
// if there wasn't a criteria, or we need to reset it (make a new one), then do so //
/////////////////////////////////////////////////////////////////////////////////////
if (criteria == null || criteriaNeedsReset())
{
makeNewCriteria();
}
/////////////////////////
// build up the button //
/////////////////////////
const tooComplex = criteriaParam == "tooComplex";
const tooltipEnterDelay = 500;
let buttonAdditionalStyles: any = {};
let buttonContent = <span>{tableForField?.name != tableMetaData.name ? `${tableForField.label}: ` : ""}{fieldMetaData.label}</span>;
let buttonClassName = "filterNotActive";
if (criteriaIsValid)
{
buttonAdditionalStyles.backgroundColor = accentColor + " !important";
buttonAdditionalStyles.borderColor = accentColor + " !important";
buttonAdditionalStyles.color = "white !important";
buttonClassName = "filterActive";
let valuesString = FilterUtils.getValuesString(fieldMetaData, criteria, 1, "+N");
///////////////////////////////////////////
// don't show the Equals or In operators //
///////////////////////////////////////////
let operatorString = (<>{operatorSelectedValue.label}&nbsp;</>);
if (operatorSelectedValue.value == QCriteriaOperator.EQUALS || operatorSelectedValue.value == QCriteriaOperator.IN)
{
operatorString = (<></>);
}
buttonContent = (<><span style={{fontWeight: 700}}>{buttonContent}:</span>&nbsp;<span style={{fontWeight: 400}}>{operatorString}{valuesString}</span></>);
}
const mouseEvents =
{
onMouseOver: () => handleMouseOverElement(),
onMouseOut: () => handleMouseOutElement()
};
let button = fieldMetaData && <Button
id={`quickFilter.${fullFieldName}`}
className={buttonClassName}
{...mouseEvents}
sx={{...quickFilterButtonStyles, ...buttonAdditionalStyles, mr: "0.5rem"}}
onClick={tooComplex ? noop : handleOpenMenu}
disabled={tooComplex}
>{buttonContent}</Button>;
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if the criteria on this field is the "tooComplex" sentinel, then wrap the button in a tooltip stating such, and return early. //
// note this was part of original design on this widget, but later deprecated... //
///////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
if (tooComplex)
{
////////////////////////////////////////////////////////////////////////////
// wrap button in span, so disabled button doesn't cause disabled tooltip //
////////////////////////////////////////////////////////////////////////////
return (
<Tooltip title={`Your current filter is too complex to do a Quick Filter on ${fieldMetaData.label}. Use the Filter button to edit.`} enterDelay={tooltipEnterDelay} slotProps={{popper: {sx: {top: "-0.75rem!important"}}}}>
<span>{button}</span>
</Tooltip>
);
}
/*******************************************************************************
** event handler for 'x' button - either resets the criteria or turns off the field.
*******************************************************************************/
const xClicked = (e: React.MouseEvent<HTMLSpanElement>) =>
{
e.stopPropagation();
if (criteriaIsValid)
{
resetCriteria(e);
}
else
{
handleTurningOffQuickFilterField();
}
};
//////////////////////////////
// return the button & menu //
//////////////////////////////
const widthAndMaxWidth = (fieldMetaData?.type == QFieldType.DATE_TIME) ? 315 : 250;
return (
<>
{button}
{
/////////////////////////////////////////////////////////////////////////////////////
// only show the 'x' if it's to clear out a valid criteria on the field, //
// or if we were given a callback to remove the quick-filter field from the screen //
/////////////////////////////////////////////////////////////////////////////////////
(criteriaIsValid || handleRemoveQuickFilterField) && isMouseOver && <span {...mouseEvents}><XIcon shade={criteriaIsValid ? "accent" : "default"} position="forQuickFilter" onClick={xClicked} /></span>
}
{
isOpen && <Menu open={Boolean(anchorEl)} anchorEl={anchorEl} onClose={closeMenu} sx={{overflow: "visible"}}>
<Box display="inline-block" width={widthAndMaxWidth} maxWidth={widthAndMaxWidth} className="operatorColumn">
<Autocomplete
id={"criteriaOperator"}
////////////////////////////////////////////////////////////////////////////////////////////////////
// ok, so, by default, if you type an 'o' as the first letter in the FilterCriteriaRowValues box, //
// something is causing THIS element to become selected, if the first letter in its label is 'O'. //
// ... work around is to put invisible &zwnj; entity as first character in label instead... //
////////////////////////////////////////////////////////////////////////////////////////////////////
renderInput={(params) => (<TextField {...params} label={<>&zwnj;Operator</>} variant="standard" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
options={operatorOptions}
value={operatorSelectedValue as any}
inputValue={operatorInputValue}
onChange={handleOperatorChange}
onInputChange={(e, value) => setOperatorInputValue(value)}
isOptionEqualToValue={(option, value) => isOperatorOptionEqual(option, value)}
getOptionLabel={(option: any) => option.label}
autoSelect={true}
autoHighlight={true}
disableClearable
slotProps={{popper: {style: {padding: 0, maxHeight: "unset", width: "250px"}}}}
/>
</Box>
<Box width={widthAndMaxWidth} maxWidth={widthAndMaxWidth} className="quickFilter filterValuesColumn">
<FilterCriteriaRowValues
queryScreenUsage={queryScreenUsage}
operatorOption={operatorSelectedValue}
criteria={criteria}
field={fieldMetaData}
table={tableForField}
allowVariables={allowVariables}
valueChangeHandler={(event, valueIndex, newValue) => handleValueChange(event, valueIndex, newValue)}
initiallyOpenMultiValuePvs={true} // todo - maybe not?
/>
</Box>
</Menu>
}
</>
);
}

View File

@ -1,74 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import 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 TextField from "@mui/material/TextField";
import React, {useState} from "react";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
/*******************************************************************************
** Component that is the dialog for the user to enter the selection-subset
*******************************************************************************/
export default function SelectionSubsetDialog(props: { isOpen: boolean; initialValue: number; closeHandler: (value?: number) => void })
{
const [value, setValue] = useState(props.initialValue);
const handleChange = (newValue: string) =>
{
setValue(parseInt(newValue));
};
const keyPressed = (e: React.KeyboardEvent<HTMLDivElement>) =>
{
if (e.key == "Enter" && value)
{
props.closeHandler(value);
}
};
return (
<Dialog open={props.isOpen} onClose={() => props.closeHandler()} onKeyPress={(e) => keyPressed(e)}>
<DialogTitle>Subset of the Query Result</DialogTitle>
<DialogContent>
<DialogContentText>How many records do you want to select?</DialogContentText>
<TextField
autoFocus
name="selection-subset-size"
inputProps={{width: "100%", type: "number", min: 1}}
onChange={(e) => handleChange(e.target.value)}
value={value}
sx={{width: "100%"}}
onFocus={event => event.target.select()}
/>
</DialogContent>
<DialogActions>
<QCancelButton disabled={false} onClickHandler={() => props.closeHandler()} />
<QSaveButton label="OK" iconName="check" disabled={value == undefined || isNaN(value)} onClickHandler={() => props.closeHandler(value)} />
</DialogActions>
</Dialog>
);
}

View File

@ -1,122 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QTableVariant} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableVariant";
import Autocomplete from "@mui/material/Autocomplete";
import Dialog from "@mui/material/Dialog";
import DialogContent from "@mui/material/DialogContent";
import DialogContentText from "@mui/material/DialogContentText";
import DialogTitle from "@mui/material/DialogTitle";
import TextField from "@mui/material/TextField";
import React, {useEffect, useState} from "react";
import {TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT} from "qqq/pages/records/query/RecordQuery";
import Client from "qqq/utils/qqq/Client";
const qController = Client.getInstance();
/*******************************************************************************
** Component that is the dialog for the user to select a variant on tables with variant backends //
*******************************************************************************/
export default function TableVariantDialog(props: { isOpen: boolean; table: QTableMetaData; closeHandler: (value?: QTableVariant) => void })
{
const [value, setValue] = useState(null);
const [dropDownOpen, setDropDownOpen] = useState(false);
const [variants, setVariants] = useState(null);
const handleVariantChange = (event: React.SyntheticEvent, value: any | any[], reason: string, details?: string) =>
{
const tableVariantLocalStorageKey = `${TABLE_VARIANT_LOCAL_STORAGE_KEY_ROOT}.${props.table.name}`;
if (value != null)
{
localStorage.setItem(tableVariantLocalStorageKey, JSON.stringify(value));
}
else
{
localStorage.removeItem(tableVariantLocalStorageKey);
}
props.closeHandler(value);
};
const keyPressed = (e: React.KeyboardEvent<HTMLDivElement>) =>
{
if (e.key == "Enter" && value)
{
props.closeHandler(value);
}
};
useEffect(() =>
{
console.log("queryVariants");
try
{
(async () =>
{
const variants = await qController.tableVariants(props.table.name);
console.log(JSON.stringify(variants));
setVariants(variants);
})();
}
catch (e)
{
console.log(e);
}
}, []);
return variants && (
<Dialog open={props.isOpen} onKeyPress={(e) => keyPressed(e)}>
<DialogTitle>{props.table.variantTableLabel}</DialogTitle>
<DialogContent>
<DialogContentText>Select the {props.table.variantTableLabel} to be used on this table:</DialogContentText>
<Autocomplete
id="tableVariantId"
sx={{width: "400px", marginTop: "10px"}}
open={dropDownOpen}
size="small"
onOpen={() =>
{
setDropDownOpen(true);
}}
onClose={() =>
{
setDropDownOpen(false);
}}
// @ts-ignore
onChange={handleVariantChange}
isOptionEqualToValue={(option, value) => option.id === value.id}
options={variants}
renderInput={(params) => <TextField {...params} label={props.table.variantTableLabel} />}
getOptionLabel={(option) =>
{
if (typeof option == "object")
{
return (option as QTableVariant).name;
}
return option;
}}
/>
</DialogContent>
</Dialog>
);
}

View File

@ -1,92 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Icon from "@mui/material/Icon";
import IconButton from "@mui/material/IconButton";
import React, {useContext} from "react";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
interface XIconProps
{
onClick: (e: React.MouseEvent<HTMLSpanElement>) => void;
position: "forQuickFilter" | "forAdvancedQueryPreview" | "default";
shade: "default" | "accent" | "accentLight"
}
XIcon.defaultProps = {
position: "default",
shade: "default"
};
export default function XIcon({onClick, position, shade}: XIconProps): JSX.Element
{
const {accentColor, accentColorLight} = useContext(QContext)
//////////////////////////
// for default position //
//////////////////////////
let rest: any = {
top: "-0.75rem",
left: "-0.5rem",
}
if(position == "forQuickFilter")
{
rest = {
left: "-1.125rem",
}
}
else if(position == "forAdvancedQueryPreview")
{
rest = {
top: "-0.5rem",
left: "-0.75rem",
}
}
let color;
switch (shade)
{
case "default":
color = colors.gray.main;
break;
case "accent":
color = accentColor;
break;
case "accentLight":
color = accentColorLight;
break;
}
return (
<span style={{position: "relative"}}><IconButton sx={{
fontSize: "0.75rem",
border: `1px solid ${color}`,
color: color,
padding: "0",
background: "#FFFFFF !important",
position: "absolute",
... rest
}} onClick={onClick}><Icon>close</Icon></IconButton></span>
)
}

View File

@ -49,7 +49,7 @@ function ScriptDocsForm({helpText, exampleCode, aceEditorHeight}: Props): JSX.El
<Card sx={{width: "100%", height: "100%"}}>
<Typography variant="h6" p={2} pb={1}>{heading}</Typography>
<Box className="devDocumentation" height="100%">
<Typography variant="body2" sx={{maxWidth: "1200px", margin: "auto", height: "calc(100% - 0.5rem)"}}>
<Typography variant="body2" sx={{maxWidth: "1200px", margin: "auto", height: "100%"}}>
<AceEditor
mode={mode}
theme="github"
@ -62,7 +62,7 @@ function ScriptDocsForm({helpText, exampleCode, aceEditorHeight}: Props): JSX.El
width="100%"
showPrintMargin={false}
height="100%"
style={{borderBottomRightRadius: "0.75rem", borderBottomLeftRadius: "0.75rem"}}
style={{borderBottomRightRadius: "1rem", borderBottomLeftRadius: "1rem"}}
/>
</Typography>
</Box>

View File

@ -40,17 +40,16 @@ import Snackbar from "@mui/material/Snackbar";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip";
import FormData from "form-data";
import React, {useEffect, useReducer, useRef, useState} from "react";
import AceEditor from "react-ace";
import {QCancelButton, QSaveButton} from "qqq/components/buttons/DefaultButtons";
import DynamicSelect from "qqq/components/forms/DynamicSelect";
import ScriptDocsForm from "qqq/components/scripts/ScriptDocsForm";
import ScriptTestForm from "qqq/components/scripts/ScriptTestForm";
import Client from "qqq/utils/qqq/Client";
import "ace-builds/src-noconflict/ace";
import "ace-builds/src-noconflict/mode-javascript";
import "ace-builds/src-noconflict/theme-github";
import React, {useEffect, useReducer, useRef, useState} from "react";
import AceEditor from "react-ace";
import "ace-builds/src-noconflict/ext-language_tools";
export interface ScriptEditorProps
@ -70,15 +69,15 @@ const qController = Client.getInstance();
function buildInitialFileContentsMap(scriptRevisionRecord: QRecord, scriptTypeFileSchemaList: QRecord[]): { [name: string]: string }
{
const rs: { [name: string]: string } = {};
const rs: {[name: string]: string} = {};
if (!scriptTypeFileSchemaList)
if(!scriptTypeFileSchemaList)
{
console.log("Missing scriptTypeFileSchemaList");
}
else
{
let files = scriptRevisionRecord?.associatedRecords?.get("files");
let files = scriptRevisionRecord?.associatedRecords?.get("files")
for (let i = 0; i < scriptTypeFileSchemaList.length; i++)
{
@ -89,7 +88,7 @@ function buildInitialFileContentsMap(scriptRevisionRecord: QRecord, scriptTypeFi
for (let j = 0; j < files?.length; j++)
{
let file = files[j];
if (file.values.get("fileName") == name)
if(file.values.get("fileName") == name)
{
contents = file.values.get("contents");
}
@ -104,9 +103,9 @@ function buildInitialFileContentsMap(scriptRevisionRecord: QRecord, scriptTypeFi
function buildFileTypeMap(scriptTypeFileSchemaList: QRecord[]): { [name: string]: string }
{
const rs: { [name: string]: string } = {};
const rs: {[name: string]: string} = {};
if (!scriptTypeFileSchemaList)
if(!scriptTypeFileSchemaList)
{
console.log("Missing scriptTypeFileSchemaList");
}
@ -126,21 +125,21 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
{
const [closing, setClosing] = useState(false);
const [apiName, setApiName] = useState(scriptRevisionRecord ? scriptRevisionRecord.values.get("apiName") : null);
const [apiNameLabel, setApiNameLabel] = useState(scriptRevisionRecord ? scriptRevisionRecord.displayValues.get("apiName") : null);
const [apiVersion, setApiVersion] = useState(scriptRevisionRecord ? scriptRevisionRecord.values.get("apiVersion") : null);
const [apiVersionLabel, setApiVersionLabel] = useState(scriptRevisionRecord ? scriptRevisionRecord.displayValues.get("apiVersion") : null);
const [apiName, setApiName] = useState(scriptRevisionRecord ? scriptRevisionRecord.values.get("apiName") : null)
const [apiNameLabel, setApiNameLabel] = useState(scriptRevisionRecord ? scriptRevisionRecord.displayValues.get("apiName") : null)
const [apiVersion, setApiVersion] = useState(scriptRevisionRecord ? scriptRevisionRecord.values.get("apiVersion") : null)
const [apiVersionLabel, setApiVersionLabel] = useState(scriptRevisionRecord ? scriptRevisionRecord.displayValues.get("apiVersion") : null)
const fileNamesFromSchema = scriptTypeFileSchemaList.map((schemaRecord) => schemaRecord.values.get("name"));
const fileNamesFromSchema = scriptTypeFileSchemaList.map((schemaRecord) => schemaRecord.values.get("name"))
const [availableFileNames, setAvailableFileNames] = useState(fileNamesFromSchema);
const [openEditorFileNames, setOpenEditorFileNames] = useState([fileNamesFromSchema[0]]);
const [fileContents, setFileContents] = useState(buildInitialFileContentsMap(scriptRevisionRecord, scriptTypeFileSchemaList));
const [fileTypes, setFileTypes] = useState(buildFileTypeMap(scriptTypeFileSchemaList));
const [openEditorFileNames, setOpenEditorFileNames] = useState([fileNamesFromSchema[0]])
const [fileContents, setFileContents] = useState(buildInitialFileContentsMap(scriptRevisionRecord, scriptTypeFileSchemaList))
const [fileTypes, setFileTypes] = useState(buildFileTypeMap(scriptTypeFileSchemaList))
console.log(`file types: ${JSON.stringify(fileTypes)}`);
const [commitMessage, setCommitMessage] = useState("");
const [commitMessage, setCommitMessage] = useState("")
const [openTool, setOpenTool] = useState(null);
const [errorAlert, setErrorAlert] = useState("");
const [errorAlert, setErrorAlert] = useState("")
const [promptForCommitMessageOpen, setPromptForCommitMessageOpen] = useState(false);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
const ref = useRef();
@ -242,19 +241,19 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
// need this to make Ace recognize new height.
setTimeout(() =>
{
window.dispatchEvent(new Event("resize"));
window.dispatchEvent(new Event("resize"))
}, 100);
};
const saveClicked = (overrideCommitMessage?: string) =>
{
if (!apiName || !apiVersion)
if(!apiName || !apiVersion)
{
setErrorAlert("You must select a value for both API Name and API Version.");
setErrorAlert("You must select a value for both API Name and API Version.")
return;
}
if (!commitMessage && !overrideCommitMessage)
if(!commitMessage && !overrideCommitMessage)
{
setPromptForCommitMessageOpen(true);
return;
@ -268,18 +267,18 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
formData.append("scriptId", scriptId);
formData.append("commitMessage", overrideCommitMessage ?? commitMessage);
if (apiName)
if(apiName)
{
formData.append("apiName", apiName);
}
if (apiVersion)
if(apiVersion)
{
formData.append("apiVersion", apiVersion);
}
const fileNamesFromSchema = scriptTypeFileSchemaList.map((schemaRecord) => schemaRecord.values.get("name"));
const fileNamesFromSchema = scriptTypeFileSchemaList.map((schemaRecord) => schemaRecord.values.get("name"))
formData.append("fileNames", fileNamesFromSchema.join(","));
for (let fileName in fileContents)
@ -300,58 +299,58 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
setErrorAlert(jobError.userFacingError ?? jobError.error);
const jobError = processResult as QJobError
setErrorAlert(jobError.userFacingError ?? jobError.error)
setClosing(false);
return;
}
closeCallback(null, "saved", "Saved New Script Version");
}
catch (e)
catch(e)
{
// @ts-ignore
setErrorAlert(e.message ?? "Unexpected error saving script");
setErrorAlert(e.message ?? "Unexpected error saving script")
setClosing(false);
}
})();
};
}
const cancelClicked = () =>
{
setClosing(true);
closeCallback(null, "cancelled");
};
}
const updateCode = (value: string, event: any, index: number) =>
{
fileContents[openEditorFileNames[index]] = value;
forceUpdate();
};
}
const updateCommitMessage = (event: React.ChangeEvent<HTMLInputElement>) =>
{
setCommitMessage(event.target.value);
};
}
const closePromptForCommitMessage = (wasSaveClicked: boolean, message?: string) =>
{
setPromptForCommitMessageOpen(false);
if (wasSaveClicked)
if(wasSaveClicked)
{
setCommitMessage(message);
setCommitMessage(message)
saveClicked(message);
}
else
{
setClosing(false);
}
};
}
const changeApiName = (apiNamePossibleValue?: QPossibleValue) =>
{
if (apiNamePossibleValue)
if(apiNamePossibleValue)
{
setApiName(apiNamePossibleValue.id);
}
@ -359,11 +358,11 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
{
setApiName(null);
}
};
}
const changeApiVersion = (apiVersionPossibleValue?: QPossibleValue) =>
{
if (apiVersionPossibleValue)
if(apiVersionPossibleValue)
{
setApiVersion(apiVersionPossibleValue.id);
}
@ -371,33 +370,33 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
{
setApiVersion(null);
}
};
}
const handleSelectingFile = (event: SelectChangeEvent, index: number) =>
{
openEditorFileNames[index] = event.target.value;
openEditorFileNames[index] = event.target.value
setOpenEditorFileNames(openEditorFileNames);
forceUpdate();
};
}
const splitEditorClicked = () =>
{
openEditorFileNames.push(availableFileNames[0]);
openEditorFileNames.push(availableFileNames[0])
setOpenEditorFileNames(openEditorFileNames);
forceUpdate();
};
}
const closeEditorClicked = (index: number) =>
{
openEditorFileNames.splice(index, 1);
openEditorFileNames.splice(index, 1)
setOpenEditorFileNames(openEditorFileNames);
forceUpdate();
};
}
const computeEditorWidth = (): string =>
{
return (100 / openEditorFileNames.length) + "%";
};
return (100 / openEditorFileNames.length) + "%"
}
return (
<Box className="scriptEditor" sx={{position: "absolute", overflowY: "auto", height: "100%", width: "100%"}} p={6}>
@ -409,7 +408,7 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
{
return;
}
setErrorAlert("");
setErrorAlert("")
}} anchorOrigin={{vertical: "top", horizontal: "center"}}>
<Alert color="error" onClose={() => setErrorAlert("")}>
{errorAlert}
@ -441,10 +440,10 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
<Box sx={{height: openTool ? "45%" : "100%"}}>
<Grid container alignItems="flex-end">
<Box maxWidth={"50%"} minWidth={300}>
<DynamicSelect fieldPossibleValueProps={{tableName: "scriptRevision", fieldName: "apiName", initialDisplayValue: apiNameLabel}} initialValue={apiName} fieldLabel={"API Name *"} inForm={false} onChange={changeApiName} useCase="form" />
<DynamicSelect fieldName={"apiName"} initialValue={apiName} initialDisplayValue={apiNameLabel} fieldLabel={"API Name *"} tableName={"scriptRevision"} inForm={false} onChange={changeApiName} />
</Box>
<Box maxWidth={"50%"} minWidth={300} pl={2}>
<DynamicSelect fieldPossibleValueProps={{tableName: "scriptRevision", fieldName: "apiVersion", initialDisplayValue: apiVersionLabel}} initialValue={apiVersion} fieldLabel={"API Version *"} inForm={false} onChange={changeApiVersion} useCase="form" />
<DynamicSelect fieldName={"apiVersion"} initialValue={apiVersion} initialDisplayValue={apiVersionLabel} fieldLabel={"API Version *"} tableName={"scriptRevision"} inForm={false} onChange={changeApiVersion} />
</Box>
</Grid>
<Box display="flex" sx={{height: "100%"}}>
@ -465,19 +464,19 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
<Box>
{
openEditorFileNames.length > 1 &&
<Tooltip title="Close this editor split" enterDelay={500}>
<IconButton size="small" onClick={() => closeEditorClicked(index)}>
<Icon>close</Icon>
</IconButton>
</Tooltip>
<Tooltip title="Close this editor split" enterDelay={500}>
<IconButton size="small" onClick={() => closeEditorClicked(index)}>
<Icon>close</Icon>
</IconButton>
</Tooltip>
}
{
index == openEditorFileNames.length - 1 &&
<Tooltip title="Open a new editor split" enterDelay={500}>
<IconButton size="small" onClick={splitEditorClicked}>
<Icon>vertical_split</Icon>
</IconButton>
</Tooltip>
<Tooltip title="Open a new editor split" enterDelay={500}>
<IconButton size="small" onClick={splitEditorClicked}>
<Icon>vertical_split</Icon>
</IconButton>
</Tooltip>
}
</Box>
</Box>
@ -527,29 +526,29 @@ function ScriptEditor({title, scriptId, scriptRevisionRecord, closeCallback, tab
</Grid>
</Box>
<CommitMessagePrompt isOpen={promptForCommitMessageOpen} closeHandler={closePromptForCommitMessage} />
<CommitMessagePrompt isOpen={promptForCommitMessageOpen} closeHandler={closePromptForCommitMessage}/>
</Card>
</Box>
);
}
function CommitMessagePrompt(props: { isOpen: boolean, closeHandler: (wasSaveClicked: boolean, message?: string) => void })
function CommitMessagePrompt(props: {isOpen: boolean, closeHandler: (wasSaveClicked: boolean, message?: string) => void})
{
const [commitMessage, setCommitMessage] = useState("No commit message given");
const [commitMessage, setCommitMessage] = useState("No commit message given")
const updateCommitMessage = (event: React.ChangeEvent<HTMLInputElement>) =>
{
setCommitMessage(event.target.value);
};
}
const keyPressHandler = (e: React.KeyboardEvent<HTMLDivElement>) =>
{
if (e.key === "Enter")
if(e.key === "Enter")
{
props.closeHandler(true, commitMessage);
}
};
}
return (
<Dialog
@ -580,10 +579,10 @@ function CommitMessagePrompt(props: { isOpen: boolean, closeHandler: (wasSaveCli
</DialogContent>
<DialogActions>
<QCancelButton onClickHandler={() => props.closeHandler(false)} disabled={false} />
<QSaveButton label="Save" onClickHandler={() => props.closeHandler(true, commitMessage)} disabled={false} />
<QSaveButton label="Save" onClickHandler={() => props.closeHandler(true, commitMessage)} disabled={false}/>
</DialogActions>
</Dialog>
);
)
}
export default ScriptEditor;

View File

@ -1,487 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QJobComplete} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobComplete";
import {QJobError} from "@kingsrook/qqq-frontend-core/lib/model/processes/QJobError";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Alert, Box} from "@mui/material";
import Autocomplete from "@mui/material/Autocomplete";
import Button from "@mui/material/Button";
import Card from "@mui/material/Card";
import Icon from "@mui/material/Icon";
import Modal from "@mui/material/Modal";
import TextField from "@mui/material/TextField";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography";
import FormData from "form-data";
import colors from "qqq/assets/theme/base/colors";
import {QCancelButton} from "qqq/components/buttons/DefaultButtons";
import DynamicSelect, {getAutocompleteOutlinedStyle} from "qqq/components/forms/DynamicSelect";
import Client from "qqq/utils/qqq/Client";
import React, {useEffect, useReducer, useState} from "react";
interface ShareModalProps
{
open: boolean;
onClose: () => void;
tableMetaData: QTableMetaData;
record: QRecord;
}
ShareModal.defaultProps = {};
interface CurrentShare
{
shareId: any;
scopeId: string;
audienceType: string;
audienceId: any;
audienceLabel: string;
}
interface Scope
{
id: string;
label: string;
}
const scopeOptions: Scope[] = [
{id: "READ_ONLY", label: "Read-Only"},
{id: "READ_WRITE", label: "Read and Edit"}
];
const defaultScope = scopeOptions[0];
const qController = Client.getInstance();
interface ShareableTableMetaData
{
sharedRecordTableName: string;
assetIdFieldName: string;
scopeFieldName: string;
audienceTypesPossibleValueSourceName: string;
audiencePossibleValueSourceName: string;
thisTableOwnerIdFieldName: string;
audienceTypes: {[name: string]: any}; // values here are: ShareableAudienceType
}
/*******************************************************************************
** component containing a Modal dialog for sharing records
*******************************************************************************/
export default function ShareModal({open, onClose, tableMetaData, record}: ShareModalProps): JSX.Element
{
const [statusString, setStatusString] = useState("Loading...");
const [alert, setAlert] = useState(null as string);
const [selectedAudienceOption, setSelectedAudienceOption] = useState(null as {id: string, label: string});
const [selectedAudienceType, setSelectedAudienceType] = useState(null);
const [selectedAudienceId, setSelectedAudienceId] = useState(null);
const [selectedScopeId, setSelectedScopeId] = useState(defaultScope.id);
const [submitting, setSubmitting] = useState(false);
const [currentShares, setCurrentShares] = useState([] as CurrentShare[])
const [needToLoadCurrentShares, setNeedToLoadCurrentShares] = useState(true);
const [everLoadedCurrentShares, setEverLoadedCurrentShares] = useState(false);
const shareableTableMetaData = tableMetaData.shareableTableMetaData as ShareableTableMetaData;
const [, forceUpdate] = useReducer((x) => x + 1, 0);
if(!shareableTableMetaData)
{
console.error(`Did not find a shareableTableMetaData on table ${tableMetaData.name}`);
}
/////////////////////////////////////////////////////////
// trigger initial load, and post any changes, re-load //
/////////////////////////////////////////////////////////
useEffect(() =>
{
if(needToLoadCurrentShares)
{
loadShares();
}
}, [needToLoadCurrentShares]);
/*******************************************************************************
**
*******************************************************************************/
function close(event: object, reason: string)
{
if (reason === "backdropClick" || reason === "escapeKeyDown")
{
return;
}
onClose();
}
/*******************************************************************************
**
*******************************************************************************/
function handleAudienceChange(value: any | any[], reason: string)
{
if(value)
{
const [audienceType, audienceId] = value.id.split(":");
setSelectedAudienceType(audienceType);
setSelectedAudienceId(audienceId);
}
else
{
setSelectedAudienceType(null);
setSelectedAudienceId(null);
}
}
/*******************************************************************************
**
*******************************************************************************/
function handleScopeChange(event: React.SyntheticEvent, value: any | any[], reason: string)
{
if(value)
{
setSelectedScopeId(value.id);
}
else
{
setSelectedScopeId(null);
}
}
/*******************************************************************************
**
*******************************************************************************/
async function editingExistingShareScope(shareId: number, value: any | any[])
{
setStatusString("Saving...");
setAlert(null);
const formData = new FormData();
formData.append("tableName", tableMetaData.name);
formData.append("recordId", record.values.get(tableMetaData.primaryKeyField));
formData.append("shareId", shareId);
formData.append("scopeId", value.id);
const processResult = await qController.processRun("editSharedRecord", formData, null, true);
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
setStatusString(null);
setAlert("Error editing shared record: " + jobError.error);
setSubmitting(false)
}
else
{
const result = processResult as QJobComplete;
setStatusString(null);
setAlert(null);
setNeedToLoadCurrentShares(true);
setSubmitting(false)
}
}
/*******************************************************************************
**
*******************************************************************************/
async function loadShares()
{
setNeedToLoadCurrentShares(false);
const formData = new FormData();
formData.append("tableName", tableMetaData.name);
formData.append("recordId", record.values.get(tableMetaData.primaryKeyField));
const processResult = await qController.processRun("getSharedRecords", formData, null, true);
setStatusString("Loading...");
setAlert(null)
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
setStatusString(null);
setAlert("Error loading: " + jobError.error);
}
else
{
const result = processResult as QJobComplete;
const newCurrentShares: CurrentShare[] = [];
for (let i in result.values["resultList"])
{
newCurrentShares.push(result.values["resultList"][i].values);
}
setCurrentShares(newCurrentShares);
setEverLoadedCurrentShares(true);
setStatusString(null);
setAlert(null);
}
}
/*******************************************************************************
**
*******************************************************************************/
async function saveNewShare()
{
setSubmitting(true)
setStatusString("Saving...");
setAlert(null);
const formData = new FormData();
formData.append("tableName", tableMetaData.name);
formData.append("recordId", record.values.get(tableMetaData.primaryKeyField));
formData.append("audienceType", selectedAudienceType);
formData.append("audienceId", selectedAudienceId);
formData.append("scopeId", selectedScopeId);
const processResult = await qController.processRun("insertSharedRecord", formData, null, true);
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
setStatusString(null);
setAlert("Error sharing record: " + jobError.error);
setSubmitting(false)
}
else
{
const result = processResult as QJobComplete;
setStatusString(null);
setAlert(null);
setSelectedAudienceOption(null);
setNeedToLoadCurrentShares(true);
setSubmitting(false)
}
}
/*******************************************************************************
**
*******************************************************************************/
async function removeShare(shareId: number)
{
setStatusString("Deleting...");
setAlert(null);
const formData = new FormData();
formData.append("tableName", tableMetaData.name);
formData.append("recordId", record.values.get(tableMetaData.primaryKeyField));
formData.append("shareId", shareId);
const processResult = await qController.processRun("deleteSharedRecord", formData, null, true);
if (processResult instanceof QJobError)
{
const jobError = processResult as QJobError;
setStatusString(null);
setAlert("Error deleting share: " + jobError.error);
}
else
{
const result = processResult as QJobComplete;
setNeedToLoadCurrentShares(true);
setStatusString(null);
setAlert(null);
}
}
/*******************************************************************************
**
*******************************************************************************/
function getScopeOption(scopeId: string): Scope
{
for (let scopeOption of scopeOptions)
{
if(scopeOption.id == scopeId)
{
return (scopeOption);
}
}
return (null);
}
/*******************************************************************************
**
*******************************************************************************/
function renderScopeDropdown(id: string, defaultValue: Scope, onChange: (event: React.SyntheticEvent, value: any | any[], reason: string) => void)
{
const isDisabled = (id == "new-share-scope" && submitting);
return (
<Autocomplete
id={id}
disabled={isDisabled}
renderInput={(params) => (<TextField {...params} label="Scope" variant="outlined" autoComplete="off" type="search" InputProps={{...params.InputProps}} />)}
options={scopeOptions}
// @ts-ignore
defaultValue={defaultValue}
onChange={onChange}
isOptionEqualToValue={(option, value) => option.id === value.id}
// @ts-ignore Property label does not exist on string | {thing with label}
getOptionLabel={(option) => option.label}
autoSelect={true}
autoHighlight={true}
disableClearable
fullWidth
sx={getAutocompleteOutlinedStyle(isDisabled)}
/>
);
}
//////////////////////
// render the modal //
//////////////////////
return (<Modal open={open} onClose={close}>
<div className="share">
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%", display: "flex", height: "100%", flexDirection: "column", justifyContent: "center"}}>
<Card sx={{my: 5, mx: "auto", p: 3}}>
{/* header */}
<Box display="flex" flexDirection="row" justifyContent="space-between" alignItems="flex-start" maxWidth="590px">
<Typography variant="h4" pb={1} fontWeight="600">
Share {tableMetaData.label}: {record?.recordLabel ?? record?.values?.get(tableMetaData.primaryKeyField) ?? "Unknown"}
<Box color={colors.gray.main} pb={"0.5rem"} fontSize={"0.875rem"} fontWeight="400">
{/* todo move to helpContent (what do we attach the meta-data too??) */}
Select a user or a group to share this record with.
{/*You can choose if they should only be able to Read the record, or also make Edits to it.*/}
</Box>
<Box fontSize={14} pb={1} fontWeight="300">
{alert && <Alert color="error" onClose={() => setAlert(null)}>{alert}</Alert>}
{statusString}
{!alert && !statusString && (<>&nbsp;</>)}
</Box>
</Typography>
</Box>
{/* body */}
<Box pb={3} display="flex" flexDirection="column">
{/* row for adding a new share */}
<Box display="flex" flexDirection="row" alignItems="center">
<Box width="550px" pr={2} mb={-1.5}>
<DynamicSelect
fieldPossibleValueProps={{possibleValueSourceName: shareableTableMetaData.audiencePossibleValueSourceName, initialDisplayValue: selectedAudienceOption?.label}}
fieldLabel="User or Group" // todo should come from shareableTableMetaData
initialValue={selectedAudienceOption?.id}
inForm={false}
onChange={handleAudienceChange}
useCase="form"
/>
</Box>
{/*
when turning scope back on, change width of audience box to 350px
<Box width="180px" pr={2}>
{renderScopeDropdown("new-share-scope", defaultScope, handleScopeChange)}
</Box>
*/}
<Box>
<Tooltip title={selectedAudienceId == null ? "Select a user or group to share with." : null}>
<span>
<Button disabled={submitting || selectedAudienceId == null} sx={iconButtonSX} onClick={() => saveNewShare()}>
<Icon color={selectedAudienceId == null ? "secondary" : "info"}>save</Icon>
</Button>
</span>
</Tooltip>
</Box>
</Box>
{/* row showing existing shares */}
<Box pt={3}>
<Box pb="0.25rem">
<h5 style={{fontWeight: "600"}}>Current Shares
{
everLoadedCurrentShares ? <>&nbsp;({currentShares.length})</> : <></>
}
</h5>
</Box>
<Box sx={{border: `1px solid ${colors.grayLines.main}`, borderRadius: "1rem", overflow: "hidden"}}>
<Box sx={{overflow: "auto"}} height="210px" pt="0.75rem">
{
currentShares.map((share) => (
<Box key={share.shareId} display="flex" justifyContent="space-between" alignItems="center" p="0.25rem" pb="0.75rem" fontSize="1rem">
<Box display="flex" alignItems="center">
<Box width="490px" pl="1rem">{share.audienceLabel}</Box>
{/*
when turning scope back on, change width of audience box to 310px
<Box width="160px">{renderScopeDropdown(`scope-${share.shareId}`, getScopeOption(share.scopeId), (event: React.SyntheticEvent, value: any | any[], reason: string) => editingExistingShareScope(share.shareId, value))}</Box>
*/}
</Box>
<Box pr="1rem">
<Button sx={{...iconButtonSX, ...redIconButton}} onClick={() => removeShare(share.shareId)}><Icon>clear</Icon></Button>
</Box>
</Box>
))
}
</Box>
</Box>
</Box>
</Box>
{/* footer */}
<Box display="flex" flexDirection="row" justifyContent="flex-end">
<QCancelButton label="Done" iconName="check" onClickHandler={() => close(null, null)} disabled={false} />
</Box>
</Card>
</Box>
</div>
</Modal>);
}
const iconButtonSX =
{
border: `1px solid ${colors.grayLines.main} !important`,
borderRadius: "0.75rem",
textTransform: "none",
fontSize: "1rem",
fontWeight: "400",
width: "40px",
minWidth: "40px",
paddingLeft: 0,
paddingRight: 0,
color: colors.secondary.main,
"&:hover": {color: colors.secondary.main},
"&:focus": {color: colors.secondary.main},
"&:focus:not(:hover)": {color: colors.secondary.main},
};
const redIconButton =
{
color: colors.error.main,
"&:hover": {color: colors.error.main},
"&:focus": {color: colors.error.main},
"&:focus:not(:hover)": {color: colors.error.main},
};

View File

@ -1,217 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Box, Skeleton} from "@mui/material";
import Card from "@mui/material/Card";
import Modal from "@mui/material/Modal";
import parse from "html-react-parser";
import {BlockData} from "qqq/components/widgets/blocks/BlockModels";
import WidgetBlock from "qqq/components/widgets/WidgetBlock";
import ProcessWidgetBlockUtils from "qqq/pages/processes/ProcessWidgetBlockUtils";
import React, {useEffect, useState} from "react";
export interface CompositeData
{
blockId: string;
blocks: BlockData[];
styleOverrides?: any;
layout?: string;
overlayHtml?: string;
overlayStyleOverrides?: any;
modalMode: string;
styles?: any;
}
interface CompositeWidgetProps
{
widgetMetaData: QWidgetMetaData;
data: CompositeData;
actionCallback?: (blockData: BlockData, eventValues?: { [name: string]: any }) => boolean;
values?: { [key: string]: any };
}
/*******************************************************************************
** Widget which is a list of Blocks.
*******************************************************************************/
export default function CompositeWidget({widgetMetaData, data, actionCallback, values}: CompositeWidgetProps): JSX.Element
{
if (!data || !data.blocks)
{
return (<Skeleton />);
}
////////////////////////////////////////////////////////////////////////////////////
// note - these layouts are defined in qqq in the CompositeWidgetData.Layout enum //
////////////////////////////////////////////////////////////////////////////////////
let layout = data?.layout;
let boxStyle: any = {};
if (layout == "FLEX_COLUMN")
{
boxStyle.display = "flex";
boxStyle.flexDirection = "column";
boxStyle.flexWrap = "wrap";
boxStyle.gap = "0.5rem";
}
else if (layout == "FLEX_ROW_WRAPPED")
{
boxStyle.display = "flex";
boxStyle.flexDirection = "row";
boxStyle.flexWrap = "wrap";
boxStyle.gap = "0.5rem";
}
else if (layout == "FLEX_ROW")
{
boxStyle.display = "flex";
boxStyle.flexDirection = "row";
boxStyle.gap = "0.5rem";
}
else if (layout == "FLEX_ROW_SPACE_BETWEEN")
{
boxStyle.display = "flex";
boxStyle.flexDirection = "row";
boxStyle.justifyContent = "space-between";
boxStyle.gap = "0.25rem";
}
else if (layout == "FLEX_ROW_CENTER")
{
boxStyle.display = "flex";
boxStyle.flexDirection = "row";
boxStyle.justifyContent = "center";
boxStyle.gap = "0.25rem";
boxStyle.flexWrap = "wrap";
}
else if (layout == "TABLE_SUB_ROW_DETAILS")
{
boxStyle.display = "flex";
boxStyle.flexDirection = "column";
boxStyle.fontSize = "0.875rem";
boxStyle.fontWeight = 400;
boxStyle.borderRight = "1px solid #D0D0D0";
}
else if (layout == "BADGES_WRAPPER")
{
boxStyle.display = "flex";
boxStyle.gap = "0.25rem";
boxStyle.padding = "0 0.25rem";
boxStyle.fontSize = "0.875rem";
boxStyle.fontWeight = 400;
boxStyle.border = "1px solid gray";
boxStyle.borderRadius = "0.5rem";
boxStyle.background = "#FFFFFF";
}
if (data?.styleOverrides)
{
boxStyle = {...boxStyle, ...data.styleOverrides};
}
if (data.styles?.backgroundColor)
{
boxStyle.backgroundColor = ProcessWidgetBlockUtils.processColorFromStyleMap(data.styles.backgroundColor);
}
if (data.styles?.padding)
{
boxStyle.paddingTop = data.styles?.padding.top + "px"
boxStyle.paddingBottom = data.styles?.padding.bottom + "px"
boxStyle.paddingLeft = data.styles?.padding.left + "px"
boxStyle.paddingRight = data.styles?.padding.right + "px"
}
let overlayStyle: any = {};
if (data?.overlayStyleOverrides)
{
overlayStyle = {...overlayStyle, ...data.overlayStyleOverrides};
}
const content = (
<>
{
data?.overlayHtml &&
<Box sx={overlayStyle} className="blockWidgetOverlay">{parse(data.overlayHtml)}</Box>
}
<Box sx={boxStyle} className="compositeWidget">
{
data.blocks.map((block: BlockData, index) => (
<React.Fragment key={index}>
<WidgetBlock widgetMetaData={widgetMetaData} block={block} actionCallback={actionCallback} values={values} />
</React.Fragment>
))
}
</Box>
</>
);
if (data.modalMode)
{
const [isModalOpen, setIsModalOpen] = useState(values && (values[data.blockId] == true));
/***************************************************************************
**
***************************************************************************/
const controlCallback = (newValue: boolean) =>
{
setIsModalOpen(newValue);
};
/***************************************************************************
**
***************************************************************************/
const modalOnClose = (event: object, reason: string) =>
{
values[data.blockId] = false;
setIsModalOpen(false);
actionCallback({blockTypeName: "BUTTON", values: {}}, {controlCode: `hideModal:${data.blockId}`});
};
//////////////////////////////////////////////////////////////////////////////////////////
// register the control-callback function - so when buttons are clicked, we can be told //
//////////////////////////////////////////////////////////////////////////////////////////
useEffect(() =>
{
if (actionCallback)
{
actionCallback(null, {
registerControlCallbackName: data.blockId,
registerControlCallbackFunction: controlCallback
});
}
}, []);
return (<Modal open={isModalOpen} onClose={modalOnClose}>
<Box sx={{position: "absolute", overflowY: "auto", maxHeight: "100%", width: "100%"}}>
<Card sx={{my: 5, mx: "auto", p: "1rem", maxWidth: "1024px"}}>
{content}
</Card>
</Box>
</Modal>);
}
else
{
return content;
}
}

View File

@ -18,46 +18,35 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
import {Alert, Skeleton} from "@mui/material";
import {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 React, {useContext, useEffect, useReducer, useState} from "react";
import {useLocation} from "react-router-dom";
import QContext from "QContext";
import EntityForm from "qqq/components/forms/EntityForm";
import MDTypography from "qqq/components/legacy/MDTypography";
import TabPanel from "qqq/components/misc/TabPanel";
import BarChart from "qqq/components/widgets/charts/barchart/BarChart";
import HorizontalBarChart from "qqq/components/widgets/charts/barchart/HorizontalBarChart";
import DefaultLineChart from "qqq/components/widgets/charts/linechart/DefaultLineChart";
import SmallLineChart from "qqq/components/widgets/charts/linechart/SmallLineChart";
import PieChart from "qqq/components/widgets/charts/piechart/PieChart";
import StackedBarChart from "qqq/components/widgets/charts/StackedBarChart";
import CompositeWidget from "qqq/components/widgets/CompositeWidget";
import DataBagViewer from "qqq/components/widgets/misc/DataBagViewer";
import DividerWidget from "qqq/components/widgets/misc/Divider";
import DynamicFormWidget from "qqq/components/widgets/misc/DynamicFormWidget";
import FieldValueListWidget from "qqq/components/widgets/misc/FieldValueListWidget";
import FilterAndColumnsSetupWidget from "qqq/components/widgets/misc/FilterAndColumnsSetupWidget";
import PivotTableSetupWidget from "qqq/components/widgets/misc/PivotTableSetupWidget";
import QuickSightChart from "qqq/components/widgets/misc/QuickSightChart";
import RecordGridWidget, {ChildRecordListData} from "qqq/components/widgets/misc/RecordGridWidget";
import RecordGridWidget from "qqq/components/widgets/misc/RecordGridWidget";
import ScriptViewer from "qqq/components/widgets/misc/ScriptViewer";
import StepperCard from "qqq/components/widgets/misc/StepperCard";
import USMapWidget from "qqq/components/widgets/misc/USMapWidget";
import ParentWidget from "qqq/components/widgets/ParentWidget";
import MultiStatisticsCard from "qqq/components/widgets/statistics/MultiStatisticsCard";
import StatisticsCard from "qqq/components/widgets/statistics/StatisticsCard";
import Widget, {HeaderIcon, LabelComponent, WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT, WidgetData} from "qqq/components/widgets/Widget";
import WidgetBlock from "qqq/components/widgets/WidgetBlock";
import Widget, {WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT} from "qqq/components/widgets/Widget";
import ProcessRun from "qqq/pages/processes/ProcessRun";
import Client from "qqq/utils/qqq/Client";
import React, {useContext, useEffect, useReducer, useState} from "react";
import TableWidget from "./tables/TableWidget";
@ -68,15 +57,10 @@ interface Props
widgetMetaDataList: QWidgetMetaData[];
tableName?: string;
entityPrimaryKey?: string;
record?: QRecord;
omitWrappingGridContainer: boolean;
areChildren?: boolean;
childUrlParams?: string;
parentWidgetMetaData?: QWidgetMetaData;
wrapWidgetsInTabPanels: boolean;
actionCallback?: (data: any, eventValues?: { [name: string]: any }) => boolean;
initialWidgetDataList: any[];
values?: { [key: string]: any };
areChildren?: boolean
childUrlParams?: string
parentWidgetMetaData?: QWidgetMetaData
}
DashboardWidgets.defaultProps = {
@ -86,16 +70,13 @@ DashboardWidgets.defaultProps = {
omitWrappingGridContainer: false,
areChildren: false,
childUrlParams: "",
parentWidgetMetaData: null,
wrapWidgetsInTabPanels: false,
actionCallback: null,
initialWidgetDataList: null,
values: {}
parentWidgetMetaData: null
};
function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, record, omitWrappingGridContainer, areChildren, childUrlParams, parentWidgetMetaData, wrapWidgetsInTabPanels, actionCallback, initialWidgetDataList, values}: Props): JSX.Element
function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, omitWrappingGridContainer, areChildren, childUrlParams, parentWidgetMetaData}: Props): JSX.Element
{
const [widgetData, setWidgetData] = useState(initialWidgetDataList == null ? [] as any[] : initialWidgetDataList);
const location = useLocation();
const [widgetData, setWidgetData] = useState([] as any[]);
const [widgetCounter, setWidgetCounter] = useState(0);
const [, forceUpdate] = useReducer((x) => x + 1, 0);
@ -103,40 +84,9 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
const [haveLoadedParams, setHaveLoadedParams] = useState(false);
const {accentColor} = useContext(QContext);
/////////////////////////
// modal form controls //
/////////////////////////
const [showEditChildForm, setShowEditChildForm] = useState(null as any);
let initialSelectedTab = 0;
let selectedTabKey: string = null;
if (parentWidgetMetaData && wrapWidgetsInTabPanels)
{
selectedTabKey = `qqq.widgets.selectedTabs.${parentWidgetMetaData.name}`;
if (localStorage.getItem(selectedTabKey))
{
initialSelectedTab = Number(localStorage.getItem(selectedTabKey));
}
}
const [selectedTab, setSelectedTab] = useState(initialSelectedTab);
const changeTab = (newValue: number) =>
{
setSelectedTab(newValue);
localStorage.setItem(selectedTabKey, String(newValue));
};
useEffect(() =>
{
if (initialWidgetDataList && initialWidgetDataList.length > 0)
{
// todo actually, should this check each element of the array, down in the loop? yeah, when we need to, do it that way.
console.log("We already have initial widget data, so not fetching from backend.");
return;
}
setWidgetData([]);
for (let i = 0; i < widgetMetaDataList.length; i++)
{
const widgetMetaData = widgetMetaDataList[i];
@ -152,15 +102,15 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
widgetData[i] = await qController.widget(widgetMetaData.name, urlParams);
setWidgetData(widgetData);
setWidgetCounter(widgetCounter + 1);
if (widgetData[i])
if(widgetData[i])
{
widgetData[i]["errorLoading"] = false;
}
}
catch (e)
catch(e)
{
console.error(e);
if (widgetData[i])
if(widgetData[i])
{
widgetData[i]["errorLoading"] = true;
}
@ -173,7 +123,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
const reloadWidget = async (index: number, data: string) =>
{
await (async () =>
(async() =>
{
const urlParams = getQueryParams(widgetMetaDataList[index], data);
setCurrentUrlParams(urlParams);
@ -190,7 +140,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
widgetData[index]["errorLoading"] = false;
}
}
catch (e)
catch(e)
{
console.error(e);
if (widgetData[index])
@ -201,7 +151,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
forceUpdate();
})();
};
}
function getQueryParams(widgetMetaData: QWidgetMetaData, extraParams: string): string
{
@ -218,7 +168,7 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
const metaDataToUse = (thisWidgetHasDropdowns) ? widgetMetaData : parentWidgetMetaData;
for (let i = 0; i < metaDataToUse.dropdowns.length; i++)
{
const dropdownName = metaDataToUse.dropdowns[i].possibleValueSourceName ?? metaDataToUse.dropdowns[i].name;
const dropdownName = metaDataToUse.dropdowns[i].possibleValueSourceName;
const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${metaDataToUse.name}.${dropdownName}`;
const json = JSON.parse(localStorage.getItem(localStorageKey));
if (json)
@ -228,36 +178,36 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
}
}
if (entityPrimaryKey)
if(entityPrimaryKey)
{
paramMap.set("id", entityPrimaryKey);
}
if (tableName)
if(tableName)
{
paramMap.set("tableName", tableName);
}
if (extraParams)
if(extraParams)
{
let pairs = extraParams.split("&");
for (let i = 0; i < pairs.length; i++)
{
let nameValue = pairs[i].split("=");
if (nameValue.length == 2)
if(nameValue.length == 2)
{
paramMap.set(nameValue[0], nameValue[1]);
}
}
}
if (childUrlParams)
if(childUrlParams)
{
let pairs = childUrlParams.split("&");
for (let i = 0; i < pairs.length; i++)
{
let nameValue = pairs[i].split("=");
if (nameValue.length == 2)
if(nameValue.length == 2)
{
paramMap.set(nameValue[0], nameValue[1]);
}
@ -275,192 +225,10 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
const widgetCount = widgetMetaDataList ? widgetMetaDataList.length : 0;
/*******************************************************************************
** helper function, to convert values from a QRecord values map to a regular old
** js object
*******************************************************************************/
function convertQRecordValuesFromMapToObject(record: QRecord): { [name: string]: any }
{
const rs: { [name: string]: any } = {};
if (record && record.values)
{
record.values.forEach((value, key) => rs[key] = value);
}
return (rs);
}
/*******************************************************************************
**
*******************************************************************************/
const closeEditChildForm = (event: object, reason: string) =>
{
if (reason === "backdropClick" || reason === "escapeKeyDown")
{
return;
}
setShowEditChildForm(null);
};
/*******************************************************************************
**
*******************************************************************************/
function deleteChildRecord(name: string, widgetIndex: number, rowIndex: number)
{
updateChildRecordList(name, "delete", rowIndex);
forceUpdate();
actionCallback(widgetData[widgetIndex]);
};
/*******************************************************************************
**
*******************************************************************************/
function openEditChildRecord(name: string, widgetData: any, rowIndex: number)
{
let defaultValues = widgetData.queryOutput.records[rowIndex].values;
let disabledFields = widgetData.disabledFieldsForNewChildRecords;
if (!disabledFields)
{
disabledFields = widgetData.defaultValuesForNewChildRecords;
}
doOpenEditChildForm(name, widgetData.childTableMetaData, rowIndex, defaultValues, disabledFields);
}
/*******************************************************************************
**
*******************************************************************************/
function openAddChildRecord(name: string, widgetData: any)
{
let defaultValues = widgetData.defaultValuesForNewChildRecords;
let disabledFields = widgetData.disabledFieldsForNewChildRecords;
if (!disabledFields)
{
disabledFields = widgetData.defaultValuesForNewChildRecords;
}
doOpenEditChildForm(name, widgetData.childTableMetaData, null, defaultValues, disabledFields);
}
/*******************************************************************************
**
*******************************************************************************/
function doOpenEditChildForm(widgetName: string, table: QTableMetaData, rowIndex: number, defaultValues: any, disabledFields: any)
{
const showEditChildForm: any = {};
showEditChildForm.widgetName = widgetName;
showEditChildForm.table = table;
showEditChildForm.rowIndex = rowIndex;
showEditChildForm.defaultValues = defaultValues;
showEditChildForm.disabledFields = disabledFields;
setShowEditChildForm(showEditChildForm);
}
/*******************************************************************************
**
*******************************************************************************/
function submitEditChildForm(values: any, tableName: string)
{
updateChildRecordList(showEditChildForm.widgetName, showEditChildForm.rowIndex == null ? "insert" : "edit", showEditChildForm.rowIndex, values);
let widgetIndex = determineChildRecordListIndex(showEditChildForm.widgetName);
actionCallback(widgetData[widgetIndex]);
}
/*******************************************************************************
**
*******************************************************************************/
function determineChildRecordListIndex(widgetName: string): number
{
let widgetIndex = -1;
for (var i = 0; i < widgetMetaDataList.length; i++)
{
const widgetMetaData = widgetMetaDataList[i];
if (widgetMetaData.name == widgetName)
{
widgetIndex = i;
break;
}
}
return (widgetIndex);
}
/*******************************************************************************
**
*******************************************************************************/
function updateChildRecordList(widgetName: string, action: "insert" | "edit" | "delete", rowIndex?: number, values?: any)
{
////////////////////////////////////////////////
// find the correct child record widget index //
////////////////////////////////////////////////
let widgetIndex = determineChildRecordListIndex(widgetName);
if (!widgetData[widgetIndex].queryOutput.records)
{
widgetData[widgetIndex].queryOutput.records = [];
}
const newChildListWidgetData: ChildRecordListData = widgetData[widgetIndex];
if (!newChildListWidgetData.queryOutput.records)
{
newChildListWidgetData.queryOutput.records = [];
}
switch (action)
{
case "insert":
newChildListWidgetData.queryOutput.records.push({values: values});
break;
case "edit":
newChildListWidgetData.queryOutput.records[rowIndex] = {values: values};
break;
case "delete":
newChildListWidgetData.queryOutput.records.splice(rowIndex, 1);
break;
}
newChildListWidgetData.totalRows = newChildListWidgetData.queryOutput.records.length;
widgetData[widgetIndex] = newChildListWidgetData;
setWidgetData(widgetData);
setShowEditChildForm(null);
}
const renderWidget = (widgetMetaData: QWidgetMetaData, i: number): JSX.Element =>
{
const labelAdditionalComponentsRight: LabelComponent[] = [];
if (widgetMetaData && widgetMetaData.icons)
{
const topRightInsideCardIcon = widgetMetaData.icons.get("topRightInsideCard");
if (topRightInsideCardIcon)
{
labelAdditionalComponentsRight.push(new HeaderIcon(topRightInsideCardIcon.name, topRightInsideCardIcon.path, topRightInsideCardIcon.color, "topRightInsideCard"));
}
}
const labelAdditionalComponentsLeft: LabelComponent[] = [];
if (widgetMetaData && widgetMetaData.icons)
{
const topLeftInsideCardIcon = widgetMetaData.icons.get("topLeftInsideCard");
if (topLeftInsideCardIcon)
{
labelAdditionalComponentsLeft.push(new HeaderIcon(topLeftInsideCardIcon.name, topLeftInsideCardIcon.path, topLeftInsideCardIcon.color, "topLeftInsideCard"));
}
}
return (
<Box key={`${widgetMetaData.name}-${i}`} sx={{alignItems: "stretch", flexGrow: 1, display: "flex", marginTop: "0px", paddingTop: "0px", width: "100%", height: "100%", flexDirection: widgetMetaData.type == "multiTable" ? "column" : "row"}}>
<Box key={`${widgetMetaData.name}-${i}`} sx={{alignItems: "stretch", flexGrow: 1, display: "flex", marginTop: "0px", paddingTop: "0px", width: "100%", height: "100%"}}>
{
haveLoadedParams && widgetMetaData.type === "parentWidget" && (
<ParentWidget
@ -470,35 +238,11 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
widgetIndex={i}
widgetMetaData={widgetMetaData}
data={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
reloadWidgetCallback={reloadWidget}
storeDropdownSelections={widgetMetaData.storeDropdownSelections}
/>
)
}
{
widgetMetaData.type === "alert" && widgetData[i]?.html && !widgetData[i]?.hideWidget && (
<Widget
omitPadding={true}
widgetMetaData={widgetMetaData}
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<Alert severity={widgetData[i]?.alertType?.toLowerCase()}>
{parse(widgetData[i]?.html)}
{widgetData[i]?.bulletList && (
<div style={{fontSize: "14px"}}>
{widgetData[i].bulletList.map((bullet: string, index: number) =>
<li key={`widget-${i}-${index}`}>{parse(bullet)}</li>
)}
</div>
)}
</Alert>
</Widget>
)
}
{
widgetMetaData.type === "usaMap" && (
<USMapWidget
@ -519,20 +263,6 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
/>
)
}
{
widgetMetaData.type === "multiTable" && (
widgetData[i]?.tableDataList?.map((tableData: WidgetData, index: number) =>
<Box pb={3} key={`${widgetMetaData.type}-${index}`}>
<TableWidget
widgetMetaData={widgetMetaData}
widgetData={tableData}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
/>
</Box>
)
)
}
{
widgetMetaData.type === "stackedBarChart" && (
<Widget
@ -540,10 +270,8 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<StackedBarChart data={widgetData[i]?.chartData} chartSubheaderData={widgetData[i]?.chartSubheaderData} />
<StackedBarChart data={widgetData[i]?.chartData}/>
</Widget>
)
}
@ -554,8 +282,6 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
showReloadControl={false}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<div className="widgetProcessMidDiv" style={{height: "100%"}}>
<ProcessRun process={widgetData[i]?.processMetaData} defaultProcessValues={widgetData[i]?.defaultValues} isWidget={true} forceReInit={widgetCounter} />
@ -569,8 +295,6 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
widgetMetaData={widgetMetaData}
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<Box sx={{alignItems: "stretch", flexGrow: 1, display: "flex", marginTop: "0px", paddingTop: "0px"}}>
<Box padding="1rem" sx={{width: "100%"}}>
@ -586,10 +310,8 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
widgetMetaData={widgetMetaData}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
widgetData={widgetData[i]}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<Box>
<Box px={3} pt={0} pb={2}>
<MDTypography component="div" variant="button" color="text" fontWeight="light">
{
widgetData && widgetData[i] && widgetData[i].html ? (
@ -619,11 +341,8 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
widgetData={widgetData[i]}
isChild={areChildren}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<StatisticsCard
widgetMetaData={widgetMetaData}
data={widgetData[i]}
increaseIsGood={true}
/>
@ -662,13 +381,10 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<div>
<PieChart
chartData={widgetData[i]?.chartData}
chartSubheaderData={widgetData[i]?.chartSubheaderData}
description={widgetData[i]?.description}
/>
</div>
@ -677,7 +393,9 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
}
{
widgetMetaData.type === "divider" && (
<DividerWidget />
<Box>
<DividerWidget />
</Box>
)
}
{
@ -697,8 +415,6 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<DefaultLineChart sx={{alignItems: "center"}}
data={widgetData[i]?.chartData}
@ -711,15 +427,8 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
widgetMetaData.type === "childRecordList" && (
widgetData && widgetData[i] &&
<RecordGridWidget
disableRowClick={widgetData[i]?.disableRowClick}
allowRecordEdit={widgetData[i]?.allowRecordEdit}
allowRecordDelete={widgetData[i]?.allowRecordDelete}
deleteRecordCallback={(rowIndex) => deleteChildRecord(widgetMetaData.name, i, rowIndex)}
editRecordCallback={(rowIndex) => openEditChildRecord(widgetMetaData.name, widgetData[i], rowIndex)}
addNewRecordCallback={widgetData[i]?.isInProcess ? () => openAddChildRecord(widgetMetaData.name, widgetData[i]) : null}
widgetMetaData={widgetMetaData}
data={widgetData[i]}
parentRecord={record}
/>
)
@ -727,39 +436,11 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
{
widgetMetaData.type === "fieldValueList" && (
widgetData && widgetData[i] &&
<FieldValueListWidget
widgetMetaData={widgetMetaData}
data={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
/>
)
}
{
widgetMetaData.type === "composite" && (
<Widget
widgetMetaData={widgetMetaData}
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<CompositeWidget widgetMetaData={widgetMetaData} data={widgetData[i]} actionCallback={actionCallback} values={values} />
</Widget>
)
}
{
widgetMetaData.type === "block" && (
<Widget
widgetMetaData={widgetMetaData}
widgetData={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
isChild={areChildren}
labelAdditionalComponentsRight={labelAdditionalComponentsRight}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft}
>
<WidgetBlock widgetMetaData={widgetMetaData} block={widgetData[i]} />
</Widget>
<FieldValueListWidget
widgetMetaData={widgetMetaData}
data={widgetData[i]}
reloadWidgetCallback={(data) => reloadWidget(i, data)}
/>
)
}
{
@ -778,136 +459,34 @@ function DashboardWidgets({widgetMetaDataList, tableName, entityPrimaryKey, reco
</Widget>
)
}
{
widgetMetaData.type === "filterAndColumnsSetup" && (
widgetData && widgetData[i] &&
<FilterAndColumnsSetupWidget isEditable={false} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} recordValues={convertQRecordValuesFromMapToObject(record)} onSaveCallback={() =>
{
}} />
)
}
{
widgetMetaData.type === "pivotTableSetup" && (
widgetData && widgetData[i] && widgetData[i].queryParams &&
<PivotTableSetupWidget isEditable={false} widgetMetaData={widgetMetaData} recordValues={convertQRecordValuesFromMapToObject(record)} onSaveCallback={() =>
{
}} />
)
}
{
widgetMetaData.type === "dynamicForm" && (
widgetData && widgetData[i] &&
<DynamicFormWidget isEditable={false} widgetMetaData={widgetMetaData} widgetData={widgetData[i]} record={record} recordValues={convertQRecordValuesFromMapToObject(record)} />
)
}
</Box>
);
};
if (wrapWidgetsInTabPanels)
{
omitWrappingGridContainer = true;
}
const body: JSX.Element =
(
<>
{
widgetMetaDataList.map((widgetMetaData, i) =>
{
let renderedWidget = widgetMetaData ? renderWidget(widgetMetaData, i) : (<></>);
if (!omitWrappingGridContainer)
{
const gridProps: { [key: string]: any } = {};
for (let size of ["xs", "sm", "md", "lg", "xl", "xxl"])
{
const key = `gridCols:sizeClass:${size}`;
if (widgetMetaData?.defaultValues?.has(key))
{
gridProps[size] = widgetMetaData?.defaultValues.get(key);
}
}
if (!gridProps["xxl"])
{
gridProps["xxl"] = widgetMetaData.gridColumns ? widgetMetaData.gridColumns : 12;
}
if (!gridProps["xs"])
{
gridProps["xs"] = 12;
}
renderedWidget = (<Grid id={widgetMetaData.name} item {...gridProps} sx={{display: "flex", alignItems: "stretch", scrollMarginTop: "100px"}}>
{renderedWidget}
</Grid>);
}
if (wrapWidgetsInTabPanels)
{
renderedWidget = (<TabPanel index={i} value={selectedTab} style={{
padding: 0,
margin: "-1rem",
width: "calc(100% + 2rem)"
}}>
{renderedWidget}
</TabPanel>);
}
return (<React.Fragment key={`${widgetMetaData.name}-${i}`}>{renderedWidget}</React.Fragment>);
})
widgetMetaDataList.map((widgetMetaData, i) => (
omitWrappingGridContainer
? widgetMetaData && renderWidget(widgetMetaData, i)
:
widgetMetaData && <Grid id={widgetMetaData.name} key={`${widgetMetaData.name}-${i}`} item lg={widgetMetaData.gridColumns ? widgetMetaData.gridColumns : 12} xs={12} sx={{display: "flex", alignItems: "stretch", scrollMarginTop: "100px"}}>
{renderWidget(widgetMetaData, i)}
</Grid>
))
}
</>
);
const tabs = widgetMetaDataList && wrapWidgetsInTabPanels ?
<Tabs
sx={{
m: 0, mb: 1.5, ml: -2, mr: -2, mt: -3,
"& .MuiTabs-scroller": {
ml: 0
}
}}
value={selectedTab}
onChange={(event, newValue) => changeTab(newValue)}
variant="standard"
>
{widgetMetaDataList.map((widgetMetaData, i) => (
<Tab key={widgetMetaData.name} label={widgetMetaData.label} />
))}
</Tabs>
: <></>;
return (
widgetCount > 0 ? (
<>
{tabs}
{
omitWrappingGridContainer ? body : (
<Grid container spacing={2.5}>
{body}
</Grid>
)
}
{
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>
}
</>
omitWrappingGridContainer ? body :
(
<Grid container spacing={3} pb={4}>
{body}
</Grid>
)
) : null
);
}

View File

@ -33,7 +33,6 @@ import Client from "qqq/utils/qqq/Client";
//////////////////////////////////////////////
export interface ParentWidgetData
{
label?: string;
dropdownLabelList: string[];
dropdownNameList: string[];
dropdownDataList: {
@ -43,9 +42,7 @@ export interface ParentWidgetData
childWidgetNameList: string[];
dropdownNeedsSelectedText?: string;
storeDropdownSelections?: boolean;
csvData?: any[][];
icon?: string;
layoutType: string;
}
@ -58,7 +55,7 @@ interface Props
widgetMetaData?: QWidgetMetaData;
widgetIndex: number;
data: ParentWidgetData;
reloadWidgetCallback?: (params: string) => void;
reloadWidgetCallback?: (widgetIndex: number, params: string) => void;
entityPrimaryKey?: string;
tableName?: string;
storeDropdownSelections?: boolean;
@ -66,8 +63,7 @@ interface Props
const qController = Client.getInstance();
function ParentWidget({urlParams, widgetMetaData, widgetIndex, data, reloadWidgetCallback, entityPrimaryKey, tableName, storeDropdownSelections}: Props,): JSX.Element
function ParentWidget({urlParams, widgetMetaData, widgetIndex, data, reloadWidgetCallback, entityPrimaryKey, tableName, storeDropdownSelections}: Props, ): JSX.Element
{
const [childUrlParams, setChildUrlParams] = useState((urlParams) ? urlParams : "");
const [qInstance, setQInstance] = useState(null as QInstance);
@ -84,40 +80,29 @@ function ParentWidget({urlParams, widgetMetaData, widgetIndex, data, reloadWidge
useEffect(() =>
{
if (qInstance && data && data.childWidgetNameList)
if(qInstance && data && data.childWidgetNameList)
{
let widgetMetaDataList = [] as QWidgetMetaData[];
data?.childWidgetNameList.forEach((widgetName: string) =>
{
widgetMetaDataList.push(qInstance.widgets.get(widgetName));
});
})
setWidgets(widgetMetaDataList);
}
}, [qInstance, data, childUrlParams]);
useEffect(() =>
{
setChildUrlParams(urlParams);
}, [urlParams]);
const parentReloadWidgetCallback = (data: string) =>
{
setChildUrlParams(data);
reloadWidgetCallback(data);
};
reloadWidgetCallback(widgetIndex, data);
}
///////////////////////////////////////////////////////////////////////////////////////////
// if this parent widget is in card form, and its children are too, then we need some px //
///////////////////////////////////////////////////////////////////////////////////////////
const parentIsCard = widgetMetaData && widgetMetaData.isCard;
const childrenAreCards = widgetMetaData && widgets && widgets[0] && widgets[0].isCard;
const px = (parentIsCard && childrenAreCards) ? 3 : 0;
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// if this is a parent, which is not a card, then we need to omit the padding, i think, on the Widget that ultimately gets rendered //
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
const omitPadding = !parentIsCard;
const px = (widgetMetaData && widgetMetaData.isCard && widgets && widgets[0] && widgets[0].isCard) ? 3 : 0;
// @ts-ignore
return (
qInstance && data ? (
<Widget
@ -125,10 +110,9 @@ function ParentWidget({urlParams, widgetMetaData, widgetIndex, data, reloadWidge
widgetData={data}
storeDropdownSelections={storeDropdownSelections}
reloadWidgetCallback={parentReloadWidgetCallback}
omitPadding={omitPadding}
>
<Box sx={{height: "100%", width: "100%"}} px={px}>
<DashboardWidgets widgetMetaDataList={widgets} entityPrimaryKey={entityPrimaryKey} tableName={tableName} childUrlParams={childUrlParams} areChildren={true} parentWidgetMetaData={widgetMetaData} wrapWidgetsInTabPanels={data.layoutType?.toLowerCase() == "tabs"} />
<DashboardWidgets widgetMetaDataList={widgets} entityPrimaryKey={entityPrimaryKey} tableName={tableName} childUrlParams={childUrlParams} areChildren={true} parentWidgetMetaData={widgetMetaData}/>
</Box>
</Widget>
) : null

View File

@ -21,22 +21,18 @@
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Box, InputLabel} from "@mui/material";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Card from "@mui/material/Card";
import Icon from "@mui/material/Icon";
import Switch from "@mui/material/Switch";
import LinearProgress from "@mui/material/LinearProgress";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography";
import parse from "html-react-parser";
import QContext from "QContext";
import colors from "qqq/assets/theme/base/colors";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import WidgetDropdownMenu, {DropdownOption} from "qqq/components/widgets/components/WidgetDropdownMenu";
import {WidgetUtils} from "qqq/components/widgets/WidgetUtils";
import HtmlUtils from "qqq/utils/HtmlUtils";
import React, {useContext, useEffect, useState} from "react";
import {NavigateFunction, useNavigate} from "react-router-dom";
import React, {useEffect, useState} from "react";
import {Link, NavigateFunction, useNavigate} from "react-router-dom";
import colors from "qqq/components/legacy/colors";
import DropdownMenu, {DropdownOption} from "qqq/components/widgets/components/DropdownMenu";
export interface WidgetData
{
@ -47,11 +43,9 @@ export interface WidgetData
id: string,
label: string
}[][];
dropdownDefaultValueList?: string[];
dropdownNeedsSelectedText?: string;
hasPermission?: boolean;
errorLoading?: boolean;
[other: string]: any;
}
@ -59,10 +53,7 @@ export interface WidgetData
interface Props
{
labelAdditionalComponentsLeft: LabelComponent[];
labelAdditionalElementsLeft: JSX.Element[];
labelAdditionalComponentsRight: LabelComponent[];
labelAdditionalElementsRight: JSX.Element[];
labelBoxAdditionalSx?: any;
widgetMetaData?: QWidgetMetaData;
widgetData?: WidgetData;
children: JSX.Element;
@ -71,7 +62,6 @@ interface Props
isChild?: boolean;
footerHTML?: string;
storeDropdownSelections?: boolean;
omitPadding: boolean;
}
Widget.defaultProps = {
@ -80,11 +70,7 @@ Widget.defaultProps = {
widgetMetaData: {},
widgetData: {},
labelAdditionalComponentsLeft: [],
labelAdditionalElementsLeft: [],
labelAdditionalComponentsRight: [],
labelAdditionalElementsRight: [],
labelBoxAdditionalSx: {},
omitPadding: false,
};
@ -102,134 +88,34 @@ export class LabelComponent
{
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
return (<div>Unsupported component type</div>);
};
return (<div>Unsupported component type</div>)
}
}
/*******************************************************************************
**
*******************************************************************************/
export class HeaderIcon extends LabelComponent
export class HeaderLink extends LabelComponent
{
iconName: string;
iconPath: string;
color: string;
coloredBG: boolean;
role: string;
label: string;
to: string
iconColor: string;
bgColor: string;
constructor(iconName: string, iconPath: string, color: string, role?: string, coloredBG: boolean = true)
constructor(label: string, to: string)
{
super();
this.iconName = iconName;
this.iconPath = iconPath;
this.color = color;
this.role = role;
this.coloredBG = coloredBG;
this.iconColor = this.coloredBG ? "#FFFFFF" : this.color;
this.bgColor = this.coloredBG ? this.color : "none";
this.label = label;
this.to = to;
}
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
const styles: any = {
width: "1.75rem",
height: "1.75rem",
color: this.iconColor,
backgroundColor: this.bgColor,
borderRadius: "0.25rem"
};
if (this.role == "topLeftInsideCard")
{
styles["order"] = -1;
styles["marginRight"] = "0.5rem";
}
if (this.iconPath)
{
return (<Box sx={{textAlign: "center", ...styles}}><img src={this.iconPath} width="16" height="16" /></Box>);
}
else
{
return (<Icon sx={{padding: "0.25rem", ...styles}} fontSize="small">{this.iconName}</Icon>);
}
};
}
/*******************************************************************************
** a link (actually a button) for in a widget's header
*******************************************************************************/
interface HeaderLinkButtonComponentProps
{
label: string;
onClickCallback: () => void;
disabled?: boolean;
disabledTooltip?: string;
}
HeaderLinkButtonComponent.defaultProps = {
disabled: false,
disabledTooltip: null
};
export function HeaderLinkButtonComponent({label, onClickCallback, disabled, disabledTooltip}: HeaderLinkButtonComponentProps): JSX.Element
{
return (
<Tooltip title={disabledTooltip}>
<span>
<Button disabled={disabled} onClick={() => onClickCallback()} sx={{p: 0}} disableRipple>
<Typography display="inline" textTransform="none" fontSize={"1.125rem"}>
{label}
</Typography>
</Button>
</span>
</Tooltip>
);
}
/*******************************************************************************
**
*******************************************************************************/
interface HeaderToggleComponentProps
{
label: string;
getValue: () => boolean;
onClickCallback: () => void;
disabled?: boolean;
disabledTooltip?: string;
}
HeaderToggleComponent.defaultProps = {
disabled: false,
disabledTooltip: null
};
export function HeaderToggleComponent({label, getValue, onClickCallback, disabled, disabledTooltip}: HeaderToggleComponentProps): JSX.Element
{
const onClick = () =>
{
onClickCallback();
};
return (
<Box alignItems="baseline" mr="-0.75rem">
<Tooltip title={disabledTooltip}>
<span>
<InputLabel sx={{fontSize: "1.125rem", px: "0 !important", cursor: disabled ? "default" : "pointer", opacity: disabled ? 0.65 : 1}} unselectable="on">
{label} <Switch disabled={disabled} checked={getValue()} onClick={onClick} />
</InputLabel>
</span>
</Tooltip>
</Box>
);
return (
<Typography variant="body2" p={2} display="inline" fontSize=".875rem" pt="0" position="relative" top="-0.25rem">
{this.to ? <Link to={this.to}>{this.label}</Link> : null}
</Typography>
);
}
}
@ -242,32 +128,58 @@ export class AddNewRecordButton extends LabelComponent
label: string;
defaultValues: any;
disabledFields: any;
addNewRecordCallback?: () => void;
constructor(table: QTableMetaData, defaultValues: any, label: string = "Add new", disabledFields: any = defaultValues, addNewRecordCallback?: () => void)
constructor(table: QTableMetaData, defaultValues: any, label: string = "Add new", disabledFields: any = defaultValues)
{
super();
this.table = table;
this.label = label;
this.defaultValues = defaultValues;
this.disabledFields = disabledFields;
this.addNewRecordCallback = addNewRecordCallback;
}
openEditForm = (navigate: any, table: QTableMetaData, id: any = null, defaultValues: any, disabledFields: any) =>
{
navigate(`#/createChild=${table.name}/defaultValues=${JSON.stringify(defaultValues)}/disabledFields=${JSON.stringify(disabledFields)}`);
};
navigate(`#/createChild=${table.name}/defaultValues=${JSON.stringify(defaultValues)}/disabledFields=${JSON.stringify(disabledFields)}`)
}
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
return (
<Typography variant="body2" p={2} pr={0} display="inline" position="relative" top="-0.5rem">
<Button sx={{mt: 0.75}} onClick={() => this.addNewRecordCallback ? this.addNewRecordCallback() : this.openEditForm(args.navigate, this.table, null, this.defaultValues, this.disabledFields)}>{this.label}</Button>
<Typography variant="body2" p={2} pr={0} display="inline" position="relative" top="0.25rem">
<Button sx={{mt: 0.75}} onClick={() => this.openEditForm(args.navigate, this.table, null, this.defaultValues, this.disabledFields)}>{this.label}</Button>
</Typography>
);
};
}
}
/*******************************************************************************
**
*******************************************************************************/
export class ExportDataButton extends LabelComponent
{
callbackToExport: any;
tooltipTitle: string;
isDisabled: boolean;
constructor(callbackToExport: any, isDisabled = false, tooltipTitle: string = "Export")
{
super();
this.callbackToExport = callbackToExport;
this.isDisabled = isDisabled;
this.tooltipTitle = tooltipTitle;
}
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
return (
<Typography variant="body2" py={2} px={0} display="inline" position="relative" top="-0.375rem">
<Tooltip title={this.tooltipTitle}><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={() => this.callbackToExport()} disabled={this.isDisabled}><Icon>save_alt</Icon></Button></Tooltip>
</Typography>
);
}
}
@ -277,124 +189,45 @@ export class AddNewRecordButton extends LabelComponent
export class Dropdown extends LabelComponent
{
label: string;
dropdownMetaData: any;
options: DropdownOption[];
dropdownDefaultValue?: string;
dropdownName: string;
onChangeCallback: any;
constructor(label: string, dropdownMetaData: any, options: DropdownOption[], dropdownDefaultValue: string, dropdownName: string, onChangeCallback: any)
constructor(label: string, options: DropdownOption[], dropdownName: string, onChangeCallback: any)
{
super();
this.label = label;
this.dropdownMetaData = dropdownMetaData;
this.options = options;
this.dropdownDefaultValue = dropdownDefaultValue;
this.dropdownName = dropdownName;
this.onChangeCallback = onChangeCallback;
}
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
const label = `Select ${this.label}`;
let defaultValue = null;
const localStorageKey = `${WIDGET_DROPDOWN_SELECTION_LOCAL_STORAGE_KEY_ROOT}.${args.widgetProps.widgetMetaData.name}.${this.dropdownName}`;
if (args.widgetProps.storeDropdownSelections)
{
////////////////////////////////////////////////////////////////////////////////////////////
// see if an existing value is stored in local storage, and if so set it in dropdown //
// originally we used the full object from localStorage - but - in case the label //
// changed since it was stored, we'll instead just find the option by id (or in case that //
// option isn't available anymore, then we'll select nothing instead of a missing value //
////////////////////////////////////////////////////////////////////////////////////////////
try
{
const localStorageOption = JSON.parse(localStorage.getItem(localStorageKey));
if (localStorageOption)
{
const id = localStorageOption.id;
if (this.dropdownMetaData.type == "DATE_PICKER")
{
defaultValue = id;
}
else
{
for (let i = 0; i < this.options.length; i++)
{
if (this.options[i].id == id)
{
defaultValue = this.options[i];
args.dropdownData[args.componentIndex] = defaultValue?.id;
}
}
}
}
}
catch (e)
{
console.log(`Error getting default value for dropdown [${this.dropdownName}] from local storage`, e);
}
}
/////////////////////////////////////////////////////////////////////////////////////////////
// if there wasn't a value selected, but there is a default from the backend, then use it. //
/////////////////////////////////////////////////////////////////////////////////////////////
if (defaultValue == null && this.dropdownDefaultValue != null)
{
for (let i = 0; i < this.options.length; i++)
{
if (this.options[i].id == this.dropdownDefaultValue)
{
defaultValue = this.options[i];
args.dropdownData[args.componentIndex] = defaultValue?.id;
if (args.widgetProps.storeDropdownSelections)
{
localStorage.setItem(localStorageKey, JSON.stringify(defaultValue));
}
this.onChangeCallback(label, defaultValue);
break;
}
}
}
/////////////////////////////////////////////////////////////////////////////
// if there's a 'label for null value' (and no default from the backend), //
// then add that as an option (and select it if nothing else was selected) //
/////////////////////////////////////////////////////////////////////////////
let options = this.options;
if (this.dropdownMetaData.labelForNullValue && !this.dropdownDefaultValue)
{
const nullOption = {id: null as string, label: this.dropdownMetaData.labelForNullValue};
options = [nullOption, ...this.options];
if (!defaultValue)
{
defaultValue = nullOption;
}
///////////////////////////////////////////////////////////////////////////////////////
// see if an existing value is stored in local storage, and if so set it in dropdown //
///////////////////////////////////////////////////////////////////////////////////////
defaultValue = JSON.parse(localStorage.getItem(localStorageKey));
args.dropdownData[args.componentIndex] = defaultValue?.id;
}
return (
<Box mb={2} sx={{float: "right"}}>
<WidgetDropdownMenu
<Box my={2} sx={{float: "right"}}>
<DropdownMenu
name={this.dropdownName}
type={this.dropdownMetaData.type}
defaultValue={defaultValue}
sx={{marginLeft: "1rem"}}
label={label}
startIcon={this.dropdownMetaData.startIconName}
allowBackAndForth={this.dropdownMetaData.allowBackAndForth}
backAndForthInverted={this.dropdownMetaData.backAndForthInverted}
disableClearable={this.dropdownMetaData.disableClearable}
dropdownOptions={options}
sx={{width: 200, marginLeft: "15px"}}
label={`Select ${this.label}`}
dropdownOptions={this.options}
onChangeCallback={this.onChangeCallback}
width={this.dropdownMetaData.width ?? 225}
/>
</Box>
);
};
}
}
@ -413,14 +246,12 @@ export class ReloadControl extends LabelComponent
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
return (<Typography key={1} variant="body2" py={0} px={0} display="inline" position="relative" top="-0.25rem">
<Tooltip title="Refresh">
<Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={() => this.callback()}>
<Icon sx={{color: colors.gray.main, fontSize: 1.125}}>refresh</Icon>
</Button>
</Tooltip>
</Typography>);
};
return (
<Typography variant="body2" py={2} px={0} display="inline" position="relative" top="-0.375rem">
<Tooltip title="Refresh"><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={() => this.callback()}><Icon>refresh</Icon></Button></Tooltip>
</Typography>
);
}
}
@ -434,31 +265,15 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
{
const navigate = useNavigate();
const [dropdownData, setDropdownData] = useState([]);
const [fullScreenWidgetClassName, setFullScreenWidgetClassName] = useState("");
const [reloading, setReloading] = useState(false);
const [dropdownDataJSON, setDropdownDataJSON] = useState("");
const [labelComponentsLeft, setLabelComponentsLeft] = useState([] as LabelComponent[]);
const [labelComponentsRight, setLabelComponentsRight] = useState([] as LabelComponent[]);
////////////////////////////////////////////////////////////////////////////////////////////////////////
// support for using widget (data) label as page header, w/o it disappearing if dropdowns are changed //
////////////////////////////////////////////////////////////////////////////////////////////////////////
const [lastSeenLabel, setLastSeenLabel] = useState("");
const [usingLabelAsTitle, setUsingLabelAsTitle] = useState(false);
const {helpHelpActive} = useContext(QContext);
function renderComponent(component: LabelComponent, componentIndex: number)
{
if (component && component.render)
{
return component.render({navigate: navigate, widgetProps: props, dropdownData: dropdownData, componentIndex: componentIndex, reloadFunction: doReload});
}
else
{
console.log("Request to render a null component or component without a render function...");
console.log(JSON.stringify(component));
return (<></>);
}
return component.render({navigate: navigate, widgetProps: props, dropdownData: dropdownData, componentIndex: componentIndex, reloadFunction: doReload});
}
useEffect(() =>
@ -485,7 +300,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
// for initial render, put right-components from props into the state variable //
/////////////////////////////////////////////////////////////////////////////////
const stateLabelComponentsRight = [] as LabelComponent[];
// console.log(`${props.widgetMetaData.name} initiating right-components`);
// console.log(`${props.widgetMetaData.name} init'ing right-components`);
if (props.labelAdditionalComponentsRight)
{
props.labelAdditionalComponentsRight.map((component) => stateLabelComponentsRight.push(component));
@ -518,15 +333,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
props.widgetData.dropdownDataList?.map((dropdownData: any, index: number) =>
{
// console.log(`${props.widgetMetaData.name} building a Dropdown, data is: ${dropdownData}`);
let defaultValue = null;
if (props.widgetData.dropdownDefaultValueList && props.widgetData.dropdownDefaultValueList.length >= index)
{
defaultValue = props.widgetData.dropdownDefaultValueList[index];
}
if (props.widgetData?.dropdownLabelList && props.widgetData?.dropdownLabelList[index] && props.widgetMetaData?.dropdowns && props.widgetMetaData?.dropdowns[index] && props.widgetData?.dropdownNameList && props.widgetData?.dropdownNameList[index])
{
updatedStateLabelComponentsRight.push(new Dropdown(props.widgetData.dropdownLabelList[index], props.widgetMetaData.dropdowns[index], dropdownData, defaultValue, props.widgetData.dropdownNameList[index], handleDataChange));
}
updatedStateLabelComponentsRight.push(new Dropdown(props.widgetData.dropdownLabelList[index], dropdownData, props.widgetData.dropdownNameList[index], handleDataChange));
});
setLabelComponentsRight(updatedStateLabelComponentsRight);
}
@ -565,7 +372,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
if (index < 0)
{
throw (`Could not find table name for label ${tableName}`);
throw(`Could not find table name for label ${tableName}`);
}
dropdownData[index] = (changedData) ? changedData.id : null;
@ -587,7 +394,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
}
}
reloadWidget(dropdownData);
reloadWidget(dropdownData)
}
}
@ -615,45 +422,26 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
{
console.log(`No reload widget callback in ${props.widgetMetaData.label}`);
}
};
}
const onExportClick = () =>
const toggleFullScreenWidget = () =>
{
if (props.widgetData?.csvData)
if (fullScreenWidgetClassName)
{
const csv = WidgetUtils.widgetCsvDataToString(props.widgetData);
const fileName = WidgetUtils.makeExportFileName(props.widgetData, props.widgetMetaData);
HtmlUtils.download(fileName, csv);
setFullScreenWidgetClassName("");
}
else
{
alert("There is no data available to export.");
setFullScreenWidgetClassName("fullScreenWidget");
}
};
///////////////////////////////////////////////////////////////////////////////////////////////////////
// add the export button to the label's left elements, if the meta-data says to show it //
// don't do this for 2 types which themselves add the button (and have custom code to do the export) //
///////////////////////////////////////////////////////////////////////////////////////////////////////
let localLabelAdditionalElementsLeft = [...props.labelAdditionalElementsLeft];
if (props.widgetMetaData?.showExportButton && props.widgetMetaData?.type !== "table" && props.widgetMetaData?.type !== "childRecordList")
{
if (!localLabelAdditionalElementsLeft)
{
localLabelAdditionalElementsLeft = [];
}
localLabelAdditionalElementsLeft.push(WidgetUtils.generateExportButton(onExportClick));
}
let localLabelAdditionalElementsRight = [...props.labelAdditionalElementsRight];
const hasPermission = props.widgetData?.hasPermission === undefined || props.widgetData?.hasPermission === true;
const isSet = (v: any): boolean =>
{
return (v !== null && v !== undefined);
};
}
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
// to avoid taking up the space of the Box with the label and icon and label-components (since it has a height), only output that box if we need any of the components //
@ -662,79 +450,26 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
if (hasPermission)
{
needLabelBox ||= (labelComponentsLeft && labelComponentsLeft.length > 0);
needLabelBox ||= (localLabelAdditionalElementsLeft && localLabelAdditionalElementsLeft.length > 0);
needLabelBox ||= (labelComponentsRight && labelComponentsRight.length > 0);
needLabelBox ||= (localLabelAdditionalElementsRight && localLabelAdditionalElementsRight.length > 0);
needLabelBox ||= isSet(props.widgetData?.icon);
needLabelBox ||= isSet(props.widgetMetaData?.icon);
needLabelBox ||= isSet(props.widgetData?.label);
needLabelBox ||= isSet(props.widgetMetaData?.label);
}
//////////////////////////////////////////////////////////////////////////////////////////
// first look for a label in the widget data, which would override that in the metadata //
//////////////////////////////////////////////////////////////////////////////////////////
const isParentWidget = props.widgetMetaData.type == "parentWidget"; // todo - do we need to know top-level parent, vs. a nested parent?
let labelToUse = props.widgetData?.label ?? props.widgetMetaData?.label;
if (!labelToUse)
{
/////////////////////////////////////////////////////////////////////////////////////////////
// prevent the label from disappearing, especially when it's being used as the page header //
/////////////////////////////////////////////////////////////////////////////////////////////
if (lastSeenLabel && isParentWidget && usingLabelAsTitle)
{
labelToUse = lastSeenLabel;
}
}
let labelElement = (
<Typography sx={{cursor: "default", pl: "auto", fontWeight: 600}} variant={isParentWidget && (props.widgetData.isLabelPageTitle || usingLabelAsTitle) ? "h3" : "h6"} display="inline">
{labelToUse}
</Typography>
);
let sublabelElement = (
<Box key="sublabel" height="20px">
<Typography sx={{position: "relative", top: "-18px"}} variant="caption">
{props.widgetData?.sublabel}
</Typography>
</Box>
);
if (labelToUse && labelToUse != lastSeenLabel)
{
setLastSeenLabel(labelToUse);
setUsingLabelAsTitle(props.widgetData.isLabelPageTitle);
}
const helpRoles = ["ALL_SCREENS"];
const slotName = "label";
const showHelp = helpHelpActive || hasHelpContent(props.widgetMetaData?.helpContent?.get(slotName), helpRoles);
if (showHelp)
{
const formattedHelpContent = <HelpContent helpContents={props.widgetMetaData?.helpContent?.get(slotName)} roles={helpRoles} helpContentKey={`widget:${props.widgetMetaData?.name};slot:${slotName}`} />;
labelElement = <Tooltip title={formattedHelpContent} arrow={true} placement="bottom-start">{labelElement}</Tooltip>;
}
else if (props.widgetMetaData?.tooltip)
{
labelElement = <Tooltip title={props.widgetMetaData.tooltip} arrow={true} placement="bottom-start">{labelElement}</Tooltip>;
}
const isTable = props.widgetMetaData.type == "table";
const errorLoading = props.widgetData?.errorLoading !== undefined && props.widgetData?.errorLoading === true;
const widgetContent =
<Box sx={{width: "100%", height: "100%", minHeight: props.widgetMetaData?.minHeight ? props.widgetMetaData?.minHeight : "initial"}}>
{
needLabelBox &&
<Box display="flex" justifyContent="space-between" alignItems="flex-start" sx={{width: "100%", ...props.labelBoxAdditionalSx}} minHeight={"2.5rem"}>
<Box display="flex" flexDirection="column">
<Box display="flex" alignItems="baseline">
{
hasPermission ?
props.widgetMetaData?.icon && (
<Box ml={1} mr={2} mt={-4} sx={{
<Box pr={2} display="flex" justifyContent="space-between" alignItems="flex-start" sx={{width: "100%"}} height={"3.5rem"}>
<Box pt={2} pb={1}>
{
hasPermission ?
props.widgetMetaData?.icon && (
<Box
ml={3}
mt={-4}
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
@ -745,14 +480,17 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
color: "#ffffff",
float: "left"
}}
>
<Icon fontSize="medium" color="inherit">
{props.widgetMetaData.icon}
</Icon>
</Box>
) :
(
<Box ml={3} mt={-4} sx={{
>
<Icon fontSize="medium" color="inherit">
{props.widgetMetaData.icon}
</Icon>
</Box>
) :
(
<Box
ml={3}
mt={-4}
sx={{
display: "flex",
justifyContent: "center",
alignItems: "center",
@ -763,29 +501,35 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
color: "#ffffff",
float: "left"
}}
>
<Icon fontSize="medium" color="inherit">lock</Icon>
</Box>
)
}
{
hasPermission && labelToUse && (labelElement)
}
{
hasPermission && (
labelComponentsLeft.map((component, i) =>
{
return (<React.Fragment key={i}>{renderComponent(component, i)}</React.Fragment>);
})
>
<Icon fontSize="medium" color="inherit">lock</Icon>
</Box>
)
}
{localLabelAdditionalElementsLeft}
</Box>
<Box key="sublabelContainer" display="flex">
{
hasPermission && props.widgetData?.sublabel && (sublabelElement)
}
</Box>
}
{
//////////////////////////////////////////////////////////////////////////////////////////
// first look for a label in the widget data, which would override that in the metadata //
//////////////////////////////////////////////////////////////////////////////////////////
hasPermission && props.widgetData?.label ? (
<Typography sx={{position: "relative", top: -4}} variant="h6" fontWeight="medium" pl={2} display="inline-block">
{props.widgetData.label}
</Typography>
) : (
hasPermission && props.widgetMetaData?.label && (
<Typography sx={{position: "relative", top: -4}} variant="h6" fontWeight="medium" pl={3} display="inline-block">
{props.widgetMetaData.label}
</Typography>
)
)
}
{
hasPermission && (
labelComponentsLeft.map((component, i) =>
{
return (<span key={i}>{renderComponent(component, i)}</span>);
})
)
}
</Box>
<Box>
{
@ -796,17 +540,11 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
})
)
}
{localLabelAdditionalElementsRight}
</Box>
</Box>
}
{
///////////////////////////////////////////////////////////////////
// turning this off... for now. maybe make a property in future //
///////////////////////////////////////////////////////////////////
/*
props.widgetMetaData?.isCard && (reloading ? <LinearProgress color="info" sx={{overflow: "hidden", borderRadius: "0", mx:-2}} /> : <Box height="0.375rem" />)
*/
props.widgetMetaData?.isCard && (reloading ? <LinearProgress color="info" sx={{overflow: "hidden", borderRadius: "0"}} /> : <Box height="0.375rem" />)
}
{
errorLoading ? (
@ -816,7 +554,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
</Box>
) : (
hasPermission && props.widgetData?.dropdownNeedsSelectedText ? (
<Box pb={3} sx={{width: "100%", textAlign: "right"}}>
<Box pb={3} pr={3} sx={{width: "100%", textAlign: "right"}}>
<Typography variant="body2">
{props.widgetData?.dropdownNeedsSelectedText}
</Typography>
@ -832,27 +570,16 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
}
{
!errorLoading && props?.footerHTML && (
<Box mt={isTable ? "36px" : 1} ml={isTable ? 0 : 3} mr={isTable ? 0 : 3} mb={isTable ? "-12px" : 2} sx={{fontWeight: 300, color: "#7b809a", display: "flex", alignContent: "flex-end", fontSize: "14px"}}>{parse(props.footerHTML)}</Box>
<Box mt={1} ml={3} mr={3} mb={2} sx={{fontWeight: 300, color: "#7b809a", display: "flex", alignContent: "flex-end", fontSize: "14px"}}>{parse(props.footerHTML)}</Box>
)
}
</Box>;
const padding = props.omitPadding ? "auto" : "24px 16px";
///////////////////////////////////////////////////
// try to make tables fill their entire "parent" //
///////////////////////////////////////////////////
let noCardMarginBottom = "unset";
if (isTable)
{
noCardMarginBottom = "-8px";
}
return props.widgetMetaData?.isCard
? <Card sx={{marginTop: props.widgetMetaData?.icon ? 2 : 0, width: "100%", p: padding}} className="widget inCard">
? <Card sx={{marginTop: props.widgetMetaData?.icon ? 2 : 0, width: "100%"}} className={fullScreenWidgetClassName}>
{widgetContent}
</Card>
: <span style={{width: "100%", padding: padding, marginBottom: noCardMarginBottom}} className="widget noCard">{widgetContent}</span>;
: widgetContent;
}
export default Widget;

View File

@ -1,104 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Alert, Skeleton} from "@mui/material";
import ButtonBlock from "qqq/components/widgets/blocks/ButtonBlock";
import AudioBlock from "qqq/components/widgets/blocks/AudioBlock";
import InputFieldBlock from "qqq/components/widgets/blocks/InputFieldBlock";
import React from "react";
import BigNumberBlock from "qqq/components/widgets/blocks/BigNumberBlock";
import {BlockData} from "qqq/components/widgets/blocks/BlockModels";
import DividerBlock from "qqq/components/widgets/blocks/DividerBlock";
import NumberIconBadgeBlock from "qqq/components/widgets/blocks/NumberIconBadgeBlock";
import ProgressBarBlock from "qqq/components/widgets/blocks/ProgressBarBlock";
import TableSubRowDetailRowBlock from "qqq/components/widgets/blocks/TableSubRowDetailRowBlock";
import TextBlock from "qqq/components/widgets/blocks/TextBlock";
import UpOrDownNumberBlock from "qqq/components/widgets/blocks/UpOrDownNumberBlock";
import CompositeWidget from "qqq/components/widgets/CompositeWidget";
import ImageBlock from "./blocks/ImageBlock";
interface WidgetBlockProps
{
widgetMetaData: QWidgetMetaData;
block: BlockData;
actionCallback?: (blockData: BlockData, eventValues?: {[name: string]: any}) => boolean;
values?: { [key: string]: any };
}
/*******************************************************************************
** Component to render a single Block in the widget framework!
*******************************************************************************/
export default function WidgetBlock({widgetMetaData, block, actionCallback, values}: WidgetBlockProps): JSX.Element
{
if(!block)
{
return (<Skeleton />);
}
if(!block.values)
{
block.values = {};
}
if(!block.styles)
{
block.styles = {};
}
if(block.blockTypeName == "COMPOSITE")
{
// @ts-ignore - special case for composite type block...
return (<CompositeWidget widgetMetaData={widgetMetaData} data={block} actionCallback={actionCallback} values={values} />);
}
switch(block.blockTypeName)
{
case "TEXT":
return (<TextBlock widgetMetaData={widgetMetaData} data={block} />);
case "NUMBER_ICON_BADGE":
return (<NumberIconBadgeBlock widgetMetaData={widgetMetaData} data={block} />);
case "UP_OR_DOWN_NUMBER":
return (<UpOrDownNumberBlock widgetMetaData={widgetMetaData} data={block} />);
case "TABLE_SUB_ROW_DETAIL_ROW":
return (<TableSubRowDetailRowBlock widgetMetaData={widgetMetaData} data={block} />);
case "PROGRESS_BAR":
return (<ProgressBarBlock widgetMetaData={widgetMetaData} data={block} />);
case "DIVIDER":
return (<DividerBlock widgetMetaData={widgetMetaData} data={block} />);
case "BIG_NUMBER":
return (<BigNumberBlock widgetMetaData={widgetMetaData} data={block} />);
case "INPUT_FIELD":
return (<InputFieldBlock widgetMetaData={widgetMetaData} data={block} actionCallback={actionCallback} />);
case "BUTTON":
return (<ButtonBlock widgetMetaData={widgetMetaData} data={block} actionCallback={actionCallback} />);
case "AUDIO":
return (<AudioBlock widgetMetaData={widgetMetaData} data={block} />);
case "IMAGE":
return (<ImageBlock widgetMetaData={widgetMetaData} data={block} actionCallback={actionCallback} />);
default:
return (<Alert sx={{m: "0.5rem"}} color="warning">Unsupported block type: {block.blockTypeName}</Alert>)
}
}

View File

@ -1,113 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import Box from "@mui/material/Box";
import Button from "@mui/material/Button";
import Icon from "@mui/material/Icon";
import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography";
import colors from "qqq/assets/theme/base/colors";
import {WidgetData} from "qqq/components/widgets/Widget";
import ValueUtils from "qqq/utils/qqq/ValueUtils";
import React from "react";
import {Link} from "react-router-dom";
/*******************************************************************************
** Utility class used by Widgets
**
*******************************************************************************/
export class WidgetUtils
{
/*******************************************************************************
**
*******************************************************************************/
public static generateExportButton = (onExportClick: () => void): JSX.Element =>
{
return (<Typography key={1} variant="body2" py={0} px={0} display="inline" position="relative" top="-0.25rem">
<Tooltip title="Export">
<Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={onExportClick} disabled={false}>
<Icon sx={{color: colors.gray.main, fontSize: 1.125}}>save_alt</Icon>
</Button>
</Tooltip>
</Typography>);
};
/*******************************************************************************
**
*******************************************************************************/
public static generateLabelLink = (linkText: string, linkURL: string): JSX.Element =>
{
return (<Box key={1} fontSize="1rem" pl={1} display="inline" position="relative">
(<Link to={linkURL}>{linkText}</Link>)
</Box>);
};
/*******************************************************************************
**
*******************************************************************************/
public static widgetCsvDataToString = (data: WidgetData): string =>
{
function isNumeric(x: any)
{
return !isNaN(Number(x));
}
let csv = "";
for (let i = 0; i < data.csvData.length; i++)
{
for (let j = 0; j < data.csvData[i].length; j++)
{
if (j > 0)
{
csv += ",";
}
let cell = data.csvData[i][j];
if (cell && isNumeric(String(cell)))
{
csv += cell;
}
else
{
csv += `"${ValueUtils.cleanForCsv(cell)}"`;
}
}
csv += "\n";
}
return (csv);
};
/*******************************************************************************
**
*******************************************************************************/
public static makeExportFileName = (data: WidgetData, widgetMetaData: QWidgetMetaData): string =>
{
const fileName = (data?.label ?? widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv";
return (fileName);
};
}

View File

@ -1,40 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Box from "@mui/material/Box";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import DumpJsonBox from "qqq/utils/DumpJsonBox";
import React from "react";
/*******************************************************************************
** Block that renders ... an audio tag
**
** <audio src=${path} ${autoPlay} ${showControls} />
*******************************************************************************/
export default function AudioBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
return (
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<audio src={data.values?.path} autoPlay={data.values?.autoPlay} controls={data.values?.showControls} />
</BlockElementWrapper>
);
}

View File

@ -1,67 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders ... a big number, optionally with some other stuff.
**
** ${heading}
** ${number} ${context}
*******************************************************************************/
export default function BigNumberBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
let flexJustifyContent = "normal";
let flexAlignItems = "baseline";
return (
<div style={{width: data.styles.width ?? "auto"}}>
<div style={{fontWeight: "700", fontSize: "0.875rem", color: "#3D3D3D", marginBottom: "-0.5rem"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="heading">
<span>{data.values.heading}</span>
</BlockElementWrapper>
</div>
<div style={{display: "flex", alignItems: flexAlignItems, justifyContent: flexJustifyContent}}>
<div style={{display: "flex", alignItems: "baseline"}}>
<div style={{fontWeight: "700", fontSize: "2rem", marginRight: "0.25rem"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="number">
<span style={{color: data.styles.numberColor}}>{data.values.number}</span>
</BlockElementWrapper>
</div>
{
data.values.context &&
<div style={{fontWeight: "500", fontSize: "0.875rem", color: "#7b809a"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="context">
<span>{data.values.context}</span>
</BlockElementWrapper>
</div>
}
</div>
</div>
</div>
);
}

View File

@ -1,120 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {Box, Tooltip} from "@mui/material";
import QContext from "QContext";
import HelpContent, {hasHelpContent} from "qqq/components/misc/HelpContent";
import {BlockData, BlockLink, BlockTooltip} from "qqq/components/widgets/blocks/BlockModels";
import CompositeWidget from "qqq/components/widgets/CompositeWidget";
import React, {ReactElement, useContext} from "react";
import {Link} from "react-router-dom";
interface BlockElementWrapperProps
{
data: BlockData;
metaData: QWidgetMetaData;
slot: string;
linkProps?: any;
children: ReactElement;
}
/*******************************************************************************
** For Blocks - wrap their "slot" elements with an optional tooltip and/or link
*******************************************************************************/
export default function BlockElementWrapper({data, metaData, slot, linkProps, children}: BlockElementWrapperProps): JSX.Element
{
const {helpHelpActive} = useContext(QContext);
let link: BlockLink;
let tooltip: BlockTooltip;
if (slot)
{
link = data.linkMap && data.linkMap[slot.toUpperCase()];
if (!link)
{
link = data.link;
}
tooltip = data.tooltipMap && data.tooltipMap[slot.toUpperCase()];
if (!tooltip)
{
tooltip = data.tooltip;
}
}
else
{
link = data.link;
tooltip = data.tooltip;
}
if (!tooltip)
{
const helpRoles = ["ALL_SCREENS"];
///////////////////////////////////////////////////////////////////////////////////////////////
// the full keys in the helpContent table will look like: //
// widget:MyCoolWidget;slot=myBlockId,label (if the block has a blockId in data) //
// widget:MyCoolWidget;slot=label (no blockId; note, label is slot name here) //
// in the widget metaData, the map of helpContent will just have the "slot" portion as a key //
///////////////////////////////////////////////////////////////////////////////////////////////
const key = data.blockId ? `${data.blockId},${slot}` : slot;
const showHelp = helpHelpActive || hasHelpContent(metaData?.helpContent?.get(key), helpRoles);
if (showHelp)
{
const formattedHelpContent = <HelpContent helpContents={metaData?.helpContent?.get(key)} roles={helpRoles} helpContentKey={`widget:${metaData?.name};slot:${key}`} />;
tooltip = {title: formattedHelpContent, placement: "bottom"};
}
}
let rs = children;
if (link && link.href)
{
rs = <Link to={link.href} target={link.target} style={{color: "#546E7A"}} {...linkProps}>{rs}</Link>;
}
if (tooltip)
{
let placement = tooltip.placement ? tooltip.placement.toLowerCase() : "bottom";
// @ts-ignore - placement possible values
if (tooltip.blockData)
{
// @ts-ignore - special case for composite type block...
rs = <Tooltip title={
<Box sx={{width: "200px"}}>
<CompositeWidget widgetMetaData={metaData} data={tooltip?.blockData} />
</Box>
}>{rs}</Tooltip>;
}
else
{
// @ts-ignore - placement possible values
rs = <Tooltip title={tooltip.title} placement={placement}>{rs}</Tooltip>;
}
}
return (rs);
}

View File

@ -1,64 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {CompositeData} from "qqq/components/widgets/CompositeWidget";
export interface BlockData
{
blockId?: string;
blockTypeName: string;
tooltip?: BlockTooltip;
link?: BlockLink;
tooltipMap?: { [slot: string]: BlockTooltip };
linkMap?: { [slot: string]: BlockLink };
values: any;
styles?: any;
conditional?: string;
}
export interface BlockTooltip
{
blockData?: CompositeData;
title: string | JSX.Element;
placement: string;
}
export interface BlockLink
{
href: string;
target: string;
}
export interface StandardBlockComponentProps
{
widgetMetaData: QWidgetMetaData;
data: BlockData;
actionCallback?: (blockData: BlockData, eventValues?: {[name: string]: any}) => boolean;
}

View File

@ -1,86 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import {standardWidth} from "qqq/components/buttons/DefaultButtons";
import MDButton from "qqq/components/legacy/MDButton";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import React from "react";
/*******************************************************************************
** Block that renders ... a button...
**
*******************************************************************************/
export default function ButtonBlock({widgetMetaData, data, actionCallback}: StandardBlockComponentProps): JSX.Element
{
const startIcon = data.values.startIcon?.name ? <Icon>{data.values.startIcon.name}</Icon> : null;
const endIcon = data.values.endIcon?.name ? <Icon>{data.values.endIcon.name}</Icon> : null;
function onClick()
{
if (actionCallback)
{
actionCallback(data, data.values);
}
else
{
console.log("ButtonBlock onClick with no actionCallback present, so, noop");
}
}
let buttonVariant: "gradient" | "outlined" | "text" = "gradient";
if (data.styles?.format == "outlined")
{
buttonVariant = "outlined";
}
else if (data.styles?.format == "text")
{
buttonVariant = "text";
}
else if (data.styles?.format == "filled")
{
buttonVariant = "gradient";
}
// todo - button colors... but to do RGB's, might need to move away from MDButton?
return (
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<Box mx={1} my={1} minWidth={standardWidth}>
<MDButton
type="button"
variant={buttonVariant}
color="dark"
size="small"
fullWidth
startIcon={startIcon}
endIcon={endIcon}
onClick={onClick}
>
{data.values.label ?? "Button"}
</MDButton>
</Box>
</BlockElementWrapper>
);
}

View File

@ -1,33 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders a simple dividing line
** margins & width are set such that it covers the padding of a card.
** if we need to use it differently, a style attribute should be added to its backend data.
*******************************************************************************/
export default function DividerBlock({}: StandardBlockComponentProps): JSX.Element
{
return (<div style={{margin: "1rem -1rem", width: "calc(100% + 2rem)", borderBottom: "1px solid #E0E0E0"}} />);
}

View File

@ -1,59 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Box from "@mui/material/Box";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import DumpJsonBox from "qqq/utils/DumpJsonBox";
import React from "react";
/*******************************************************************************
** Block that renders ... an image tag
**
** <audio src=${path} ${autoPlay} ${showControls} />
*******************************************************************************/
export default function AudioBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
let imageStyle: any = {};
if(data.styles?.width)
{
imageStyle.width = data.styles?.width;
}
if(data.styles?.height)
{
imageStyle.height = data.styles?.height;
}
if(data.styles?.bordered)
{
imageStyle.border = "1px solid #C0C0C0";
imageStyle.borderRadius = "0.5rem";
}
return (
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<img src={data.values?.path} alt={data.values?.alt} style={imageStyle} />
</BlockElementWrapper>
);
}

View File

@ -1,139 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import {QFieldMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QFieldMetaData";
import Box from "@mui/material/Box";
import QDynamicFormField from "qqq/components/forms/DynamicFormField";
import DynamicFormUtils from "qqq/components/forms/DynamicFormUtils";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import React, {SyntheticEvent, useState} from "react";
/*******************************************************************************
** Block that renders ... a text input
**
*******************************************************************************/
export default function InputFieldBlock({widgetMetaData, data, actionCallback}: StandardBlockComponentProps): JSX.Element
{
const [blurCount, setBlurCount] = useState(0)
const fieldMetaData = new QFieldMetaData(data.values.fieldMetaData);
const dynamicField = DynamicFormUtils.getDynamicField(fieldMetaData);
let autoFocus = data.values.autoFocus as boolean
let value = data.values.value;
if(value == null || value == undefined)
{
value = "";
}
////////////////////////////////////////////////////////////////////////////////
// for an autoFocus field... //
// we're finding that if we blur it when clicking an action button, that //
// an un-desirable "now it's been touched, so show an error" happens. //
// so let us remove the default blur handler, for the first (auto) focus/blur //
// cycle, and we seem to have a better time. //
////////////////////////////////////////////////////////////////////////////////
let dynamicFormFieldRest: {onBlur?: any, sx?: any} = {}
if(autoFocus && blurCount == 0)
{
dynamicFormFieldRest.onBlur = (event: React.SyntheticEvent) =>
{
event.stopPropagation();
event.preventDefault();
setBlurCount(blurCount + 1);
}
}
/***************************************************************************
**
***************************************************************************/
function eventHandler(event: KeyboardEvent)
{
if(data.values.submitOnEnter && event.key == "Enter")
{
// @ts-ignore target.value...
const inputValue = event.target.value?.trim()
// todo - make this behavior opt-in for inputBlocks?
if(inputValue && `${inputValue}`.startsWith("->"))
{
const actionCode = inputValue.substring(2);
if(actionCallback)
{
actionCallback(data, {actionCode: actionCode, _fieldToClearIfError: fieldMetaData.name});
///////////////////////////////////////////////////////
// return, so we don't submit the actionCode as text //
///////////////////////////////////////////////////////
return;
}
}
if(fieldMetaData.isRequired && inputValue == "")
{
console.log("input field is required, but missing value, so not submitting");
return;
}
if(actionCallback)
{
console.log("InputFieldBlock calling actionCallback for submitOnEnter");
let values: {[name: string]: any} = {};
values[fieldMetaData.name] = inputValue;
actionCallback(data, values);
}
else
{
console.log("InputFieldBlock was set as submitOnEnter, but no actionCallback was present, so, noop");
}
}
}
const labelElement = <Box fontSize="1rem" fontWeight="500" marginBottom="0.25rem">
<label htmlFor={fieldMetaData.name}>{fieldMetaData.label}</label>
</Box>
return (
<Box mt="0.5rem">
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<>
{labelElement}
<QDynamicFormField
name={fieldMetaData.name}
displayFormat={null}
label=""
placeholder={data.values?.placeholder}
backgroundColor="#FFFFFF"
formFieldObject={dynamicField}
type={fieldMetaData.type}
value={value}
autoFocus={autoFocus}
onKeyUp={eventHandler}
{...dynamicFormFieldRest} />
</>
</BlockElementWrapper>
</Box>
);
}

View File

@ -1,48 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Icon from "@mui/material/Icon";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders ... a number, and an icon, like a badge.
**
** ${number} ${icon}
*******************************************************************************/
export default function NumberIconBadgeBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
return (
<div style={{display: "inline-block", whiteSpace: "nowrap", color: data.styles.color}}>
{
data.values.number &&
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="number">
<span style={{color: data.styles.color, fontSize: "0.875rem"}}>{data.values.number}</span>
</BlockElementWrapper>
}
{
data.values.iconName &&
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="icon">
<Icon style={{color: data.styles.color, fontSize: "1rem", marginLeft: "2px", position: "relative", top: "4px"}}>{data.values.iconName}</Icon>
</BlockElementWrapper>
}
</div>);
}

View File

@ -1,70 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Typography from "@mui/material/Typography";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders a progress bar!
**
** Values:
** ${heading}
** [${percent}===___] ${value ?? percent}
**
** Slots:
** ${heading}
** ${bar} ${value}
*******************************************************************************/
export default function ProgressBarBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
return (
<Typography component="div" variant="button" color="text" fontWeight="light" sx={{textTransform: "none"}}>
{
data.values.heading &&
<div style={{marginBottom: "0.25rem", fontWeight: 500, color: "#3D3D3D"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="heading">
<span>{data.values.heading}</span>
</BlockElementWrapper>
</div>
}
<div style={{display: "flex", alignItems: "center", marginBottom: "0.75rem"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="bar" linkProps={{style: {width: "100%"}}}>
<div style={{background: "#E0E0E0", width: "100%", borderRadius: "0.5rem", height: "1rem"}}>
{
data.values.percent > 0 ? <div style={{background: data.styles.barColor ?? "#0062ff", minWidth: "1rem", width: `${data.values.percent}%`, borderRadius: "0.5rem", height: "1rem"}}></div> : <></>
}
</div>
</BlockElementWrapper>
<div style={{width: "60px", textAlign: "right", fontWeight: 600, color: "#3D3D3D"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="value">
<span>{data.values.value ?? `${(data.values.percent as number).toFixed(1)}%`}</span>
</BlockElementWrapper>
</div>
</div>
</Typography>);
}

View File

@ -1,54 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders a label & value, meant to be used as a detail-row in a
** sub-row within a table widget
**
** ${label} ${value}
*******************************************************************************/
export default function TableSubRowDetailRowBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
return (
<div style={{display: "flex", maxWidth: "calc(100% - 24px)", justifyContent: "space-between"}}>
{
data.values.label &&
<div style={{overflow: "hidden", whiteSpace: "nowrap", textOverflow: "ellipsis"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="label">
<span style={{color: data.styles.labelColor}}>{data.values.label}</span>
</BlockElementWrapper>
</div>
}
{
data.values.value &&
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="value">
<span style={{color: data.styles.valueColor}}>{data.values.value}</span>
</BlockElementWrapper>
}
</div>
);
}

View File

@ -1,164 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Box from "@mui/material/Box";
import Icon from "@mui/material/Icon";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
import ProcessWidgetBlockUtils from "qqq/pages/processes/ProcessWidgetBlockUtils";
import React from "react";
/*******************************************************************************
** Block that renders ... just some text.
**
** ${text}
*******************************************************************************/
export default function TextBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
let color = "rgba(0, 0, 0, 0.87)";
if (data.styles?.color)
{
color = ProcessWidgetBlockUtils.processColorFromStyleMap(data.styles.color);
}
let boxStyle = {};
if (data.styles?.format == "alert")
{
boxStyle =
{
border: `1px solid ${color}`,
background: `${color}40`,
padding: "0.5rem",
borderRadius: "0.5rem",
};
}
else if (data.styles?.format == "banner")
{
boxStyle =
{
background: `${color}40`,
padding: "0.5rem",
};
}
let fontSize = "1rem";
if (data.styles?.size)
{
switch (data.styles.size.toLowerCase())
{
case "largest":
fontSize = "3rem";
break;
case "headline":
fontSize = "2rem";
break;
case "title":
fontSize = "1.5rem";
break;
case "body":
fontSize = "1rem";
break;
case "smallest":
fontSize = "0.75rem";
break;
default:
{
if (data.styles.size.match(/^\d+$/))
{
fontSize = `${data.styles.size}px`;
}
else
{
fontSize = "1rem";
}
}
}
}
let fontWeight = "400";
if (data.styles?.weight)
{
switch (data.styles.weight.toLowerCase())
{
case "thin":
case "100":
fontWeight = "100";
break;
case "extralight":
case "200":
fontWeight = "200";
break;
case "light":
case "300":
fontWeight = "300";
break;
case "normal":
case "400":
fontWeight = "400";
break;
case "medium":
case "500":
fontWeight = "500";
break;
case "semibold":
case "600":
fontWeight = "600";
break;
case "bold":
case "700":
fontWeight = "700";
break;
case "extrabold":
case "800":
fontWeight = "800";
break;
case "black":
case "900":
fontWeight = "900";
break;
}
}
const text = data.values.interpolatedText ?? data.values.text;
const lines = text.split("\n");
const startIcon = data.values.startIcon?.name ? <Icon>{data.values.startIcon.name}</Icon> : null;
const endIcon = data.values.endIcon?.name ? <Icon>{data.values.endIcon.name}</Icon> : null;
return (
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="">
<Box display="inline-block" lineHeight="1.2" sx={boxStyle}>
<span style={{fontSize: fontSize, color: color, fontWeight: fontWeight}}>
{lines.map((line: string, index: number) =>
(
<div key={index}>
<>
{index == 0 && startIcon ? {startIcon} : null}
{line}
{index == lines.length - 1 && endIcon ? {endIcon} : null}
</>
</div>
))
}</span>
</Box>
</BlockElementWrapper>
);
}

View File

@ -1,81 +0,0 @@
/*
* QQQ - Low-code Application Framework for Engineers.
* Copyright (C) 2021-2024. Kingsrook, LLC
* 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States
* contact@kingsrook.com
* https://github.com/Kingsrook/
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import Icon from "@mui/material/Icon";
import React from "react";
import BlockElementWrapper from "qqq/components/widgets/blocks/BlockElementWrapper";
import {StandardBlockComponentProps} from "qqq/components/widgets/blocks/BlockModels";
/*******************************************************************************
** Block that renders an up/down icon, a number, and some context
**
** ${icon} ${number} ${context}
*
** or, if style.isStacked:
*
** ${icon} ${number}
** ${context}
*******************************************************************************/
export default function UpOrDownNumberBlock({widgetMetaData, data}: StandardBlockComponentProps): JSX.Element
{
if (!data.styles)
{
data.styles = {};
}
if (!data.values)
{
data.values = {};
}
const UP_ICON = "arrow_drop_up";
const DOWN_ICON = "arrow_drop_down";
const defaultGreenColor = "#2BA83F";
const defaultRedColor = "#FB4141";
const goodOrBadColor = data.styles.colorOverride ?? (data.values.isGood ? defaultGreenColor : defaultRedColor);
const iconName = data.values.isUp ? UP_ICON : DOWN_ICON;
return (
<>
<div style={{display: "flex", flexDirection: data.styles.isStacked ? "column" : "row", alignItems: data.styles.isStacked ? "flex-end" : "baseline", marginLeft: "auto"}}>
<div style={{display: "flex", alignItems: "baseline", fontWeight: 700, fontSize: ".875rem"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="number">
<>
<Icon sx={{color: goodOrBadColor, alignSelf: "flex-end", fontSize: "2.25rem !important", lineHeight: "0.875rem", height: "1rem", width: "2rem",}}>{iconName}</Icon>
<span style={{color: goodOrBadColor}}>{data.values.number}</span>
</>
</BlockElementWrapper>
</div>
<div style={{fontWeight: 500, fontSize: "0.875rem", color: "#7b809a", marginLeft: "0.25rem"}}>
<BlockElementWrapper metaData={widgetMetaData} data={data} slot="context">
<span>{data.values.context}</span>
</BlockElementWrapper>
</div>
</div>
</>
);
}

View File

@ -28,7 +28,6 @@ import {Bar} from "react-chartjs-2";
import {useNavigate} from "react-router-dom";
import colors from "qqq/assets/theme/base/colors";
import {chartColors, DefaultChartData} from "qqq/components/widgets/charts/DefaultChartData";
import ChartSubheaderWithData, {ChartSubheaderData} from "qqq/components/widgets/components/ChartSubheaderWithData";
ChartJS.register(
CategoryScale,
@ -39,89 +38,30 @@ ChartJS.register(
Legend
);
export const makeOptions = (data: DefaultChartData) =>
{
return({
maintainAspectRatio: false,
responsive: true,
animation: {
duration: 0
export const options = {
responsive: true,
animation: {
duration: 0
},
scales: {
x: {
stacked: true,
grid: {offset: false},
ticks: {autoSkip: false, maxRotation: 90}
},
elements: {
bar: {
borderRadius: 4
}
y: {
stacked: true,
},
onHover: function (event: any, elements: any[], chart: any)
{
if(event.type == "mousemove" && elements.length > 0 && data.urls && data.urls.length > elements[0].index && data.urls[elements[0].index])
{
chart.canvas.style.cursor = "pointer";
}
else
{
chart.canvas.style.cursor = "default";
}
},
plugins: {
tooltip: {
// todo - some configs around this
callbacks: {
title: function(context: any)
{
return ("");
},
label: function(context: any)
{
if(context.dataset.label.startsWith(context.label))
{
return `${context.label}: ${context.formattedValue}`;
}
else
{
return ("");
}
}
}
},
legend: {
position: "bottom",
labels: {
usePointStyle: true,
pointStyle: "circle",
boxHeight: 6,
boxWidth: 6,
padding: 12,
font: {
size: 14
}
}
}
},
scales: {
x: {
stacked: true,
grid: {display: false},
ticks: {autoSkip: false, maxRotation: 90}
},
y: {
stacked: true,
position: "right",
ticks: {precision: 0}
},
},
});
}
},
};
interface Props
{
data: DefaultChartData;
chartSubheaderData?: ChartSubheaderData;
}
const {gradients} = colors;
function StackedBarChart({data, chartSubheaderData}: Props): JSX.Element
function StackedBarChart({data}: Props): JSX.Element
{
const navigate = useNavigate();
@ -130,30 +70,23 @@ function StackedBarChart({data, chartSubheaderData}: Props): JSX.Element
const handleClick = (e: Array<{}>) =>
{
if (e && e.length > 0 && data?.urls && data?.urls.length)
if(e && e.length > 0 && data?.urls && data?.urls.length)
{
// @ts-ignore
navigate(data.urls[e[0]["index"]]);
}
console.log(e);
};
}
useEffect(() =>
{
if (data)
if(data)
{
data?.datasets.forEach((dataset: any, index: number) =>
{
if (!dataset.backgroundColor)
{
if (gradients[chartColors[index]])
{
dataset.backgroundColor = gradients[chartColors[index]].state;
}
else
{
dataset.backgroundColor = chartColors[index];
}
dataset.backgroundColor = gradients[chartColors[index]].state;
}
});
setStateData(stateData);
@ -162,13 +95,8 @@ function StackedBarChart({data, chartSubheaderData}: Props): JSX.Element
return data ? (
<Box>
{chartSubheaderData && (<ChartSubheaderWithData chartSubheaderData={chartSubheaderData} />)}
<Box width="100%" height="300px">
<Bar data={data} options={makeOptions(data)} getElementsAtEvent={handleClick} />
</Box>
</Box>
) : <Skeleton sx={{marginLeft: "20px", marginRight: "20px", height: "200px"}} />;
<Box p={3}><Bar data={data} options={options} getElementsAtEvent={handleClick} /></Box>
) : <Skeleton sx={{marginLeft: "20px", marginRight: "20px", height: "200px"}} /> ;
}
export default StackedBarChart;

Some files were not shown because too many files have changed in this diff Show More