@onehat/ui
Version:
Base UI for OneHat apps
461 lines (436 loc) • 12.5 kB
JavaScript
import { useMemo, useState, useEffect, } 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 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;
if (CURRENT_MODE === UI_MODE_WEB) {
import('react-dnd-html5-backend').then((module) => {
getEmptyImage = module.getEmptyImage;
}).catch(() => {
getEmptyImage = null;
});
}
// This was broken out from Grid simply so we can memoize it
function GridRow(props) {
const {
columnsConfig,
columnProps,
fields,
rowProps,
hideNavColumn,
showSelectHandle,
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) {
// Only suppress default drag preview when we have a custom one and we're on web
dragPreviewRef(getEmptyImage(), { captureDraggingState: true });
}
}, [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) {
return null;
}
const
propsToPass = columnProps[key] || {},
colStyle = {},
whichCursor = showSelectHandle ? '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',
'overflow-auto',
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)) {
return config(item, key);
}
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',
'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),
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 />;
} 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>;
}
}
return content;
}
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',
'overflow-scroll',
colClassName,
styles.GRID_CELL_CLASSNAME,
styles.GRID_ROW_MAX_HEIGHT_NORMAL,
);
if (config.className) {
elementClassName += ' ' + config.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)) {
return value(key);
}
const elementProps = {};
if (config.getCellProps) {
_.assign(elementProps, config.getCellProps(item));
}
let textClassName = clsx(
'GridRow-TextNative',
'self-center',
'overflow-hidden',
colClassName,
styles.GRID_CELL_CLASSNAME,
styles.GRID_ROW_MAX_HEIGHT_EXTRA,
);
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 = <>
{(isDragSource || isDraggable || showSelectHandle) &&
<RowHandle
ref={dragSourceRef}
isDragSource={isDragSource}
isDraggable={isDraggable}
showSelectHandle={showSelectHandle}
/>}
{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]';
}
return <HStackNative
{...testProps('Row ' + (isSelected ? 'row-selected' : ''))}
{...rowProps}
key={hash}
className={rowClassName}
style={{
backgroundColor: bg,
}}
>{rowContents}</HStackNative>;
}, [
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,
]);
}
// 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));