@onehat/ui
Version:
Base UI for OneHat apps
510 lines (480 loc) • 14.7 kB
JavaScript
import { useMemo, useState, useEffect, forwardRef, isValidElement, cloneElement, } from 'react';
import {
Box,
HStack,
HStackNative,
Icon,
Text,
TextNative,
} from '@project-components/Gluestack';
import clsx from 'clsx';
import {
UI_MODE_WEB,
UI_MODE_NATIVE,
CURRENT_MODE,
} from '../../Constants/UiModes.js';
import * as colourMixer from '@k-renwick/colour-mixer';
import Tooltip from '../Tooltip/Tooltip.js';
import getComponentFromType from '../../Functions/getComponentFromType.js';
import UiGlobals from '../../UiGlobals.js';
import { withDragSource, withDropTarget } from '../Hoc/withDnd.js';
import testProps from '../../Functions/testProps.js';
import Loading from '../Messages/Loading.js';
import AngleRight from '../Icons/AngleRight.js';
import RowHandle from './RowHandle.js';
import useAsyncRenderers from './useAsyncRenderers.js';
import _ from 'lodash';
// Conditional import for web only
let getEmptyImage = null;
// This was broken out from Grid simply so we can memoize it
const GridRow = forwardRef((props, ref) => {
const {
columnsConfig,
columnProps,
fields,
rowProps,
hideNavColumn,
showRowHandle,
areCellsScrollable,
rowCanSelect,
rowCanDrag,
isRowHoverable,
isSelected,
isHovered,
bg,
showHovers,
index,
alternatingInterval,
alternateRowBackgrounds,
item,
isInlineEditorShown,
isDraggable = false, // withDraggable
isDragSource = false, // withDnd
isOver = false, // drop target
canDrop,
draggedItem,
validateDrop, // same as canDrop (for visual feedback)
getDragProxy,
dragSourceRef,
dragPreviewRef,
dropTargetRef,
...propsToPass
} = props,
styles = UiGlobals.styles,
{
results: asyncResults,
loading: asyncLoading,
} = useAsyncRenderers(columnsConfig, item);
if (item.isDestroyed) {
return null;
}
// Hide the default drag preview only when using custom drag proxy (and only on web)
useEffect(() => {
if (dragPreviewRef && typeof dragPreviewRef === 'function' && getDragProxy && CURRENT_MODE === UI_MODE_WEB) {
// Load getEmptyImage dynamically and apply it
import('react-dnd-html5-backend').then((module) => {
const getEmptyImage = module.getEmptyImage;
if (getEmptyImage) {
dragPreviewRef(getEmptyImage(), { captureDraggingState: true });
}
}).catch((error) => {
console.warn('Failed to load react-dnd-html5-backend:', error);
});
}
}, [dragPreviewRef, getDragProxy]);
const
isPhantom = item.isPhantom,
hash = item?.hash || item;
return useMemo(() => {
let bg = rowProps.bg || props.bg || styles.GRID_ROW_BG,
mixWith;
// TODO: Finish Drop styling
// Use custom validation for enhanced visual feedback, fallback to React DnD's canDrop
let actualCanDrop = canDrop;
if (isOver && draggedItem && validateDrop) {
actualCanDrop = validateDrop(draggedItem);
}
if (isSelected) {
if (showHovers && isHovered) {
mixWith = styles.GRID_ROW_SELECTED_BG_HOVER;
} else {
mixWith = styles.GRID_ROW_SELECTED_BG;
}
} else if (isRowHoverable && showHovers && isHovered) {
mixWith = styles.GRID_ROW_BG_HOVER;
} else if (alternateRowBackgrounds && index % alternatingInterval === 0) { // i.e. every second line, or every third line
mixWith = styles.GRID_ROW_ALTERNATE_BG;
}
if (mixWith) {
// const
// mixWithObj = gsToHex(mixWith),
// ratio = mixWithObj.alpha ? 1 - mixWithObj.alpha : 0.5;
// bg = colourMixer.blend(bg, ratio, mixWithObj.color);
bg = colourMixer.blend(bg, 0.5, mixWith);
}
const
visibleColumns = _.filter(columnsConfig, (config) => !config.isHidden),
isOnlyOneVisibleColumn = visibleColumns.length === 1;
const renderColumns = (item) => {
if (_.isArray(columnsConfig)) {
return _.map(columnsConfig, (config, key, all) => {
if (config.isHidden) {
// every element needs a key, so return an empty element with a key
return <Box key={key} style={{ display: 'none' }} />;
}
const
propsToPass = columnProps[key] || {},
colStyle = {},
whichCursor = showRowHandle ? 'cursor-text' : 'cursor-pointer'; // when using rowSelectHandle, indicate that the row text is selectable, otherwise indicate that the row itself is selectable
let colClassName = clsx(
'GridRow-column',
'p-1',
'justify-center',
'border-r-black-100',
'block',
areCellsScrollable ? 'overflow-auto' : 'overflow-hidden',
whichCursor,
styles.GRID_ROW_MAX_HEIGHT_EXTRA,
);
if (isOnlyOneVisibleColumn) {
colClassName = ' w-full';
} else {
if (config.w) {
colStyle.width = config.w;
} else if (config.flex) {
colStyle.flex = config.flex;
colClassName = ' min-w-[100px]';
} else {
colClassName = ' flex-1';
}
}
if (isInlineEditorShown) {
colClassName += ' ' + styles.INLINE_EDITOR_MIN_WIDTH;
}
let value;
if (_.isFunction(config)) {
const element = config(item, key);
if (isValidElement(element)) {
// ensure element has a key
return element.key != null ? element : cloneElement(element, { key });
}
return <Box key={key}>{element}</Box>; // create a box (with key) to wrap non-elements
}
if (_.isPlainObject(config)) {
if (config.renderer) {
const
asyncResult = asyncResults.get(key),
isLoading = asyncLoading.has(key),
extraProps = _.omit(config, [
'header',
'fieldName',
'type',
'isEditable',
'editor',
'format',
'renderer',
'isAsync',
'isReorderable',
'isResizable',
'isSortable',
'w',
'flex',
'isOver',
]);
if (!extraProps._web) {
extraProps._web = {};
}
if (!extraProps._web.style) {
extraProps._web.style = {};
}
extraProps._web.style = {
// userSelect: 'none',
};
let content = null;
let textClassName = clsx(
'GridRow-TextNative',
'self-center',
areCellsScrollable ? 'overflow-auto' : 'overflow-hidden',
colClassName,
styles.GRID_CELL_CLASSNAME,
styles.GRID_ROW_MAX_HEIGHT_EXTRA,
);
if (config.className) {
textClassName += ' ' + config.className;
}
const rendererProps = {
...testProps('rendererCol-' + (config.fieldName || config.id || key)),
className: textClassName,
...propsToPass,
...extraProps,
style: colStyle,
};
if (config.isAsync) {
// TODO: Figure out how to pass the rendererProps to the async renderer function
throw Error('Not yet working correctly!');
// Async renderer
if (isLoading) {
content = <Loading key={key} />;
} else if (asyncResult) {
if (asyncResult.error) {
content = <Text key={key}>Render Error: {asyncResult.error.message || String(asyncResult.error)}</Text>;
} else {
content = asyncResult.result;
}
}
} else {
// Synchronous renderer
try {
const result = config.renderer(item, config.fieldName, rendererProps, key);
if (result && typeof result.then === 'function') {
content = <Text key={key}>Error: Async renderer not properly configured</Text>;
} else {
content = result;
}
} catch (error) {
content = <Text key={key}>Render Error: {error}</Text>;
}
}
// Ensure content has a key prop
if (isValidElement(content)) {
// ensure element has a key
return content.key != null ? content : cloneElement(content, { key });
}
return <Box key={key}>{content}</Box>; // create a box (with key) to wrap non-elements
}
if (config.fieldName) {
if (item?.properties && item.properties[config.fieldName]) {
const
property = item.properties[config.fieldName],
type = property?.viewerType?.type;
value = property.displayValue;
if (type) {
const Element = getComponentFromType(type);
const elementProps = {};
if (type.match(/(Tag|TagEditor|Json)$/)) {
elementProps.isViewOnly = true; // TODO: this won't work for InlineGridEditor, bc that Grid can't use isViewOnly when actually editing
}
let cellProps = {};
if (config.getCellProps) {
_.assign(cellProps, config.getCellProps(item));
}
let elementClassName = clsx(
'GridRow-Element',
'self-center',
'text-ellipsis',
'px-2',
'py-3',
'block',
areCellsScrollable ? 'overflow-auto' : 'overflow-hidden',
'[&::-webkit-scrollbar]:h-2',
'[&::-webkit-scrollbar-thumb]:bg-gray-300',
'[&::-webkit-scrollbar-thumb]:rounded-full',
colClassName,
styles.GRID_CELL_CLASSNAME,
styles.GRID_ROW_MAX_HEIGHT_NORMAL,
);
if (config.className) {
elementClassName += ' ' + config.className;
}
if (rowProps?._cell?.className) {
elementClassName += ' ' + rowProps._cell.className;
}
if (cellProps.className) {
elementClassName += ' ' + cellProps.className;
}
if (type.match(/(Tag|TagEditor)$/)) {
elementClassName += ' ' + styles.GRID_ROW_MAX_HEIGHT_EXTRA;
}
return <Element
{...testProps('cell-' + config.fieldName)}
value={value}
key={key}
overflow="hidden"
style={{
// userSelect: 'none',
...colStyle,
}}
minimizeForRow={true}
className={elementClassName}
numberOfLines={1}
ellipsizeMode="head"
{...propsToPass}
{...elementProps}
/>;
}
} else if (item[config.fieldName]) {
value = item[config.fieldName];
} else if (fields) {
const ix = fields.indexOf(config.fieldName);
value = item[ix];
}
}
}
if (_.isString(config)) {
if (fields) {
const ix = fields.indexOf(config);
value = item[ix];
} else {
value = item[config];
}
}
if (_.isFunction(value)) {
const result = value(key);
// Ensure the result has a key prop
if (isValidElement(result)) {
// Only clone if the result doesn't already have a key
return result.key != null ? result : cloneElement(result, { key });
}
return <Box key={key}>{result}</Box>;
}
const elementProps = {};
if (config.getCellProps) {
_.assign(elementProps, config.getCellProps(item));
}
// TODO: incorporate better scrollbar formatting with
// tailwind plugin 'tailwind-scrollbar' (already installed, just not yet used here)
let textClassName = clsx(
'GridRow-TextNative',
'self-center',
areCellsScrollable ? 'overflow-auto' : 'overflow-hidden',
'[&::-webkit-scrollbar]:h-2',
'[&::-webkit-scrollbar-thumb]:bg-gray-300',
'[&::-webkit-scrollbar-thumb]:rounded-full',
colClassName,
styles.GRID_CELL_CLASSNAME,
styles.GRID_ROW_MAX_HEIGHT_EXTRA,
);
if (rowProps?._cell?.className) {
textClassName += ' ' + rowProps._cell.className;
}
if (config.className) {
textClassName += ' ' + config.className;
}
return <TextNative
{...testProps('cell-' + config.fieldName)}
key={key}
style={{
// userSelect: 'none',
...colStyle,
}}
numberOfLines={1}
ellipsizeMode="head"
className={textClassName}
{...elementProps}
{...propsToPass}
>{value}</TextNative>;
});
} else {
// TODO: if 'columnsConfig' is an object, parse its contents
throw new Error('Non-array columnsConfig not yet supported');
}
};
let rowContents = <>
{showRowHandle &&
<RowHandle
ref={dragSourceRef}
isDragSource={isDragSource}
isDraggable={isDraggable}
canSelect={rowCanSelect}
canDrag={rowCanDrag}
/>}
{isPhantom &&
<Box
className={clsx(
'GridRow-phantom',
'absolute',
'h-2',
'w-2',
'top-0',
'left-0',
'bg-[#f00]',
)}
/>}
{renderColumns(item)}
{!hideNavColumn &&
<Icon
as={AngleRight}
variant="outline"
className={clsx(
'GridRow-Icon',
'w-30',
'self-center',
'mx-3',
styles.GRID_NAV_COLUMN_COLOR,
)}
/>}
</>;
if (dropTargetRef) {
rowContents = <HStack
ref={dropTargetRef}
className={clsx(
'GridRow-dropTargetRef',
'w-full',
'flex-1',
'grow-1',
)}
style={{
backgroundColor: bg,
}}
>{rowContents}</HStack>;
}
let rowClassName = clsx(
'GridRow-HStackNative',
'items-center',
);
if (isOnlyOneVisibleColumn) {
rowClassName += ' w-full';
}
if (rowProps?.className) {
rowClassName += ' ' + rowProps.className;
}
if (isOver) {
rowClassName += ' border-4 border-[#0ff]';
}
let row = <HStackNative
ref={ref}
{...testProps('Row ' + (isSelected ? 'row-selected' : ''))}
{...rowProps}
key={hash}
className={rowClassName}
style={{
backgroundColor: bg,
}}
>{rowContents}</HStackNative>;
if (rowProps.tooltip) {
row = <Tooltip label={rowProps.tooltip} placement="bottom left">{row}</Tooltip>;
}
return row;
}, [
columnsConfig,
asyncResults,
asyncLoading,
columnProps,
fields,
rowProps,
hideNavColumn,
bg,
item,
isPhantom,
hash, // this is an easy way to determine if the data has changed and the item needs to be rerendered
isInlineEditorShown,
isSelected,
isHovered,
isOver,
index,
canDrop,
draggedItem,
validateDrop,
dragSourceRef,
dragPreviewRef,
dropTargetRef,
showRowHandle,
rowCanSelect,
rowCanDrag,
]);
});
// export default withDraggable(withDragSource(withDropTarget(GridRow)));
export default GridRow;
export const DragSourceGridRow = withDragSource(GridRow);
export const DropTargetGridRow = withDropTarget(GridRow);
export const DragSourceDropTargetGridRow = withDragSource(withDropTarget(GridRow));