Merged feature/remove-old-auth-header into feature/CE-604-complete-shipment-sla-updates-and-local-tnt-rules

This commit is contained in:
2023-10-27 16:04:50 -05:00
13 changed files with 441 additions and 281 deletions

View File

@ -73,6 +73,14 @@ export default function App()
const [loggedInUser, setLoggedInUser] = useState({} as { name?: string, email?: string }); const [loggedInUser, setLoggedInUser] = useState({} as { name?: string, email?: string });
const [defaultRoute, setDefaultRoute] = useState("/no-apps"); 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 => const shouldStoreNewToken = (newToken: string, oldToken: string): boolean =>
{ {
if (!cookies[SESSION_UUID_COOKIE_NAME]) if (!cookies[SESSION_UUID_COOKIE_NAME])
@ -167,18 +175,8 @@ export default function App()
console.log("Using existing sessionUUID cookie"); 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); setIsFullyAuthenticated(true);
qController.setGotAuthentication(); qController.setGotAuthentication();
qController.setAuthorizationHeaderValue("Bearer " + accessToken);
setLoggedInUser(user); setLoggedInUser(user);
console.log("Token load complete."); console.log("Token load complete.");
@ -199,8 +197,8 @@ export default function App()
// use a random token if anonymous or mock // // use a random token if anonymous or mock //
///////////////////////////////////////////// /////////////////////////////////////////////
console.log("Generating random token..."); console.log("Generating random token...");
qController.setAuthorizationHeaderValue(Md5.hashStr(`${new Date()}`));
setIsFullyAuthenticated(true); setIsFullyAuthenticated(true);
qController.setGotAuthentication();
setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"}); setCookie(SESSION_UUID_COOKIE_NAME, Md5.hashStr(`${new Date()}`), {path: "/"});
console.log("Token generation complete."); console.log("Token generation complete.");
return; return;

View File

@ -30,7 +30,7 @@ import Tooltip from "@mui/material/Tooltip/Tooltip";
import Typography from "@mui/material/Typography"; import Typography from "@mui/material/Typography";
import parse from "html-react-parser"; import parse from "html-react-parser";
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {Link, NavigateFunction, useNavigate} from "react-router-dom"; import {NavigateFunction, useNavigate} from "react-router-dom";
import colors from "qqq/components/legacy/colors"; import colors from "qqq/components/legacy/colors";
import DropdownMenu, {DropdownOption} from "qqq/components/widgets/components/DropdownMenu"; import DropdownMenu, {DropdownOption} from "qqq/components/widgets/components/DropdownMenu";
@ -46,6 +46,7 @@ export interface WidgetData
dropdownNeedsSelectedText?: string; dropdownNeedsSelectedText?: string;
hasPermission?: boolean; hasPermission?: boolean;
errorLoading?: boolean; errorLoading?: boolean;
[other: string]: any; [other: string]: any;
} }
@ -53,6 +54,7 @@ export interface WidgetData
interface Props interface Props
{ {
labelAdditionalComponentsLeft: LabelComponent[]; labelAdditionalComponentsLeft: LabelComponent[];
labelAdditionalElementsLeft: JSX.Element[];
labelAdditionalComponentsRight: LabelComponent[]; labelAdditionalComponentsRight: LabelComponent[];
widgetMetaData?: QWidgetMetaData; widgetMetaData?: QWidgetMetaData;
widgetData?: WidgetData; widgetData?: WidgetData;
@ -70,6 +72,7 @@ Widget.defaultProps = {
widgetMetaData: {}, widgetMetaData: {},
widgetData: {}, widgetData: {},
labelAdditionalComponentsLeft: [], labelAdditionalComponentsLeft: [],
labelAdditionalElementsLeft: [],
labelAdditionalComponentsRight: [], labelAdditionalComponentsRight: [],
}; };
@ -88,34 +91,8 @@ export class LabelComponent
{ {
render = (args: LabelComponentRenderArgs): JSX.Element => render = (args: LabelComponentRenderArgs): JSX.Element =>
{ {
return (<div>Unsupported component type</div>) return (<div>Unsupported component type</div>);
} };
}
/*******************************************************************************
**
*******************************************************************************/
export class HeaderLink extends LabelComponent
{
label: string;
to: string
constructor(label: string, to: string)
{
super();
this.label = label;
this.to = to;
}
render = (args: LabelComponentRenderArgs): JSX.Element =>
{
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>
);
}
} }
@ -184,8 +161,8 @@ export class AddNewRecordButton extends LabelComponent
openEditForm = (navigate: any, table: QTableMetaData, id: any = null, defaultValues: any, disabledFields: any) => 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 => render = (args: LabelComponentRenderArgs): JSX.Element =>
{ {
@ -194,35 +171,7 @@ export class AddNewRecordButton extends LabelComponent
<Button sx={{mt: 0.75}} onClick={() => this.openEditForm(args.navigate, this.table, null, this.defaultValues, this.disabledFields)}>{this.label}</Button> <Button sx={{mt: 0.75}} onClick={() => this.openEditForm(args.navigate, this.table, null, this.defaultValues, this.disabledFields)}>{this.label}</Button>
</Typography> </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>
);
}
} }
@ -270,7 +219,7 @@ export class Dropdown extends LabelComponent
/> />
</Box> </Box>
); );
} };
} }
@ -294,7 +243,7 @@ export class ReloadControl extends LabelComponent
<Tooltip title="Refresh"><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={() => this.callback()}><Icon>refresh</Icon></Button></Tooltip> <Tooltip title="Refresh"><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={() => this.callback()}><Icon>refresh</Icon></Button></Tooltip>
</Typography> </Typography>
); );
} };
} }
@ -415,7 +364,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
if (index < 0) 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; dropdownData[index] = (changedData) ? changedData.id : null;
@ -437,7 +386,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
} }
} }
reloadWidget(dropdownData) reloadWidget(dropdownData);
} }
} }
@ -465,7 +414,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
{ {
console.log(`No reload widget callback in ${props.widgetMetaData.label}`); console.log(`No reload widget callback in ${props.widgetMetaData.label}`);
} }
} };
const toggleFullScreenWidget = () => const toggleFullScreenWidget = () =>
{ {
@ -477,14 +426,14 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
{ {
setFullScreenWidgetClassName("fullScreenWidget"); setFullScreenWidgetClassName("fullScreenWidget");
} }
} };
const hasPermission = props.widgetData?.hasPermission === undefined || props.widgetData?.hasPermission === true; const hasPermission = props.widgetData?.hasPermission === undefined || props.widgetData?.hasPermission === true;
const isSet = (v: any): boolean => const isSet = (v: any): boolean =>
{ {
return (v !== null && v !== undefined); 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 // // 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 //
@ -493,6 +442,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
if (hasPermission) if (hasPermission)
{ {
needLabelBox ||= (labelComponentsLeft && labelComponentsLeft.length > 0); needLabelBox ||= (labelComponentsLeft && labelComponentsLeft.length > 0);
needLabelBox ||= (props.labelAdditionalElementsLeft && props.labelAdditionalElementsLeft.length > 0);
needLabelBox ||= (labelComponentsRight && labelComponentsRight.length > 0); needLabelBox ||= (labelComponentsRight && labelComponentsRight.length > 0);
needLabelBox ||= isSet(props.widgetMetaData?.icon); needLabelBox ||= isSet(props.widgetMetaData?.icon);
needLabelBox ||= isSet(props.widgetData?.label); needLabelBox ||= isSet(props.widgetData?.label);
@ -577,6 +527,7 @@ function Widget(props: React.PropsWithChildren<Props>): JSX.Element
}) })
) )
} }
{props.labelAdditionalElementsLeft}
</Box> </Box>
<Box> <Box>
{ {

View File

@ -22,10 +22,14 @@
import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData"; import {QTableMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QTableMetaData";
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData"; import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord"; import {QRecord} from "@kingsrook/qqq-frontend-core/lib/model/QRecord";
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 {DataGridPro, GridCallbackDetails, GridRowParams, MuiEvent} from "@mui/x-data-grid-pro"; import {DataGridPro, GridCallbackDetails, GridRowParams, MuiEvent} from "@mui/x-data-grid-pro";
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {useNavigate} from "react-router-dom"; import {useNavigate, Link} from "react-router-dom";
import Widget, {AddNewRecordButton, ExportDataButton, HeaderLink, LabelComponent} from "qqq/components/widgets/Widget"; import Widget, {AddNewRecordButton, LabelComponent} from "qqq/components/widgets/Widget";
import DataGridUtils from "qqq/utils/DataGridUtils"; import DataGridUtils from "qqq/utils/DataGridUtils";
import HtmlUtils from "qqq/utils/HtmlUtils"; import HtmlUtils from "qqq/utils/HtmlUtils";
import Client from "qqq/utils/qqq/Client"; import Client from "qqq/utils/qqq/Client";
@ -47,6 +51,8 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
const [records, setRecords] = useState([] as QRecord[]) const [records, setRecords] = useState([] as QRecord[])
const [columns, setColumns] = useState([]); const [columns, setColumns] = useState([]);
const [allColumns, setAllColumns] = useState([]) const [allColumns, setAllColumns] = useState([])
const [csv, setCsv] = useState(null as string);
const [fileName, setFileName] = useState(null as string);
const navigate = useNavigate(); const navigate = useNavigate();
useEffect(() => useEffect(() =>
@ -75,6 +81,7 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
///////////////////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////////////////
// capture all-columns to use for the export (before we might splice some away from the on-screen display) // // capture all-columns to use for the export (before we might splice some away from the on-screen display) //
///////////////////////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////////////////////
const allColumns = [... columns];
setAllColumns(JSON.parse(JSON.stringify(columns))); setAllColumns(JSON.parse(JSON.stringify(columns)));
//////////////////////////////////////////////////////////////// ////////////////////////////////////////////////////////////////
@ -95,11 +102,7 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
setRows(rows); setRows(rows);
setRecords(records) setRecords(records)
setColumns(columns); setColumns(columns);
}
}, [data]);
const exportCallback = () =>
{
let csv = ""; let csv = "";
for (let i = 0; i < allColumns.length; i++) for (let i = 0; i < allColumns.length; i++)
{ {
@ -118,16 +121,23 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
} }
const fileName = (data?.label ?? widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv"; const fileName = (data?.label ?? widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv";
HtmlUtils.download(fileName, csv);
setCsv(csv);
setFileName(fileName);
} }
}, [data]);
/////////////////// ///////////////////
// view all link // // view all link //
/////////////////// ///////////////////
const labelAdditionalComponentsLeft: LabelComponent[] = [] const labelAdditionalElementsLeft: JSX.Element[] = [];
if(data && data.viewAllLink) if(data && data.viewAllLink)
{ {
labelAdditionalComponentsLeft.push(new HeaderLink("View All", data.viewAllLink)); labelAdditionalElementsLeft.push(
<Typography variant="body2" p={2} display="inline" fontSize=".875rem" pt="0" position="relative" top="-0.25rem">
<Link to={data.viewAllLink}>View All</Link>
</Typography>
)
} }
/////////////////// ///////////////////
@ -149,7 +159,26 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
} }
} }
labelAdditionalComponentsLeft.push(new ExportDataButton(() => exportCallback(), isExportDisabled, tooltipTitle)) const onExportClick = () =>
{
if(csv)
{
HtmlUtils.download(fileName, csv);
}
else
{
alert("There is no data available to export.")
}
}
if(widgetMetaData?.showExportButton)
{
labelAdditionalElementsLeft.push(
<Typography key={1} variant="body2" py={2} px={0} display="inline" position="relative" top="-0.375rem">
<Tooltip title={tooltipTitle}><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={onExportClick} disabled={isExportDisabled}><Icon>save_alt</Icon></Button></Tooltip>
</Typography>
);
}
//////////////////// ////////////////////
// add new button // // add new button //
@ -184,7 +213,7 @@ function RecordGridWidget({widgetMetaData, data}: Props): JSX.Element
<Widget <Widget
widgetMetaData={widgetMetaData} widgetMetaData={widgetMetaData}
widgetData={data} widgetData={data}
labelAdditionalComponentsLeft={labelAdditionalComponentsLeft} labelAdditionalElementsLeft={labelAdditionalElementsLeft}
labelAdditionalComponentsRight={labelAdditionalComponentsRight} labelAdditionalComponentsRight={labelAdditionalComponentsRight}
> >
<DataGridPro <DataGridPro

View File

@ -21,11 +21,15 @@
import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData"; import {QWidgetMetaData} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QWidgetMetaData";
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";
// @ts-ignore // @ts-ignore
import {htmlToText} from "html-to-text"; import {htmlToText} from "html-to-text";
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import TableCard from "qqq/components/widgets/tables/TableCard"; import TableCard from "qqq/components/widgets/tables/TableCard";
import Widget, {ExportDataButton, WidgetData} from "qqq/components/widgets/Widget"; import Widget, {WidgetData} from "qqq/components/widgets/Widget";
import HtmlUtils from "qqq/utils/HtmlUtils"; import HtmlUtils from "qqq/utils/HtmlUtils";
import ValueUtils from "qqq/utils/qqq/ValueUtils"; import ValueUtils from "qqq/utils/qqq/ValueUtils";
@ -43,6 +47,8 @@ TableWidget.defaultProps = {
function TableWidget(props: Props): JSX.Element function TableWidget(props: Props): JSX.Element
{ {
const [isExportDisabled, setIsExportDisabled] = useState(false); // hmm, would like true here, but it broke... const [isExportDisabled, setIsExportDisabled] = useState(false); // hmm, would like true here, but it broke...
const [csv, setCsv] = useState(null as string);
const [fileName, setFileName] = useState(null as string);
const rows = props.widgetData?.rows; const rows = props.widgetData?.rows;
const columns = props.widgetData?.columns; const columns = props.widgetData?.columns;
@ -56,14 +62,8 @@ function TableWidget(props: Props): JSX.Element
} }
setIsExportDisabled(isExportDisabled); setIsExportDisabled(isExportDisabled);
}, [props.widgetMetaData, props.widgetData]);
const exportCallback = () =>
{
if (props.widgetData && rows && columns) if (props.widgetData && rows && columns)
{ {
console.log(props.widgetData);
let csv = ""; let csv = "";
for (let j = 0; j < columns.length; j++) for (let j = 0; j < columns.length; j++)
{ {
@ -98,16 +98,37 @@ function TableWidget(props: Props): JSX.Element
csv += "\n"; csv += "\n";
} }
console.log(csv); setCsv(csv);
const fileName = (props.widgetData.label ?? props.widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv"; const fileName = (props.widgetData.label ?? props.widgetMetaData.label) + " " + ValueUtils.formatDateTimeForFileName(new Date()) + ".csv";
setFileName(fileName)
console.log(`useEffect, setting fileName ${fileName}`);
}
}, [props.widgetMetaData, props.widgetData]);
const onExportClick = () =>
{
if(csv)
{
HtmlUtils.download(fileName, csv); HtmlUtils.download(fileName, csv);
} }
else else
{ {
alert("There is no data available to export."); alert("There is no data available to export.")
}
}
const labelAdditionalElementsLeft: JSX.Element[] = [];
if(props.widgetMetaData?.showExportButton)
{
labelAdditionalElementsLeft.push(
<Typography key={1} variant="body2" py={2} px={0} display="inline" position="relative" top="-0.375rem">
<Tooltip title="Export"><Button sx={{px: 1, py: 0, minWidth: "initial"}} onClick={onExportClick} disabled={false}><Icon>save_alt</Icon></Button></Tooltip>
</Typography>
);
} }
};
return ( return (
<Widget <Widget
@ -116,7 +137,7 @@ function TableWidget(props: Props): JSX.Element
reloadWidgetCallback={(data) => props.reloadWidgetCallback(data)} reloadWidgetCallback={(data) => props.reloadWidgetCallback(data)}
footerHTML={props.widgetData?.footerHTML} footerHTML={props.widgetData?.footerHTML}
isChild={props.isChild} isChild={props.isChild}
labelAdditionalComponentsLeft={props.widgetMetaData?.showExportButton ? [new ExportDataButton(() => exportCallback(), isExportDisabled)] : []} labelAdditionalElementsLeft={labelAdditionalElementsLeft}
> >
<TableCard <TableCard
noRowsFoundHTML={props.widgetData?.noRowsFoundHTML} noRowsFoundHTML={props.widgetData?.noRowsFoundHTML}

View File

@ -229,7 +229,7 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
const download = (url: string, fileName: string) => const download = (url: string, fileName: string) =>
{ {
///////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////
// todo - this could be simplified. // // todo - this could be simplified, i think? //
// it was originally built like this when we had to submit full access token to backend... // // it was originally built like this when we had to submit full access token to backend... //
///////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////
let xhr = new XMLHttpRequest(); let xhr = new XMLHttpRequest();
@ -237,12 +237,6 @@ function ProcessRun({process, table, defaultProcessValues, isModal, isWidget, is
xhr.responseType = "blob"; xhr.responseType = "blob";
let formData = new FormData(); let formData = new FormData();
////////////////////////////////////
// todo#authHeader - delete this. //
////////////////////////////////////
const qController = Client.getInstance();
formData.append("Authorization", qController.getAuthorizationHeaderValue());
// @ts-ignore // @ts-ignore
xhr.send(formData); xhr.send(formData);

View File

@ -1136,8 +1136,6 @@ function RecordQuery({table, launchProcess}: Props): JSX.Element
<body> <body>
Generating file <u>${filename}</u>${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}... Generating file <u>${filename}</u>${totalRecords ? " with " + totalRecords.toLocaleString() + " record" + (totalRecords == 1 ? "" : "s") : ""}...
<form id="exportForm" method="post" action="${url}" > <form id="exportForm" method="post" action="${url}" >
<!-- todo#authHeader - remove this. -->
<input type="hidden" name="Authorization" value="${qController.getAuthorizationHeaderValue()}">
<input type="hidden" name="fields" value="${visibleFields.join(",")}"> <input type="hidden" name="fields" value="${visibleFields.join(",")}">
<input type="hidden" name="filter" id="filter"> <input type="hidden" name="filter" id="filter">
</form> </form>

View File

@ -63,6 +63,10 @@ export default class HtmlUtils
/******************************************************************************* /*******************************************************************************
** Download a server-side generated file (or the contents of a data: url) ** Download a server-side generated file (or the contents of a data: url)
**
** todo - this could be simplified (i think?)
** it was originally built like this when we had to submit full access token to backend...
**
*******************************************************************************/ *******************************************************************************/
static downloadUrlViaIFrame = (url: string, filename: string) => static downloadUrlViaIFrame = (url: string, filename: string) =>
{ {
@ -95,18 +99,6 @@ export default class HtmlUtils
form.setAttribute("target", "downloadIframe"); form.setAttribute("target", "downloadIframe");
iframe.appendChild(form); iframe.appendChild(form);
/////////////////////////////////////////////////////////////////////////////////////////////
// todo#authHeader - remove after comfortable with sessionUUID //
// todo - this could be simplified (i think?) //
// it was originally built like this when we had to submit full access token to backend... //
/////////////////////////////////////////////////////////////////////////////////////////////
const authorizationInput = document.createElement("input");
authorizationInput.setAttribute("type", "hidden");
authorizationInput.setAttribute("id", "authorizationInput");
authorizationInput.setAttribute("name", "Authorization");
authorizationInput.setAttribute("value", Client.getInstance().getAuthorizationHeaderValue());
form.appendChild(authorizationInput);
const downloadInput = document.createElement("input"); const downloadInput = document.createElement("input");
downloadInput.setAttribute("type", "hidden"); downloadInput.setAttribute("type", "hidden");
downloadInput.setAttribute("name", "download"); downloadInput.setAttribute("name", "download");
@ -118,15 +110,16 @@ export default class HtmlUtils
/******************************************************************************* /*******************************************************************************
** Open a server-side generated file from a url in a new window (or a data: url) ** Open a server-side generated file from a url in a new window (or a data: url)
**
** todo - this could be simplified (i think?)
** it was originally built like this when we had to submit full access token to backend...
**
*******************************************************************************/ *******************************************************************************/
static openInNewWindow = (url: string, filename: string) => static openInNewWindow = (url: string, filename: string) =>
{ {
if(url.startsWith("data:")) if(url.startsWith("data:"))
{ {
///////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////
// todo#authHeader - remove the Authorization input after comfortable with sessionUUID //
// todo - this could be simplified (i think?) //
// it was originally built like this when we had to submit full access token to backend... //
///////////////////////////////////////////////////////////////////////////////////////////// /////////////////////////////////////////////////////////////////////////////////////////////
const openInWindow = window.open("", "_blank"); const openInWindow = window.open("", "_blank");
openInWindow.document.write(`<html lang="en"> openInWindow.document.write(`<html lang="en">
@ -154,7 +147,6 @@ export default class HtmlUtils
<body> <body>
Opening ${filename}... Opening ${filename}...
<form id="exportForm" method="post" action="${url}" > <form id="exportForm" method="post" action="${url}" >
<input type="hidden" name="Authorization" value="${Client.getInstance().getAuthorizationHeaderValue()}">
</form> </form>
</body> </body>
</html>`); </html>`);

View File

@ -29,11 +29,18 @@ import {QException} from "@kingsrook/qqq-frontend-core/lib/exceptions/QException
class Client class Client
{ {
private static qController: QController; private static qController: QController;
private static unauthorizedCallback: () => void;
private static handleException(exception: QException) private static handleException(exception: QException)
{ {
// todo - check for 401 and clear cookie et al & logout?
console.log(`Caught Exception: ${JSON.stringify(exception)}`); console.log(`Caught Exception: ${JSON.stringify(exception)}`);
if(exception && exception.status == "401" && Client.unauthorizedCallback)
{
console.log("This is a 401 - calling the unauthorized callback.");
Client.unauthorizedCallback();
}
throw (exception); throw (exception);
} }
@ -46,6 +53,11 @@ class Client
return this.qController; return this.qController;
} }
static setUnauthorizedCallback(unauthorizedCallback: () => void)
{
Client.unauthorizedCallback = unauthorizedCallback;
}
} }
export default Client; export default Client;

View File

@ -1,6 +1,11 @@
package com.kingsrook.qqq.materialdashboard.lib; package com.kingsrook.qqq.materialdashboard.lib;
import java.io.File;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import com.kingsrook.qqq.backend.core.utils.CollectionUtils;
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin; import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
import io.github.bonigarcia.wdm.WebDriverManager; import io.github.bonigarcia.wdm.WebDriverManager;
import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.AfterEach;
@ -11,6 +16,7 @@ import org.openqa.selenium.Dimension;
import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebDriver;
import org.openqa.selenium.chrome.ChromeDriver; import org.openqa.selenium.chrome.ChromeDriver;
import org.openqa.selenium.chrome.ChromeOptions; import org.openqa.selenium.chrome.ChromeOptions;
import static org.junit.jupiter.api.Assertions.fail;
/******************************************************************************* /*******************************************************************************
@ -54,7 +60,15 @@ public class QBaseSeleniumTest
@BeforeEach @BeforeEach
public void beforeEach() public void beforeEach()
{ {
manageDownloadsDirectory();
HashMap<String, Object> chromePrefs = new HashMap<>();
chromePrefs.put("profile.default_content_settings.popups", 0);
chromePrefs.put("download.default_directory", getDownloadsDirectory());
chromeOptions.setExperimentalOption("prefs", chromePrefs);
driver = new ChromeDriver(chromeOptions); driver = new ChromeDriver(chromeOptions);
driver.manage().window().setSize(new Dimension(1700, 1300)); driver.manage().window().setSize(new Dimension(1700, 1300));
qSeleniumLib = new QSeleniumLib(driver); qSeleniumLib = new QSeleniumLib(driver);
@ -68,6 +82,57 @@ public class QBaseSeleniumTest
/*******************************************************************************
**
*******************************************************************************/
private void manageDownloadsDirectory()
{
File downloadsDirectory = new File(getDownloadsDirectory());
if(!downloadsDirectory.exists())
{
if(!downloadsDirectory.mkdir())
{
fail("Could not create downloads directory: " + downloadsDirectory);
}
}
if(!downloadsDirectory.isDirectory())
{
fail("Downloads directory: " + downloadsDirectory + " is not a directory.");
}
for(File file : CollectionUtils.nonNullArray(downloadsDirectory.listFiles()))
{
if(!file.delete())
{
fail("Could not remove a file from the downloads directory: " + file.getAbsolutePath());
}
}
}
/*******************************************************************************
**
*******************************************************************************/
protected String getDownloadsDirectory()
{
return ("/tmp/selenium-downloads");
}
/*******************************************************************************
**
*******************************************************************************/
protected List<File> getDownloadedFiles()
{
File[] downloadedFiles = CollectionUtils.nonNullArray((new File(getDownloadsDirectory())).listFiles());
return (Arrays.stream(downloadedFiles).toList());
}
/******************************************************************************* /*******************************************************************************
** control if the test needs to start its own javalin server, or if we're running ** control if the test needs to start its own javalin server, or if we're running
** in an environment where an external web server is being used. ** in an environment where an external web server is being used.

View File

@ -1,3 +1,24 @@
/*
* 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.materialdashboard.lib; package com.kingsrook.qqq.materialdashboard.lib;
@ -6,11 +27,15 @@ import java.time.Duration;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;
import com.kingsrook.qqq.backend.core.utils.SleepUtils;
import org.apache.commons.io.FileUtils; import org.apache.commons.io.FileUtils;
import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger; import org.apache.logging.log4j.Logger;
import org.openqa.selenium.By; import org.openqa.selenium.By;
import org.openqa.selenium.JavascriptExecutor; import org.openqa.selenium.JavascriptExecutor;
import org.openqa.selenium.NoSuchElementException;
import org.openqa.selenium.OutputType; import org.openqa.selenium.OutputType;
import org.openqa.selenium.StaleElementReferenceException; import org.openqa.selenium.StaleElementReferenceException;
import org.openqa.selenium.WebDriver; import org.openqa.selenium.WebDriver;
@ -36,6 +61,8 @@ public class QSeleniumLib
private boolean SCREENSHOTS_ENABLED = true; private boolean SCREENSHOTS_ENABLED = true;
private String SCREENSHOTS_PATH = "/tmp/QSeleniumScreenshots/"; private String SCREENSHOTS_PATH = "/tmp/QSeleniumScreenshots/";
private boolean autoHighlight = false;
/******************************************************************************* /*******************************************************************************
@ -187,7 +214,13 @@ public class QSeleniumLib
*******************************************************************************/ *******************************************************************************/
public WebElement waitForSelector(String cssSelector) public WebElement waitForSelector(String cssSelector)
{ {
return (waitForSelectorAll(cssSelector, 1).get(0)); WebElement element = waitForSelectorAll(cssSelector, 1).get(0);
Actions actions = new Actions(driver);
actions.moveToElement(element);
conditionallyAutoHighlight(element);
return element;
} }
@ -230,7 +263,7 @@ public class QSeleniumLib
do do
{ {
List<WebElement> elements = driver.findElements(By.cssSelector(cssSelector)); List<WebElement> elements = driver.findElements(By.cssSelector(cssSelector));
if(elements.size() == 0) if(elements.isEmpty())
{ {
LOG.debug("Found non-existence of element(s) matching selector [" + cssSelector + "]"); LOG.debug("Found non-existence of element(s) matching selector [" + cssSelector + "]");
return; return;
@ -256,7 +289,7 @@ public class QSeleniumLib
do do
{ {
List<WebElement> elements = driver.findElements(By.cssSelector(cssSelector)); List<WebElement> elements = driver.findElements(By.cssSelector(cssSelector));
if(elements.size() == 0) if(elements.isEmpty())
{ {
LOG.debug("Found non-existence of element(s) matching selector [" + cssSelector + "]"); LOG.debug("Found non-existence of element(s) matching selector [" + cssSelector + "]");
return; return;
@ -330,6 +363,22 @@ public class QSeleniumLib
/*******************************************************************************
**
*******************************************************************************/
private void soonUnhighlightElement(WebElement element)
{
CompletableFuture.supplyAsync(() ->
{
SleepUtils.sleep(2, TimeUnit.SECONDS);
JavascriptExecutor js = (JavascriptExecutor) driver;
js.executeScript("arguments[0].setAttribute('style', 'background: unset; border: unset;');", element);
return (true);
});
}
/******************************************************************************* /*******************************************************************************
** **
*******************************************************************************/ *******************************************************************************/
@ -380,7 +429,10 @@ public class QSeleniumLib
@FunctionalInterface @FunctionalInterface
public interface Code<T> public interface Code<T>
{ {
public T run(); /*******************************************************************************
**
*******************************************************************************/
T run();
} }
@ -430,6 +482,7 @@ public class QSeleniumLib
LOG.debug("Found element matching selector [" + cssSelector + "] containing text [" + textContains + "]."); LOG.debug("Found element matching selector [" + cssSelector + "] containing text [" + textContains + "].");
Actions actions = new Actions(driver); Actions actions = new Actions(driver);
actions.moveToElement(element); actions.moveToElement(element);
conditionallyAutoHighlight(element);
return (element); return (element);
} }
} }
@ -437,6 +490,10 @@ public class QSeleniumLib
{ {
LOG.debug("Caught a StaleElementReferenceException - will retry."); LOG.debug("Caught a StaleElementReferenceException - will retry.");
} }
catch(NoSuchElementException nsee)
{
LOG.debug("Caught a NoSuchElementException - will retry.");
}
} }
sleepABit(); sleepABit();
@ -449,6 +506,20 @@ public class QSeleniumLib
/*******************************************************************************
**
*******************************************************************************/
private void conditionallyAutoHighlight(WebElement element)
{
if(autoHighlight && System.getenv("CIRCLECI") == null)
{
highlightElement(element);
soonUnhighlightElement(element);
}
}
/******************************************************************************* /*******************************************************************************
** Take a screenshot, putting it in the SCREENSHOTS_PATH, with a subdirectory ** Take a screenshot, putting it in the SCREENSHOTS_PATH, with a subdirectory
** for the test class simple name, filename = methodName.png. ** for the test class simple name, filename = methodName.png.
@ -478,7 +549,8 @@ public class QSeleniumLib
destFile.mkdirs(); destFile.mkdirs();
if(destFile.exists()) if(destFile.exists())
{ {
destFile.delete(); String newFileName = destFile.getAbsolutePath().replaceFirst("\\.png", "-" + System.currentTimeMillis() + ".png");
destFile.renameTo(new File(newFileName));
} }
FileUtils.moveFile(outputFile, destFile); FileUtils.moveFile(outputFile, destFile);
LOG.info("Made screenshot at: " + destFile); LOG.info("Made screenshot at: " + destFile);
@ -555,4 +627,48 @@ public class QSeleniumLib
} }
} }
/*******************************************************************************
**
*******************************************************************************/
public String getLatestChromeDownloadedFileInfo()
{
driver.get("chrome://downloads/");
JavascriptExecutor js = (JavascriptExecutor) driver;
WebElement element = (WebElement) js.executeScript("return document.querySelector('downloads-manager').shadowRoot.querySelector('#mainContainer > iron-list > downloads-item').shadowRoot.querySelector('#content')");
return (element.getText());
}
/*******************************************************************************
** Getter for autoHighlight
*******************************************************************************/
public boolean getAutoHighlight()
{
return (this.autoHighlight);
}
/*******************************************************************************
** Setter for autoHighlight
*******************************************************************************/
public void setAutoHighlight(boolean autoHighlight)
{
this.autoHighlight = autoHighlight;
}
/*******************************************************************************
** Fluent setter for autoHighlight
*******************************************************************************/
public QSeleniumLib withAutoHighlight(boolean autoHighlight)
{
this.autoHighlight = autoHighlight;
return (this);
}
} }

View File

@ -29,7 +29,9 @@ import static org.junit.jupiter.api.Assertions.assertTrue;
/******************************************************************************* /*******************************************************************************
** Test for Associated Record Scripts functionality. ** Test that goes to a record, clicks a link for another record, then
** hits 'e' on keyboard to edit the second record - and confirms that we're
** on the edit url for the second record, not the first (a former bug).
*******************************************************************************/ *******************************************************************************/
public class ClickLinkOnRecordThenEditShortcutTest extends QBaseSeleniumTest public class ClickLinkOnRecordThenEditShortcutTest extends QBaseSeleniumTest
{ {

View File

@ -0,0 +1,111 @@
/*
* 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/>.
*/
package com.kingsrook.qqq.materialdashboard.tests;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import com.kingsrook.qqq.materialdashboard.lib.QBaseSeleniumTest;
import com.kingsrook.qqq.materialdashboard.lib.javalin.QSeleniumJavalin;
import org.apache.commons.io.FileUtils;
import org.junit.jupiter.api.Test;
import org.openqa.selenium.By;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
/*******************************************************************************
** Tests for dashboard table widget with export button
*******************************************************************************/
public class DashboardTableWidgetExportTest extends QBaseSeleniumTest
{
/*******************************************************************************
**
*******************************************************************************/
@Override
protected void addJavalinRoutes(QSeleniumJavalin qSeleniumJavalin)
{
super.addJavalinRoutes(qSeleniumJavalin);
qSeleniumJavalin
.withRouteToFile("/data/person/count", "data/person/count.json")
.withRouteToFile("/data/city/count", "data/city/count.json");
qSeleniumJavalin.withRouteToString("/widget/SampleTableWidget", """
{
"label": "Sample Table Widget",
"footerHTML": "<span class='material-icons-round notranslate MuiIcon-root MuiIcon-fontSizeInherit' aria-hidden='true'><span class='dashboard-schedule-icon'>schedule</span></span>Updated at 2023-10-17 09:11:38 AM CDT",
"columns": [
{ "type": "html", "header": "Id", "accessor": "id", "width": "1%" },
{ "type": "html", "header": "Name", "accessor": "name", "width": "99%" }
],
"rows": [
{ "id": "1", "name": "<a href='/setup/person/1'>Homer S.</a>" },
{ "id": "2", "name": "<a href='/setup/person/2'>Marge B.</a>" },
{ "id": "3", "name": "<a href='/setup/person/3'>Bart J.</a>" }
],
"type": "table"
}
""");
}
/*******************************************************************************
**
*******************************************************************************/
@Test
void testDashboardTableWidgetExport() throws IOException
{
qSeleniumLib.gotoAndWaitForBreadcrumbHeader("/", "Greetings App");
////////////////////////////////////////////////////////////////////////
// assert that the table widget rendered its header and some contents //
////////////////////////////////////////////////////////////////////////
qSeleniumLib.waitForSelectorContaining("#SampleTableWidget h6", "Sample Table Widget");
qSeleniumLib.waitForSelectorContaining("#SampleTableWidget table a", "Homer S.");
qSeleniumLib.waitForSelectorContaining("#SampleTableWidget div", "Updated at 2023-10-17 09:11:38 AM CDT");
/////////////////////////////
// click the export button //
/////////////////////////////
qSeleniumLib.waitForSelector("#SampleTableWidget h6")
.findElement(By.xpath("./.."))
.findElement(By.cssSelector("button"))
.click();
qSeleniumLib.waitForCondition("Should have downloaded 1 file", () -> getDownloadedFiles().size() == 1);
File csvFile = getDownloadedFiles().get(0);
assertThat(csvFile.getName()).matches("Sample Table Widget.*.csv");
String fileContents = FileUtils.readFileToString(csvFile, StandardCharsets.UTF_8);
assertEquals("""
"Id","Name"
"1","Homer S."
"2","Marge B."
"3","Bart J."
""", fileContents);
// qSeleniumLib.waitForever();
}
}

View File

@ -302,8 +302,7 @@
"label": "Greetings App", "label": "Greetings App",
"iconName": "emoji_people", "iconName": "emoji_people",
"widgets": [ "widgets": [
"PersonsByCreateDateBarChart", "SampleTableWidget"
"QuickSightChartRenderer"
], ],
"children": [ "children": [
{ {
@ -738,139 +737,11 @@
"icon": "/kr-icon.png" "icon": "/kr-icon.png"
}, },
"widgets": { "widgets": {
"parcelTrackingDetails": { "SampleTableWidget": {
"name": "parcelTrackingDetails", "name": "SampleTableWidget",
"label": "Tracking Details", "label": "Sample Table Widget",
"type": "childRecordList"
},
"deposcoSalesOrderLineItems": {
"name": "deposcoSalesOrderLineItems",
"label": "Line Items",
"type": "childRecordList"
},
"TotalShipmentsByDayBarChart": {
"name": "TotalShipmentsByDayBarChart",
"label": "Total Shipments By Day",
"type": "chart"
},
"TotalShipmentsByMonthLineChart": {
"name": "TotalShipmentsByMonthLineChart",
"label": "Total Shipments By Month",
"type": "chart"
},
"YTDShipmentsByCarrierPieChart": {
"name": "YTDShipmentsByCarrierPieChart",
"label": "Shipments By Carrier Year To Date",
"type": "chart"
},
"TodaysShipmentsStatisticsCard": {
"name": "TodaysShipmentsStatisticsCard",
"label": "Today's Shipments",
"type": "statistics"
},
"ShipmentsInTransitStatisticsCard": {
"name": "ShipmentsInTransitStatisticsCard",
"label": "Shipments In Transit",
"type": "statistics"
},
"OpenOrdersStatisticsCard": {
"name": "OpenOrdersStatisticsCard",
"label": "Open Orders",
"type": "statistics"
},
"ShippingExceptionsStatisticsCard": {
"name": "ShippingExceptionsStatisticsCard",
"label": "Shipping Exceptions",
"type": "statistics"
},
"WarehouseLocationCards": {
"name": "WarehouseLocationCards",
"type": "location"
},
"TotalShipmentsStatisticsCard": {
"name": "TotalShipmentsStatisticsCard",
"label": "Total Shipments",
"type": "statistics"
},
"SuccessfulDeliveriesStatisticsCard": {
"name": "SuccessfulDeliveriesStatisticsCard",
"label": "Successful Deliveries",
"type": "statistics"
},
"ServiceFailuresStatisticsCard": {
"name": "ServiceFailuresStatisticsCard",
"label": "Service Failures",
"type": "statistics"
},
"CarrierVolumeLineChart": {
"name": "CarrierVolumeLineChart",
"label": "Carrier Volume By Month",
"type": "lineChart"
},
"YTDSpendByCarrierTable": {
"name": "YTDSpendByCarrierTable",
"label": "Spend By Carrier Year To Date",
"type": "table"
},
"TimeInTransitBarChart": {
"name": "TimeInTransitBarChart",
"label": "Time In Transit Last 30 Days",
"type": "chart"
},
"OpenBillingWorksheetsTable": {
"name": "OpenBillingWorksheetsTable",
"label": "Open Billing Worksheets",
"type": "table"
},
"AssociatedParcelInvoicesTable": {
"name": "AssociatedParcelInvoicesTable",
"label": "Associated Parcel Invoices",
"type": "table", "type": "table",
"icon": "receipt" "showExportButton": true
},
"BillingWorksheetLinesTable": {
"name": "BillingWorksheetLinesTable",
"label": "Billing Worksheet Lines",
"type": "table"
},
"RatingIssuesWidget": {
"name": "RatingIssuesWidget",
"label": "Rating Issues",
"type": "html",
"icon": "warning",
"gridColumns": 6
},
"UnassignedParcelInvoicesTable": {
"name": "UnassignedParcelInvoicesTable",
"label": "Unassigned Parcel Invoices",
"type": "table"
},
"ParcelInvoiceSummaryWidget": {
"name": "ParcelInvoiceSummaryWidget",
"label": "Parcel Invoice Summary",
"type": "multiStatistics"
},
"ParcelInvoiceLineExceptionsSummaryWidget": {
"name": "ParcelInvoiceLineExceptionsSummaryWidget",
"label": "Parcel Invoice Line Exceptions",
"type": "multiStatistics"
},
"BillingWorksheetStatusStepper": {
"name": "BillingWorksheetStatusStepper",
"label": "Billing Worksheet Progress",
"type": "stepper",
"icon": "refresh",
"gridColumns": 6
},
"PersonsByCreateDateBarChart": {
"name": "PersonsByCreateDateBarChart",
"label": "Persons By Create Date",
"type": "barChart"
},
"QuickSightChartRenderer": {
"name": "QuickSightChartRenderer",
"label": "Quick Sight",
"type": "quickSightChart"
}, },
"scriptViewer": { "scriptViewer": {
"name": "scriptViewer", "name": "scriptViewer",