/* * QQQ - Low-code Application Framework for Engineers. * Copyright (C) 2021-2024. Kingsrook, LLC * 651 N Broad St Ste 205 # 6917 | Middletown DE 19709 | United States * contact@kingsrook.com * https://github.com/Kingsrook/ * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import {QExposedJoin} from "@kingsrook/qqq-frontend-core/lib/model/metaData/QExposedJoin"; 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, {useMemo, 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) => void, omitExposedJoins?: string[] } 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, omitExposedJoins}: 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); const availableExposedJoins = useMemo(() => { const rs: QExposedJoin[] = [] for(let exposedJoin of tableMetaData.exposedJoins ?? []) { if(omitExposedJoins?.indexOf(exposedJoin.joinTable.name) > -1) { continue; } rs.push(exposedJoin); } return (rs); }, [tableMetaData, omitExposedJoins]); ////////////////// // 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 (availableExposedJoins?.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 < availableExposedJoins?.length; i++) { const joinTable = availableExposedJoins[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 | React.MouseEvent | React.MouseEvent, 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) { 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, 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, 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 < availableExposedJoins?.length; i++) { const join = availableExposedJoins[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) { 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 ( <> { heading && {heading} } { searchText != "" && { updateSearch(null); document.getElementById(textFieldId).focus(); }}>close } { fieldsByTableToShow.map((tableWithFields) => { let headerContents = null; const headerTable = tableWithFields.table || tableMetaData; if (tableWithFields.table || showTableHeaderEvenIfNoExposedJoins) { headerContents = ({headerTable.label} Fields); } if (isModeToggle) { headerContents = ( handleTableToggle(event, headerTable)} />} label={{headerTable.label} Fields ({tableToggleCounts[headerTable.name]})} />); } if (isModeToggle) { headerContents = ( <> toggleCollapsedTable(headerTable.name)} sx={{justifyContent: "flex-start", fontSize: "0.875rem", pt: 0.5, pb: 0, mr: "0.25rem"}} disableRipple={true} > {collapsedTables[headerTable.name] ? "expand_less" : "expand_more"} {headerContents} ); } let marginLeft = "unset"; if (isModeToggle) { marginLeft = "-1rem"; } zIndex += 2; return ( <> {headerContents && {headerContents}} { tableWithFields.fields.map((field) => { index++; const key = `${tableWithFields.table?.name}-${field.name}`; if (collapsedTables[headerTable.name]) { return (); } 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 = {label} doHandleAdornmentClick(field, tableWithFields.table, event)}> {fieldEndAdornment} ; } let contents = <>{label}; let paddingLeft = "0.5rem"; if (isModeToggle) { contents = ( handleFieldToggle(event, field, tableWithFields.table)} />} label={label} />); paddingLeft = "2.5rem"; } return handleMouseOver(event, field, tableWithFields.table)} {...onClick} >{contents}; }) } ); }) } { index == -1 && No fields found. } ); }