@onehat/ui
Version:
Base UI for OneHat apps
1,561 lines (1,449 loc) • 60.5 kB
JavaScript
import { useState, useEffect, useRef, useMemo, useCallback, createRef, } from 'react';
import {
Box,
FlatList,
HStack,
Pressable,
// ScrollView,
Text,
VStack,
VStackNative,
} from '@project-components/Gluestack';
import clsx from 'clsx';
import {
ScrollView,
} from 'react-native';
import {
SELECTION_MODE_SINGLE,
SELECTION_MODE_MULTI,
} from '../../Constants/Selection.js';
import {
EDITOR_TYPE__SIDE,
} from '../../Constants/Editor.js';
import {
EDIT,
VIEW,
} from '../../Constants/Commands.js';
import {
DROP_POSITION_BEFORE,
DROP_POSITION_AFTER,
} from '../../Constants/Grid.js';
import {
UI_MODE_WEB,
UI_MODE_NATIVE,
CURRENT_MODE,
} from '../../Constants/UiModes.js';
import {
hasHeight,
hasWidth,
hasFlex,
} from '../../Functions/tailwindFunctions.js';
import * as yup from 'yup'; // https://github.com/jquense/yup#string
import { EDITOR_TYPE__PLAIN } from '../../Constants/Editor.js';
import UiGlobals from '../../UiGlobals.js';
import useForceUpdate from '../../Hooks/useForceUpdate.js';
import withContextMenu from '../Hoc/withContextMenu.js';
import withAlert from '../Hoc/withAlert.js';
import withComponent from '../Hoc/withComponent.js';
import withData from '../Hoc/withData.js';
import { withDropTarget } from '../Hoc/withDnd.js';
import withEvents from '../Hoc/withEvents.js';
import withFilters from '../Hoc/withFilters.js';
import withInlineEditor from '../Hoc/withInlineEditor.js';
import withPermissions from '../Hoc/withPermissions.js';
import withPresetButtons from '../Hoc/withPresetButtons.js';
import withMultiSelection from '../Hoc/withMultiSelection.js';
import withSelection from '../Hoc/withSelection.js';
import withSideEditor from '../Hoc/withSideEditor.js';
import withWindowedEditor from '../Hoc/withWindowedEditor.js';
import Form from '../Form/Form.js';
import getSaved from '../../Functions/getSaved.js';
import setSaved from '../../Functions/setSaved.js';
import getIconButtonFromConfig from '../../Functions/getIconButtonFromConfig.js';
import testProps from '../../Functions/testProps.js';
import gsToHex from '../../Functions/gsToHex.js';
import Loading from '../Messages/Loading.js';
import isSerializable from '../../Functions/isSerializable.js';
import inArray from '../../Functions/inArray.js';
import ReloadButton from '../Buttons/ReloadButton.js';
import CheckboxButton from '../Buttons/CheckboxButton.js';
import GridHeaderRow from './GridHeaderRow.js';
import GridRow, { DragSourceDropTargetGridRow, DragSourceGridRow, DropTargetGridRow } from './GridRow.js';
import Button from '../Buttons/Button.js';
import ExpandButton from '../Buttons/ExpandButton.js';
import IconButton from '../Buttons/IconButton.js';
import PaginationToolbar from '../Toolbar/PaginationToolbar.js';
import NoRecordsFound from './NoRecordsFound.js';
import Toolbar from '../Toolbar/Toolbar.js';
import NoReorderRows from '../Icons/NoReorderRows.js';
import ReorderRows from '../Icons/ReorderRows.js';
import Unauthorized from '../Messages/Unauthorized.js';
import Mask from '../Panel/Mask.js';
import _ from 'lodash';
// This fn gets called many times per component
// First call
// !isInited
// render a placeholder, to get container dimensions
// onInitialLayout()
// set initial pageSize
// setIsInited(true)
// Second call
// !isReady
// set selectorSelected
// load Repo
// Third call
// isReady
// render Grid,
// subsequent calls due to changes of selectorSelected
// re-apply selectorSelected
// subsequent calls due to changes changes in onLayout
// adjust pageSize if needed
// TODO: account for various environments (mainly for optimization):
// RN vs web
// Repository vs data
const
SINGLE_CLICK = 1,
DOUBLE_CLICK = 2,
TRIPLE_CLICK = 3,
PHASES__INITIAL = 'initial',
PHASES__MEASURING = 'measuring',
PHASES__OPTIMIZED = 'optimized',
DEBUG = false;
function GridComponent(props) {
const {
columnsConfig = [], // json configurations for each column
columnProps = {},
defaultHiddenColumns = [],
getRowProps = (item) => {
let className = clsx(
'border-bottom-1',
'border-bottom-grey-500',
);
if (CURRENT_MODE === UI_MODE_NATIVE) {
className += ' py-4';
}
return {
className,
};
},
flatListProps = {},
onRowPress,
onRender,
onLayout,
disableLoadOnRender = false,
forceLoadOnRender = false,
pullToRefresh = true,
hideNavColumn = true,
noneFoundText,
autoAdjustPageSizeToHeight = true,
showRowExpander = false,
getExpandedRowContent,
showHeaders = true,
showHovers = true,
showSelectHandle = true,
isRowSelectable = true,
isRowHoverable = true,
isDisabled = false,
canColumnsSort = true,
canColumnsReorder = true,
canColumnsResize = true,
areCellsScrollable = true,
allowToggleSelection = false, // i.e. single click with no shift key toggles the selection of the item clicked on
disableBottomToolbar = false,
disablePagination = false,
bottomToolbar = 'pagination',
_paginationToolbarProps = {},
topToolbar = null,
additionalToolbarButtons = [],
bg = '#fff',
canRecordBeEdited,
alternateRowBackgrounds = true,
alternatingInterval = 2,
defaultRowHeight = 48,
getRowTestId,
// The selectorSelected mechanism allows us to filter results of the primary model, (e.g. WorkOrders)
// by the selection on the secondary model (e.g. Equipment). It's used on Grids, Trees, Forms, etc.
// The 'selectorId' is the name of the primary model's filter (e.g. 'WorkOrders.equipment_id').
// which gets submitted to the server as a condition (e.g. 'conditions[WorkOrders.equipment_id]').
// The 'selectorSelected' is the Entity on the secondary model which is selected (e.g. Equipment).
// The 'selectorSelectedField' is the field on the secondary model to use as the value for the filter
// (e.g. 'fleet_id'). If not given, it defaults to 'id'.
// It can be disabled altogether for a specific grid ('disableSelectorSelected'), and configured
// so that no selection means no results ('noSelectorMeansNoResults').
selectorId,
selectorSelected,
selectorSelectedField = 'id',
noSelectorMeansNoResults = false,
disableSelectorSelected = false,
// DND
canRowsReorder = false,
canRowDrag, // optional fn to customize whether each row can be dragged
canRowAcceptDrop, // optional fn to customize whether each node can accept a dropped item: (targetItem, draggedItem) => boolean
dragProxyField,
getCustomDragProxy = (item, selection) => { // optional fn to render custom drag preview: (item, selection) => ReactElement
let selectionCount = selection?.length || 1,
displayText = dragProxyField ? item[dragProxyField] : (item.displayValue || 'Selected Row');
return <VStack className="bg-white border border-gray-300 rounded-lg p-3 shadow-lg max-w-[200px]">
<Text className="font-semibold text-gray-800">{displayText}</Text>
{selectionCount > 1 &&
<Text className="text-sm text-gray-600">(+{selectionCount -1} more item{selectionCount > 2 ? 's' : ''})</Text>
}
</VStack>;
},
dragPreviewOptions, // optional object for drag preview positioning options
areRowsDragSource = false,
rowDragSourceType,
getRowDragSourceItem,
areRowsDropTarget = false,
dropTargetAccept,
onRowDrop,
onDragStart,
onDragEnd,
// withComponent
self,
// withModal
showModal,
hideModal,
// withEditor
onAdd,
onEdit,
onDelete,
onView,
onContextMenu,
isAdding,
isEditorViewOnly,
getIsEditorShown,
editorType,
// withData
Repository,
data,
fields,
idField,
displayField,
idIx,
displayIx,
// withPermissions
canUser,
// withDnd
isDropTarget,
canDrop,
isOver,
dropTargetRef,
// withPresetButtons
onChangeColumnsConfig,
// withSelection
disableWithSelection,
selection,
setSelection,
selectionMode,
removeFromSelection,
addToSelection,
deselectAll,
selectRangeTo,
isInSelection,
selectNext,
selectPrev,
addNextToSelection,
addPrevToSelection,
// withInlineEditor
inlineEditor = null,
isInlineEditorShown = false,
onEditorRowClick,
} = props,
styles = UiGlobals.styles,
id = props.id || props.self?.path,
entities = Repository ? (Repository.isRemote ? Repository.entities : Repository.getEntitiesOnPage()) : data,
localColumnsConfigKey = id && id + '-localColumnsConfig',
[hasUnserializableColumns] = useState(() => {
return !isSerializable(columnsConfig); // (runs only once, when the component is first created)
}),
forceUpdate = useForceUpdate(),
isAddingRaw = useRef(),
measurementPhaseRaw = useRef(PHASES__INITIAL),
measuredRowHeightRaw = useRef(null),
containerRef = useRef(),
gridRef = useRef(),
gridContainerRef = useRef(),
expandedRowsRef = useRef({}),
cachedDragElements = useRef(),
dragSelectionRef = useRef([]),
previousSelectorId = useRef(),
headerRowRef = useRef(null),
topToolbarRef = useRef(null),
measuredRowsRef = useRef([]),
footerToolbarRef = useRef(null),
rowRefs = useRef([]),
previousEntitiesLength = useRef(0),
hasRemeasuredAfterRowsAppeared = useRef(false),
[isInited, setIsInited] = useState(false),
[isReady, setIsReady] = useState(false),
[isLoading, setIsLoading] = useState(false),
[localColumnsConfig, setLocalColumnsConfigRaw] = useState([]),
[isReorderMode, setIsReorderMode] = useState(false),
showRowHandle = showSelectHandle || areRowsDragSource || (canRowsReorder && isReorderMode),
[lastMeasuredContainerHeight, setLastMeasuredContainerHeight] = useState(0),
getMeasurementPhase = () => {
return measurementPhaseRaw.current;
},
setMeasurementPhase = (phase) => {
measurementPhaseRaw.current = phase;
forceUpdate();
},
getMeasuredRowHeight = () => {
return measuredRowHeightRaw.current;
},
setMeasuredRowHeight = (height) => {
measuredRowHeightRaw.current = height;
forceUpdate();
},
getIsExpanded = (index) => {
return !!expandedRowsRef.current[index];
},
setIsExpanded = (index, isExpanded) => {
expandedRowsRef.current[index] = isExpanded;
forceUpdate();
},
setLocalColumnsConfig = (config) => {
if (localColumnsConfigKey) {
const localConfig = [...config]; // clone it so we don't alter the original
if (hasUnserializableColumns) {
// just save the data needed to later reconstruct the columns
const usedIds = [];
_.each(localConfig, (column, ix) => {
if (!column.id || inArray(column.id, usedIds)) {
throw Error('When using unserializable columns, each column must have a unique id. ' + localColumnsConfigKey);
}
usedIds.push(column.id);
localConfig[ix] = {
id: column.id,
isHidden: !!column.isHidden,
};
if (column.w) {
localConfig[ix].w = column.w;
}
});
}
setSaved(localColumnsConfigKey, localConfig);
}
setLocalColumnsConfigRaw(config);
if (onChangeColumnsConfig) {
onChangeColumnsConfig(config);
}
},
onRowClick = (item, e) => {
if (isInlineEditorShown) {
return;
}
if (onRowPress) {
onRowPress(item, e);
}
if (disableWithSelection) {
return;
}
const {
shiftKey = false,
metaKey = false,
} = e;
let allowToggle = allowToggleSelection;
if (metaKey) {
allowToggle = true;
}
if (selectionMode === SELECTION_MODE_MULTI) {
if (shiftKey) {
if (isInSelection(item)) {
removeFromSelection(item);
} else {
selectRangeTo(item);
}
} else {
if (allowToggle) {
if (isInSelection(item)) {
removeFromSelection(item);
} else {
addToSelection(item);
}
} else {
if (!isInSelection(item)) {
// select just this one
setSelection([item]);
}
}
}
} else {
// selectionMode is SELECTION_MODE_SINGLE
let newSelection = selection;
if (isInSelection(item)) {
// Already selected
if (allowToggle) {
// Create empty selection
newSelection = [];
} else {
// Do nothing.
}
} else {
// Select it alone
newSelection = [item];
}
if (newSelection) {
setSelection(newSelection);
}
}
},
onRefresh = () => {
if (!Repository) {
return;
}
const promise = Repository.reload();
if (promise) { // Some repository types don't use promises
promise.then(() => {
setIsLoading(false);
forceUpdate();
});
}
},
getFooterToolbarItems = () => {
const items = _.map(additionalToolbarButtons, (config, ix) => getIconButtonFromConfig(config, ix, self));
if (canRowsReorder && CURRENT_MODE === UI_MODE_WEB) { // DND is currently web-only TODO: implement for RN
items.unshift(<IconButton
{...testProps('reorderBtn')}
key="reorderBtn"
parent={self}
reference="reorderBtn"
onPress={() => setIsReorderMode(!isReorderMode)}
icon={isReorderMode ? NoReorderRows : ReorderRows}
_icon={{
className: styles.TOOLBAR_ITEMS_COLOR,
}}
tooltip="Reorder Rows"
/>);
}
return items;
},
renderRow = (row) => {
if (row.item.isDestroyed) {
return null;
}
if (row.item.id === 'editor') {
return inlineEditor;
}
let {
item,
index,
} = row,
isHeaderRow = row.item.id === 'headerRow',
rowProps = getRowProps && !isHeaderRow ? getRowProps(item) : {},
isSelected = !isHeaderRow && !disableWithSelection && isInSelection(item);
let rowComponent =
<Pressable
dataSet={{ ix: index }}
{...testProps(getRowTestId ? getRowTestId(row) : ((Repository ? Repository.schema.name : 'GridRow') + '-' + item?.id))}
onPress={(e) => {
if (e.preventDefault && e.cancelable) {
e.preventDefault();
}
if (isHeaderRow || isReorderMode) {
return
}
if (CURRENT_MODE === UI_MODE_WEB) {
switch (e.detail) {
case SINGLE_CLICK:
onRowClick(item, e); // sets selection
if (onEditorRowClick) {
onEditorRowClick(item, index, e);
}
break;
case DOUBLE_CLICK:
if (!isSelected) { // If a row was already selected when double-clicked, the first click will deselect it,
onRowClick(item, e); // so reselect it
}
if (UiGlobals.doubleClickingGridRowOpensEditorInViewMode) { // global setting
if (onView) {
if (canUser && !canUser(VIEW)) { // permissions
return;
}
onView(!props.isEditorViewOnly);
}
} else {
let canDoEdit = false,
canDoView = false;
if (onEdit && canUser && canUser(EDIT) && (!canRecordBeEdited || canRecordBeEdited(selection)) && !props.disableEdit && !isEditorViewOnly) {
canDoEdit = true;
} else
if (onView && canUser && canUser(VIEW) && !props.disableView) {
canDoView = true;
}
if (canDoEdit) {
onEdit();
} else if (canDoView) {
onView();
}
}
break;
case TRIPLE_CLICK:
break;
default:
}
} else if (CURRENT_MODE === UI_MODE_NATIVE) {
onRowClick(item, e); // sets selection
if (onEditorRowClick) {
onEditorRowClick(item, index, e);
}
}
}}
onLongPress={(e) => {
if (e.preventDefault && e.cancelable) {
e.preventDefault();
}
if (isHeaderRow || isReorderMode) {
return
}
if (selection && selection[0] && selection[0].isRemotePhantom) {
return; // block context menu or changing selection when a remote phantom is already selected
}
// context menu
const newSelection = [item];
if (!disableWithSelection) {
setSelection(newSelection);
}
if (onEditorRowClick) { // e.g. inline editor
onEditorRowClick(item, index, e);
}
if (onContextMenu) {
onContextMenu(item, e, newSelection);
}
}}
onContextMenu={(e) => {
// web only; happens before onLongPress triggers
// different behavior here than onLongPress:
// if user clicks on a header row or phantom record, or if onContextMenu is not set, pass to the browser's context menu
if (isHeaderRow || isReorderMode) {
return
}
if (selection && selection[0] && selection[0].isRemotePhantom) {
return; // block context menu or changing selection when a remote phantom is already selected
}
if (onContextMenu) {
e.preventDefault();
e.stopPropagation(); // disallow browser's default behavior for context menu
// if the right-clicked item is not in the current selection,
// set the selection only to this one item.
let newSelection = selection;
if (!isInSelection(item)) {
newSelection = [item];
if (!disableWithSelection) {
setSelection(newSelection);
}
}
onContextMenu(item, e, newSelection);
}
}}
className={clsx(
'Pressable',
'Row',
'flex-row',
'grow',
'max-h-[80px]',
)}
>
{({
hovered,
focused,
pressed,
}) => {
if (isHeaderRow) {
let headerRow = <GridHeaderRow
ref={headerRowRef}
Repository={Repository}
columnsConfig={localColumnsConfig}
setColumnsConfig={setLocalColumnsConfig}
hideNavColumn={hideNavColumn}
canColumnsSort={canColumnsSort}
canColumnsReorder={canColumnsReorder}
canColumnsResize={canColumnsResize}
setSelection={setSelection}
gridRef={gridRef}
isHovered={hovered}
isInlineEditorShown={isInlineEditorShown}
areRowsDragSource={areRowsDragSource}
showColumnsSelector={showColumnsSelector}
showRowHandle={showRowHandle}
/>;
if (showRowExpander) {
// align the header row to content rows by adding a spacer that matches the width of the Grid-rowExpander-expandBtn
headerRow = <HStack className="">
<Box className="w-[40px]"></Box>
{headerRow}
</HStack>;
}
return headerRow;
}
const
rowReorderProps = {},
rowDragProps = {};
let WhichRow = GridRow,
rowCanSelect = true,
rowCanDrag = false;
if (CURRENT_MODE === UI_MODE_WEB) { // DND is currently web-only TODO: implement for RN
// Create a method that gets an always-current copy of the selection ids
dragSelectionRef.current = selection;
const getSelection = () => dragSelectionRef.current;
const userHasPermissionToDrag = (!canUser || canUser(EDIT));
if (userHasPermissionToDrag) {
// assign event handlers
if (canRowsReorder && isReorderMode) {
WhichRow = DragSourceGridRow;
rowReorderProps.isDragSource = true;
rowReorderProps.dragSourceType = 'row';
const dragIx = showHeaders ? index - 1 : index;
rowReorderProps.dragSourceItem = {
id: item.id,
getSelection,
isInSelection,
sourceComponentRef: gridRef, // Reference to the originating component
onDrag: (dragState) => {
onRowReorderDrag(dragState, dragIx);
},
};
rowReorderProps.onDragEnd = onRowReorderEnd;
rowCanDrag = true;
} else {
// Don't allow drag/drop from withDnd while reordering
if (areRowsDragSource && (!canRowDrag || canRowDrag(item))) {
WhichRow = DragSourceGridRow;
rowDragProps.isDragSource = true;
rowDragProps.dragSourceType = rowDragSourceType;
if (getRowDragSourceItem) {
rowDragProps.dragSourceItem = getRowDragSourceItem(item, getSelection, isInSelection, rowDragSourceType);
// Ensure all drag items have a component reference
if (!rowDragProps.dragSourceItem.sourceComponentRef) {
rowDragProps.dragSourceItem.sourceComponentRef = gridRef;
}
} else {
rowDragProps.dragSourceItem = {
id: item.id,
item,
getSelection,
isInSelection,
type: rowDragSourceType,
sourceComponentRef: gridRef, // Reference to the originating component
};
}
rowDragProps.dragSourceItem.onDragStart = () => {
if (!isInSelection(item)) { // get updated isSelected (will be stale if using one in closure)
// reset the selection to just this one node if it's not already selected
setSelection([item]);
}
if (onDragStart) {
onDragStart(item, rowDragProps.dragSourceItem);
}
};
if (canRowDrag) {
rowDragProps.canDrag = () => canRowDrag(item, rowDragProps.dragSourceItem);
}
// Add custom drag preview options
if (dragPreviewOptions) {
rowDragProps.dragPreviewOptions = dragPreviewOptions;
}
// Add onDragEnd callback
if (onDragEnd) {
rowDragProps.onDragEnd = onDragEnd;
}
// Add drag preview rendering
rowDragProps.getDragProxy = getCustomDragProxy ?
(dragItem) => getCustomDragProxy(item, getSelection()) :
null; // Let GlobalDragProxy handle the default case
rowCanDrag = true;
}
if (areRowsDropTarget) {
WhichRow = DropTargetGridRow;
rowDragProps.isDropTarget = true;
rowDragProps.dropTargetAccept = dropTargetAccept;
rowDragProps.onDrop = (droppedItem) => {
// NOTE: item is sometimes getting destroyed, but it still as the id, so you can still use it
onRowDrop(item, droppedItem); // item is what it was dropped on; droppedItem is the dragSourceItem defined above
};
rowDragProps.canDrop = (droppedItem, monitor) => {
// Check if the drop operation would be valid based on business rules
if (canRowAcceptDrop && typeof canRowAcceptDrop === 'function') {
return canRowAcceptDrop(item, droppedItem);
}
// Default: allow all drops
return true;
};
}
if (areRowsDragSource && areRowsDropTarget) {
WhichRow = DragSourceDropTargetGridRow;
}
}
}
}
// assign ref for row height measurement during measurement phase
let rowRef = null;
if (autoAdjustPageSizeToHeight && getMeasurementPhase() === PHASES__MEASURING &&
!isHeaderRow && index >= 1) { // Sample all data rows (index 1+)
const refIndex = index - 1; // Convert to 0-based index
// Create ref if it doesn't exist
if (!rowRefs.current[refIndex]) {
rowRefs.current[refIndex] = createRef();
}
rowRef = rowRefs.current[refIndex];
if (rowRef && !measuredRowsRef.current.includes(rowRef)) {
measuredRowsRef.current.push(rowRef);
}
}
return <WhichRow
ref={rowRef}
columnsConfig={localColumnsConfig}
columnProps={columnProps}
fields={fields}
rowProps={rowProps}
hideNavColumn={hideNavColumn}
isRowSelectable={isRowSelectable}
isRowHoverable={isRowHoverable}
isSelected={isSelected}
isHovered={hovered}
areCellsScrollable={areCellsScrollable}
showHovers={showHovers}
showRowHandle={showRowHandle}
rowCanSelect={rowCanSelect}
rowCanDrag={rowCanDrag}
index={index}
alternatingInterval={alternatingInterval}
alternateRowBackgrounds={alternateRowBackgrounds}
bg={bg}
item={item}
isInlineEditorShown={isInlineEditorShown}
{...rowReorderProps}
{...rowDragProps}
key1={item.id}
/>;
}}
</Pressable>;
if (showRowExpander && !isHeaderRow) {
const isExpanded = getIsExpanded(index);
let className = clsx(
'Grid-rowExpander',
'w-full',
'flex-none',
);
if (rowProps?.className) {
className += ' ' + rowProps.className;
}
rowComponent = <VStack className={className}>
<HStack
className={clsx(
'Grid-rowExpander-HStack',
'w-full',
'grow',
)}
>
<ExpandButton
{...testProps((Repository ? Repository.schema.name : 'GridRow') + '-expandBtn-' + item?.id)}
isExpanded={isExpanded}
onToggle={() => setIsExpanded(index, !isExpanded)}
_icon={{
size: 'sm',
}}
className={clsx(
'Grid-rowExpander-expandBtn',
styles.GRID_EXPAND_BTN_CLASSNAME,
)}
tooltip="Expand/Contract Row"
/>
{rowComponent}
</HStack>
{isExpanded ? getExpandedRowContent(row) : null}
</VStack>
}
return rowComponent;
},
getOverState = (rows, currentY, mouseX) => {
// determines which row the mouse is over
// and whether the marker should be moved to the top or bottom of the row
let newIx = -1,
useBottom = false;
_.each(rows, (row, ix) => {
const
rect = row.getBoundingClientRect(),
{
top,
bottom,
height,
} = rect,
isOver = (
mouseX >= rect.left &&
mouseX <= rect.right &&
currentY >= rect.top &&
currentY <= rect.bottom
);
if (isOver) {
newIx = ix;
const
halfHeight = height / 2,
isOverTopHalf = currentY < top + halfHeight;
useBottom = !isOverTopHalf;
return false;
}
});
return {
ix: newIx,
useBottom,
};
},
buildCachedDragElements = (dragState) => {
const {
canDrag,
isDragging,
clientOffset,
sourceClientOffset,
} = dragState,
scrollRef = gridRef.current._listRef._scrollRef,
isUsingScroll = scrollRef.childNodes[0].style.overflow === 'auto',
flatlist = isUsingScroll ? scrollRef.childNodes[0] : scrollRef,
flatlistRect = flatlist.getBoundingClientRect(),
rows = _.filter(flatlist.childNodes, (childNode, ix) => {
const
isZeroHeight = childNode.getBoundingClientRect().height === 0,
isHeader = showHeaders && ix === 0;
return !isZeroHeight && !isHeader;
}),
{ ix, useBottom } = getOverState(rows, clientOffset.y, clientOffset.x);
// Render marker showing destination location
const marker = document.createElement('div');
marker.style.position = 'absolute';
marker.style.top = '0px';
marker.style.height = '8px';
marker.style.width = flatlistRect.width + 'px';
marker.style.backgroundColor = '#ccc';
flatlist.appendChild(marker);
if (ix !== -1) {
marker.style.visibility = 'visible';
const
rowContainerRect = rows[ix].getBoundingClientRect(),
top = (useBottom ? rowContainerRect.bottom : rowContainerRect.top)
- flatlistRect.top
- (flatlist.style.borderWidth ? parseInt(flatlist.style.borderWidth) : 0); // get relative Y position
marker.style.top = top + 'px';
} else {
marker.style.visibility = 'hidden';
}
return { ix, useBottom, marker, rows };
},
onRowReorderDrag = (dragState, dragIx) => {
// initial setup
if (!cachedDragElements.current) {
cachedDragElements.current = buildCachedDragElements(dragState);
}
const {
canDrag,
isDragging,
clientOffset,
sourceClientOffset,
} = dragState,
{ marker, rows, } = cachedDragElements.current,
flatlist = gridRef.current._listRef._scrollRef.childNodes[0],
flatlistRect = flatlist.getBoundingClientRect(),
{ ix, useBottom } = getOverState(rows, clientOffset.y, clientOffset.x);
// move marker to new location
if (ix !== -1) {
marker.style.visibility = 'visible';
const
rowContainerRect = rows[ix].getBoundingClientRect(),
top = (useBottom ? rowContainerRect.bottom : rowContainerRect.top)
- flatlistRect.top
- (flatlist.style.borderWidth ? parseInt(flatlist.style.borderWidth) : 0); // get relative Y position
marker.style.top = top + 'px';
} else {
marker.style.visibility = 'hidden';
}
cachedDragElements.current = { ix, useBottom, marker, rows, dragIx };
},
onRowReorderEnd = (item, monitor) => {
const
{ ix: dropIx, useBottom, marker, rows, dragIx } = cachedDragElements.current,
shouldMove = dropIx !== dragIx;
if (shouldMove && dropIx !== -1) {
// Update the row with the new ix
let dragRecord,
dropRecord;
if (Repository) {
if (!Repository.isDestroyed) {
dragRecord = Repository.getByIx(dragIx);
dropRecord = Repository.getByIx(dropIx);
if (dropRecord) {
Repository.reorder(dragRecord, dropRecord, useBottom ? DROP_POSITION_AFTER : DROP_POSITION_BEFORE);
}
}
} else {
function arrayMove(arr, fromIndex, toIndex) {
var element = arr[fromIndex];
arr.splice(fromIndex, 1);
arr.splice(toIndex, 0, element);
}
arrayMove(data, compensatedDragIx, finalDropIx);
}
}
marker.remove();
cachedDragElements.current = null;
},
calculatePageSize = (containerHeight, useActualMeasurements = false) => {
if (DEBUG) {
console.log(`${getMeasurementPhase()}, calculatePageSize A containerHeight=${containerHeight}, useActualMeasurements=${useActualMeasurements}, measuredRowHeight=${getMeasuredRowHeight()}`);
}
// Phase 1: Initial calculation using estimated heights
if (!useActualMeasurements || getMeasurementPhase() === PHASES__INITIAL) {
const
headerRowHeight = showHeaders ? 50 : 0,
topToolbarHeight = topToolbar ? 50 : 0, // Estimate top toolbar height
footerHeight = !disablePagination ? 50 : 0,
availableHeight = containerHeight - topToolbarHeight - headerRowHeight - footerHeight,
maxClassNormal = styles.GRID_ROW_MAX_HEIGHT_NORMAL, // e.g. max-h-[40px]
rowNormalHeight = parseInt(maxClassNormal.match(/\d+/)[0]);
let pageSize = Math.floor(availableHeight / rowNormalHeight);
// Add 20% buffer for better measurement accuracy
pageSize = Math.floor(pageSize * 1.2);
if (pageSize < 1) {
pageSize = 1;
}
if (DEBUG) {
console.log(`${getMeasurementPhase()}, calculatePageSize B using ESTIMATED heights, pageSize=${pageSize}`);
}
return pageSize;
}
// Phase 3: Optimized calculation using actual measurements
if (useActualMeasurements && getMeasurementPhase() === PHASES__OPTIMIZED && getMeasuredRowHeight()) {
let actualTopToolbarHeight = 0,
actualHeaderHeight = 0,
actualFooterHeight = 0,
actualRowHeight = getMeasuredRowHeight();
if (topToolbar && topToolbarRef.current) {
actualTopToolbarHeight = topToolbarRef.current.offsetHeight || topToolbarRef.current.clientHeight || 50;
}
if (showHeaders && headerRowRef.current) {
actualHeaderHeight = headerRowRef.current.offsetHeight || headerRowRef.current.clientHeight || 50;
}
if (!disablePagination && footerToolbarRef.current) {
actualFooterHeight = footerToolbarRef.current.offsetHeight || footerToolbarRef.current.clientHeight || 50;
}
const availableHeight = containerHeight - actualTopToolbarHeight - actualHeaderHeight - actualFooterHeight;
let pageSize = Math.floor(availableHeight / actualRowHeight);
if (pageSize < 1) {
pageSize = 1;
}
if (DEBUG) {
console.log(`${getMeasurementPhase()}, calculatePageSize C using ACTUAL heights, pageSize=${pageSize}`);
}
return pageSize;
}
// Fallback to Phase 1 logic
if (DEBUG) {
console.log(`${getMeasurementPhase()}, calculatePageSize D fallback to ESTIMATED heights by calling calculatePageSize(${containerHeight}, false)`);
}
return calculatePageSize(containerHeight, false);
},
measureActualRowHeights = () => {
if (!gridContainerRef.current) {
return null;
}
if (DEBUG) {
console.log(`${getMeasurementPhase()}, measureActualRowHeights A`);
}
const measuredRows = measuredRowsRef.current.filter(ref => ref && ref.current);
if (measuredRows.length === 0) {
if (DEBUG) {
console.log(`${getMeasurementPhase()}, measureActualRowHeights B no rows to measure`);
}
return null;
}
let totalHeight = 0;
let measuredCount = 0;
if (CURRENT_MODE === UI_MODE_WEB) {
// Web: Use DOM measurement APIs
_.each(measuredRows, (rowRef) => {
if (rowRef.current) {
const height = rowRef.current.offsetHeight || rowRef.current.clientHeight;
if (height > 0) {
totalHeight += height;
measuredCount++;
}
}
});
} else if (CURRENT_MODE === UI_MODE_NATIVE) {
// React Native: Use measure API with promises
return new Promise((resolve) => {
let completed = 0;
const measurements = [];
_.each(measuredRows, (rowRef) => {
if (rowRef.current && rowRef.current.measure) {
rowRef.current.measure((x, y, width, height) => {
if (height > 0) {
measurements.push(height);
}
completed++;
if (completed === measuredRows.length) {
if (measurements.length > 0) {
const averageHeight = measurements.reduce((sum, h) => sum + h, 0) / measurements.length;
if (DEBUG) {
console.log(`[Grid] Measured actual row height: ${averageHeight}px from ${measurements.length} measured rows`);
}
// Clear measured refs for next measurement cycle
measuredRowsRef.current = [];
resolve(averageHeight);
} else {
resolve(null);
}
}
});
} else {
completed++;
if (completed === measuredRows.length && measurements.length === 0) {
resolve(null);
}
}
});
// Timeout fallback
setTimeout(() => {
if (measurements.length > 0) {
const averageHeight = measurements.reduce((sum, h) => sum + h, 0) / measurements.length;
if (DEBUG) {
log(`[Grid] Measured actual row height (timeout): ${averageHeight}px from ${measurements.length} measured rows`);
}
measuredRowsRef.current = [];
resolve(averageHeight);
} else {
resolve(null);
}
}, 200);
});
}
if (measuredCount > 0) {
const averageHeight = totalHeight / measuredCount;
if (DEBUG) {
console.log(`${getMeasurementPhase()}, measureActualRowHeights C averageHeight=${averageHeight}, measuredCount=${measuredCount}`);
}
// Clear measured refs for next measurement cycle
measuredRowsRef.current = [];
return averageHeight;
}
if (DEBUG) {
console.log(`${getMeasurementPhase()}, measureActualRowHeights D measuredCount=0`);
}
return null;
},
applyMeasuredRowHeight = (averageHeight) => {
if (DEBUG) {
console.log(`${getMeasurementPhase()}, applyMeasuredRowHeight A averageHeight=${averageHeight}, lastMeasuredContainerHeight=${lastMeasuredContainerHeight}, setMeasurementPhase(${PHASES__OPTIMIZED})`);
}
// Always transition to optimized phase, even if measurement failed
setMeasurementPhase(PHASES__OPTIMIZED);
if (averageHeight) {
setMeasuredRowHeight(averageHeight);
// Recalculate pageSize with actual measurements
if (lastMeasuredContainerHeight > 0) {
if (DEBUG) {
console.log(`${getMeasurementPhase()}, applyMeasuredRowHeight B call calculatePageSize(${lastMeasuredContainerHeight}, true)`);
}
const newPageSize = calculatePageSize(lastMeasuredContainerHeight, true);
if (newPageSize !== Repository.pageSize) {
if (DEBUG) {
console.log(`${getMeasurementPhase()}, applyMeasuredRowHeight B Repository.setPageSize(${newPageSize})`);
}
Repository.setPageSize(newPageSize);
}
}
} else {
if (DEBUG) {
console.log(`[Grid] Row height measurement failed or unavailable - using estimated pageSize`);
}
// Keep the current estimated pageSize, just hide the loading overlay
}
},
adjustPageSizeToHeight = (containerHeight, forceRemeasure = false) => {
if (!Repository || Repository.isDestroyed) { // This method gets delayed, so it's possible for Repository to have been destroyed. Check for this
return;
}
if (DEBUG) {
console.log(`${getMeasurementPhase()}, adjustPageSizeToHeight A forceRemeasure=${forceRemeasure}`);
}
let doAdjustment = autoAdjustPageSizeToHeight;
if (!_.isNil(UiGlobals.autoAdjustPageSizeToHeight) && !UiGlobals.autoAdjustPageSizeToHeight) {
// allow global override to prevent this auto adjustment
doAdjustment = false;
}
if (DEBUG) {
console.log(`${getMeasurementPhase()}, adjustPageSizeToHeight A2 doAdjustment=${doAdjustment}, autoAdjustPageSizeToHeight=${autoAdjustPageSizeToHeight}, UiGlobals.autoAdjustPageSizeToHeight=${UiGlobals.autoAdjustPageSizeToHeight}, containerHeight=${containerHeight}`);
}
// Only proceed if height changed significantly or forced
const
heightChanged = Math.abs(containerHeight - lastMeasuredContainerHeight) > 5, // 5px tolerance
isFirstMeasurement = lastMeasuredContainerHeight === 0;
if (containerHeight > 0 && (isFirstMeasurement || heightChanged || forceRemeasure)) {
if (editorType === EDITOR_TYPE__SIDE && getIsEditorShown()) {
// When side editor is shown, skip adjustment to avoid layout thrashing
console.log(`${getMeasurementPhase()}, adjustPageSizeToHeight A4 height changed significantly, but side editor is shown, skipping remeasurement`);
return;
}
if (DEBUG) {
console.log(`${getMeasurementPhase()}, adjustPageSizeToHeight A4 height changed significantly, proceeding with remeasurement`);
}
if (doAdjustment) {
setLastMeasuredContainerHeight(containerHeight);
// Phase 1: Initial calculation with buffer
if (DEBUG) {
console.log(`${getMeasurementPhase()}, adjustPageSizeToHeight B call calculatePageSize(${containerHeight}, false)`);
}
const
useActualMeasurements = (getMeasurementPhase() === PHASES__OPTIMIZED && getMeasuredRowHeight()),
pageSize = calculatePageSize(containerHeight, useActualMeasurements);
if (DEBUG) {
console.log(`${getMeasurementPhase()}, adjustPageSizeToHeight C containerHeight=${containerHeight}, pageSize=${pageSize}, currentPageSize=${Repository.pageSize}`);
}
if (pageSize !== Repository.pageSize) {
if (DEBUG) {
console.log(`${getMeasurementPhase()}, adjustPageSizeToHeight D Repository.setPageSize(${pageSize})`);
}
Repository.setPageSize(pageSize);
}
}
// Trigger Phase 2: Enable measurement mode after render
if (getMeasurementPhase() === PHASES__INITIAL) {
if (DEBUG) {
console.log(`${getMeasurementPhase()}, adjustPageSizeToHeight E setMeasurementPhase(${PHASES__MEASURING})`);
}
setMeasurementPhase(PHASES__MEASURING);
}
} else {
if (DEBUG) {
console.log(`${getMeasurementPhase()}, adjustPageSizeToHeight A5 height unchanged, skipping remeasurement`);
}
}
},
debouncedAdjustPageSizeToHeight = useCallback(_.debounce(adjustPageSizeToHeight, 200), []),
applySelectorSelected = () => {
if (disableSelectorSelected || !selectorId) {
return
}
if (previousSelectorId.current && selectorId !== previousSelectorId.current) {
Repository.pauseEvents();
Repository.clearFilters(previousSelectorId.current);
Repository.resumeEvents();
}
previousSelectorId.current = selectorId;
let value = null;
if (selectorSelected) {
value = selectorSelected[selectorSelectedField];
}
if (noSelectorMeansNoResults && _.isEmpty(selectorSelected)) {
value = 'NO_MATCHES';
}
Repository.filter(selectorId, value, false); // false so it doesn't clear existing filters
},
onGridKeyDown = (e) => {
if (isInlineEditorShown) {
return;
}
if (disableWithSelection) {
return;
}
const {
shiftKey = false,
} = e;
if (selectionMode === SELECTION_MODE_MULTI && shiftKey) {
switch(e.key) {
case 'ArrowDown':
e.preventDefault();
addNextToSelection();
break;
case 'ArrowUp':
e.preventDefault();
addPrevToSelection();
break;
}
} else {
// selectionMode is SELECTION_MODE_SINGLE
switch(e.key) {
case 'Enter':
// NOTE: This is never being reached.
// The event is getting captured somwhere else,
// but I can't find where.
// e.preventDefault();
// launch inline or windowed editor
// const p = props;
// debugger;
break;
case 'ArrowDown':
e.preventDefault();
selectNext();
break;
case 'ArrowUp':
e.preventDefault();
selectPrev();
break;
}
}
},
showColumnsSelector = () => {
const
modalItems = _.map(localColumnsConfig, (config, ix) => {
return {
name: config.id,
label: config.header,
type: config.isHidable ? 'Checkbox' : 'Text',
isEditable: config.isHidable ?? false,
};
}),
startingValues = (() => {
const startingValues = {};
_.each(localColumnsConfig, (config) => {
const value = !config.isHidden; // checkbox implies to show it, so flip the polarity
startingValues[config.id] = config.isHidable ? value : 'Always shown';
});
return startingValues;
})();
showModal({
title: 'Column Selector',
includeReset: true,
includeCancel: true,
h: 800,
w: styles.FORM_STACK_ROW_THRESHOLD + 10,
body: <Form
editorType={EDITOR_TYPE__PLAIN}
columnDefaults={{
labelWidth: '250px',
}}
items={[
{
name: 'instructions',
type: 'DisplayField',
text: 'Please select which columns to show in the grid.',
className: 'mb-3',
},
{
type: 'FieldSet',
title: 'Columns',
reference: 'columns',
showToggleAllCheckbox: true,
items: [
...modalItems,
],
}
]}
startingValues={startingValues}
onSave={(values)=> {
hideModal();
const newColumnsConfig = _.cloneDeep(localColumnsConfig);
_.each(newColumnsConfig, (config, ix) => {
if (config.isHidable) {
newColumnsConfig[ix].isHidden = !values[config.id]; // checkbox implies to show it, so flip the polarity
}
});
setLocalColumnsConfig(newColumnsConfig);
}}
/>,
});
};
if (forceLoadOnRender && disableLoadOnRender) {
throw new Error('incompatible config! forceLoadOnRender and disableLoadOnRender cannot both be true');
}
useEffect(() => {
if (!isInited) {
// first call, Repository.pauseEvents, while we render placeholder so we get container dimensions
if (Repository) {
if (Repository.isRemote) {
Repository.isAutoLoad = false;
}
if (DEBUG) {
console.log(`${getMeasurementPhase()}, useEffect 1 - first call, Repository.pauseEvents, while we render placeholder to get container dimensions`);
}
Repository.pauseEvents();
}
return () => {};
}
(async () => {
if (DEBUG) {
console.log(`${getMeasurementPhase()}, useEffect 1 - second call, do other necessary column setup`);
}
// second call, do other necessary column setup
let columnsConfigVariable = columnsConfig,
localColumnsConfig = [],
savedLocalColumnsConfig,
calculateLocalColumnsConfig = false;
if (localColumnsConfigKey && !UiGlobals.disableSavedColumnsConfig) {
savedLocalColumnsConfig = await getSaved(localColumnsConfigKey);
}
if (!savedLocalColumnsConfig || hasUnserializableColumns) {
calculateLocalColumnsConfig = true;
}
if (calculateLocalColumnsConfig) {
if (_.isFunction(columnsConfigVariable)) {
columnsConfigVariable = columnsConfigVariable();
}
if (_.isEmpty(columnsConfigVariable)) {
if (Repository) {
// create a column for the displayProperty
localColumnsConfig.push({
fieldName: Repository.schema.model.displayProperty,
});
} else {
localColumnsConfig.push({
fieldName: displayField,
});
}
} else {
_.each(columnsConfigVariable, (columnConfig) => {
if (!_.isPlainObject(columnConfig)) {
localColumnsConfig.push(columnConfig);
return;
}
const
defaults = {
isEditable: false,
isReorderable: true,
isResizable: true,
isSortable: true,
isHidden: inArray(columnConfig.id, defaultHiddenColumns),
isHidable: true,
isOver: false,
},
config = _.assign({}, defaults, columnConfig);
if (!(config.w || config.width) && !config.flex) {
// Neither is set
config.w = 100; // default
} else if (config.flex && (config.w || config.width)) {
// Both are set. Width overrules flex.
delete config.flex;
}
localColumnsConfig.push(config);
});
}
}
if (savedLocalColumnsConfig) {
if (!hasUnserializableColumns) {
// just use the saved config without any further processing
localColumnsConfig = savedLocalColumnsConfig;
} else {
// Conform the calculated localColumnsConfig to the saved config.
// This should allow us to continue using non-serializable configurations after a refresh
const reconstructedLocalColumnsConfig = savedLocalColumnsConfig
.map((savedConfig) => {
const columnConfig = localColumnsConfig.find(localConfig => localConfig.id === savedConfig.id);
if (!columnConfig) {
console.warn(`Column with id '${savedConfig.id}' not found in current config, skipping`);
return null; // Return null for missing columns
}
_.assign(columnConfig, savedConfig);
return columnConfig;
})
.filter(Boolean); // Remove null entries
localColumnsConfig = reconstructedLocalColumnsConfig;
}
}
setLocalColumnsConfig(localColumnsConfig);
if (onRender) {
onRender(self)
}
setIsReady(true);
})();
if (!Repository) {
return () => {};
}
// set up @onehat/data repository
const
setTrue = () => setIsLoading(true),
setFalse = () => setIsLoading(false),
onChangeFilters = () => {
if (DEBUG) {
console.log('onChangeFilters, reload and re-measure');
}
if (!Repository.isAutoLoad) {
Repository.reload();
}
},
onChangeSorters = () => {
if (!Repository.isAutoLoad) {
Repository.reload();
}
},
onChangePage = () => {
if (showRowExpander) {
expandedRowsRef.current = {}; // clear expanded rows
}
};
Repository.on('beforeLoad', setTrue);
Repository.on('load', setFalse);
if (!disableWithSelection) {
Repository.ons(['changePage', 'changePageSize',], deselectAll);
}
Repository.ons(['changeData', 'change'], forceUpdate);
Repository.on('changeFilters', onChangeFilters);
Repository.on('changeSorters', onChangeSorters);
Repository.on('changePage', onChangePage);
applySelectorSelected();
if (DEBUG) {
console.log(`${getMeasurementPhase()}, useEffect 1 - Repository.resumeEvents()`);
}
Repository.resumeEvents();
if (((Repository.isRemote && !Repository.isLoaded && !Repository.isLoading) || forceLoadOnRender) && !disableLoadOnRender) { // default remote repositories to load on render, optionally force or disable load on render
if (DEBUG) {
console.log(`${getMeasurementPhase()}, useEffect 1 - Repository.load()`);
}
Repository.load();
}
return () => {
Repository.off('beforeLoad', setTrue);
Repository.off('load', setFalse);
if (!disableWithSelection) {
Repository.offs(['changePage', 'changePageSize',], deselectAll);
}
Repository.offs(['changeData', 'change'], forceUpdate);
Repository.off('changeFilters', onChangeFilters);
Repository.off('changeSorters', onChangeSorters);
Repository.off('changePage', onChangePage);
};
}, [isInited]);
useEffect(() => {
if (!Repository || !isReady) {
return () => {};
}
if (DEBUG) {
console.log(`useEffect 2 - applySelectorSelected()`);
}
applySelectorSelected();
}, [selectorId, selectorSelected]);
// Effect to trigger row height measurement after render
useEffect(() => {
if (getMeasurementPhase() === PHASES__MEASURING) {
// Small delay to ensure elements are fully rendered
const timer = setTimeout(async () => {
try {
if (DEBUG) {
console.log(`${getMeasurementPhase()}, useEffect 3 call measureActualRowHeights()`);
}
const averageHeight = await measureActualRowHeights();
if (DEBUG) {
console.log(`${getMeasurementPhase()}, useEffect 3 averageHeight=${averageHeight}, call applyMeasuredRowHeight()`);
}
applyMeasuredRowHeight(averageHeight);
} catch (error) {
if (DEBUG) {
console.warn('useEffect 3 - error', error);
}
// Fallback: use default height estimation
applyMeasuredRowHeight(null);
}
}, 1000);
return () => clearTimeout(timer);
}
}, [getMeasurementPhase(), data]);
useEffect(() => {
if (autoAdjustPageSizeToHeight && getMeasurementPhase() !== PHASES__INITIAL) {
if (DEBUG) {
console.log(`${getMeasurementPhase()}, useEffect 4 setMeasurementPhase(${PHASES__INITIAL})`);
}
setMeasurementPhase(PHASES__INITIAL);
setMeasuredRowHeight(null);
measuredRowsRef.current = [];
}
}, [autoAdjustPageSizeToHeight]);
// Reset measurement when rows were first empty then became populated
u