@onehat/ui
Version:
Base UI for OneHat apps
1,289 lines (1,221 loc) • 32.3 kB
JavaScript
import { forwardRef, useState, useEffect, useRef, } from 'react';
import {
Box,
HStack,
HStackNative,
Icon,
Modal, ModalBackdrop, ModalHeader, ModalContent, ModalCloseButton, ModalBody, ModalFooter,
Popover, PopoverBackdrop, PopoverContent, PopoverBody,
Pressable,
Text,
TextNative,
VStackNative,
} from '@project-components/Gluestack';
import clsx from 'clsx';
import {
CURRENT_MODE,
UI_MODE_NATIVE,
UI_MODE_WEB,
} from '../../../../Constants/UiModes.js';
import {
EDITOR_TYPE__WINDOWED,
} from '../../../../Constants/Editor.js';
import testProps from '../../../../Functions/testProps.js';
import UiGlobals from '../../../../UiGlobals.js';
import Input from '../Input.js';
import { Grid, WindowedGridEditor } from '../../../Grid/Grid.js';
import useForceUpdate from '../../../../Hooks/useForceUpdate.js';
import withAlert from '../../../Hoc/withAlert.js';
import withComponent from '../../../Hoc/withComponent.js';
import withData from '../../../Hoc/withData.js';
import withTooltip from '../../../Hoc/withTooltip.js';
import withValue from '../../../Hoc/withValue.js';
import emptyFn from '../../../../Functions/emptyFn.js';
import IconButton from '../../../Buttons/IconButton.js';
import CaretDown from '../../../Icons/CaretDown.js';
import Check from '../../../Icons/Check.js';
import Xmark from '../../../Icons/Xmark.js';
import Eye from '../../../Icons/Eye.js';
import _ from 'lodash';
const FILTER_NAME = 'q';
/**
* isEmptyValue
* _.isEmpty returns true for all integers, so we need this instead
* @param {*} value
* @returns boolean
*/
function isEmptyValue(value) {
return value === null ||
value === undefined ||
value === '' ||
value === 0 ||
(_.isObject(value) && _.isEmpty(value));
};
function getRowProps() {
return {
className: clsx(
'w-full',
'pl-4',
'pr-2',
'py-1',
'border-b-1',
'border-grey-300',
CURRENT_MODE === UI_MODE_NATIVE ? {
'min-h-[50px]': true,
'h-[50px]': true,
} : {},
),
};
}
export const ComboComponent = forwardRef((props, ref) => {
const {
additionalButtons,
autoFocus = false,
menuMinWidth,
disableDirectEntry = false,
hideMenuOnSelection = true,
showXButton = false,
showEyeButton = false,
viewerProps = {}, // popup for eyeButton
_input = {},
_editor = {},
_grid = {},
isEditor = false,
isDisabled = false,
isInTag = false,
minimizeForRow = false,
reloadOnTrigger = false,
searchHasInitialPercent = false,
menuHeight,
placeholder,
onRowPress,
icon,
Editor, // only used for the eyeButton
onGridAdd, // to hook into when menu adds (ComboEditor only)
onGridSave, // to hook into when menu saves (ComboEditor only)
onGridDelete, // to hook into when menu deletes (ComboEditor only)
onSubmit, // when Combo is used in a Tag, call this when the user submits the Combo value (i.e. presses Enter or clicks a row)
newEntityDisplayProperty,
testID,
// withComponent
self,
// withAlert
alert,
confirm,
// withData
Repository,
data,
idIx,
displayIx,
// withValue
value,
setValue,
} = props,
styles = UiGlobals.styles,
forceUpdate = useForceUpdate(),
inputRef = useRef(),
inputCloneRef = useRef(),
triggerRef = useRef(),
menuRef = useRef(),
displayValueRef = useRef(),
typingTimeout = useRef(),
isMenuShown = useRef(false),
isGridLayoutRunWithRender = useRef(false),
[isMenuAbove, setIsMenuAbove] = useState(false),
[isViewerShown, setIsViewerShown] = useState(false),
[viewerSelection, setViewerSelection] = useState([]),
[isRendered, setIsRendered] = useState(false),
[isReady, setIsReady] = useState(false),
[isSearchMode, setIsSearchMode] = useState(false),
[isNavigatingViaKeyboard, setIsNavigatingViaKeyboard] = useState(false),
[containerWidth, setContainerWidth] = useState(),
[gridSelection, setGridSelection] = useState(null),
[textInputValue, setTextInputValue] = useState(''),
[newEntityDisplayValue, setNewEntityDisplayValue] = useState(null),
[filteredData, setFilteredData] = useState(data),
[inputHeight, setInputHeight] = useState(0),
[menuRenderedHeight, setMenuRenderedHeight] = useState(0),
[width, setWidth] = useState(0),
[top, setTop] = useState(0),
[left, setLeft] = useState(0),
getIsMenuShown = () => {
return isMenuShown.current;
},
setIsMenuShown = (bool) => {
isMenuShown.current = bool;
if (!bool) {
// The menu's onLayout runs every time there's a change in its size or position.
// We're only interested in the *first* time it runs with a rendered height.
// So if hiding the menu, reset isGridLayoutRunWithRender here and we'll set it to true
// the first time onLayout runs with a height.
setIsGridLayoutRunWithRender(false);
setIsMenuAbove(false); // reset this so the next time the menu opens, it starts below the input
}
forceUpdate();
},
getIsGridLayoutRunWithRender = () => {
return isGridLayoutRunWithRender.current;
},
setIsGridLayoutRunWithRender = (bool) => {
isGridLayoutRunWithRender.current = bool;
},
onLayout = (e) => {
setIsRendered(true);
setContainerWidth(e.nativeEvent.layout.width);
},
showMenu = async () => {
if (getIsMenuShown()) {
return;
}
if (CURRENT_MODE === UI_MODE_WEB && inputRef.current?.getBoundingClientRect) {
// For web, ensure it's in the proper place
const
rect = inputRef.current.getBoundingClientRect(),
inputRect = inputRef.current.getBoundingClientRect();
if (rect.top !== top) {
setTop(rect.top);
}
if (rect.left !== left) {
setLeft(rect.left);
}
let widthToSet = rect.width,
minWidthToUse = menuMinWidth || styles.FORM_COMBO_MENU_MIN_WIDTH;
if (widthToSet < minWidthToUse) {
widthToSet = minWidthToUse;
}
if (widthToSet !== width) {
setWidth(widthToSet);
}
setInputHeight(inputRect.height);
}
if (Repository && !Repository.isLoaded) {
// await Repository.load(); // this breaks when the menu (Grid) has selectorSelected
}
setIsMenuShown(true);
},
hideMenu = () => {
if (!getIsMenuShown()) {
return;
}
setIsMenuShown(false);
},
toggleMenu = () => {
setIsMenuShown(!getIsMenuShown());
},
temporarilySetIsNavigatingViaKeyboard = () => {
setIsNavigatingViaKeyboard(true);
setTimeout(() => {
setIsNavigatingViaKeyboard(false);
}, 1000);
},
getDisplayValue = () => {
return displayValueRef.current;
},
setDisplayValue = async (value) => {
let displayValue = '';
if (_.isNil(value)) {
// do nothing
} else if (_.isArray(value)) {
displayValue = [];
if (Repository) {
if (!Repository.isDestroyed) {
if (!Repository.isLoaded) {
throw Error('Not yet implemented'); // Would a Combo ever have multiple remote selections? Shouldn't that be a Tag field??
}
if (Repository.isLoading) {
await Repository.waitUntilDoneLoading();
}
displayValue = _.each(value, (id) => {
const entity = Repository.getById(id);
if (entity) {
displayValue.push(entity.displayValue);
}
});
}
} else {
displayValue = _.each(value, (id) => {
const item = _.find(data, (datum) => datum[idIx] === id);
if (item) {
displayValue.push(item[displayIx]);
}
});
}
displayValue = displayValue.join(', ');
} else {
if (Repository) {
if (!Repository.isDestroyed) {
let entity;
if (!isEmptyValue(value)) {
if (!Repository.isLoaded) {
entity = await Repository.getSingleEntityFromServer(value);
} else {
if (Repository.isLoading) {
await Repository.waitUntilDoneLoading();
}
entity = Repository.getById(value);
if (!entity) {
entity = await Repository.getSingleEntityFromServer(value);
}
}
}
displayValue = entity?.displayValue || '';
}
} else {
const item = _.find(data, (datum) => datum[idIx] === value);
displayValue = (item && item[displayIx]) || '';
}
}
if (isInTag) {
displayValue = '';
}
displayValueRef.current = displayValue;
resetTextInputValue();
},
resetTextInputValue = () => {
setTextInputValue(getDisplayValue());
},
onInputKeyPress = (e, inputValue) => {
if (disableDirectEntry) {
return;
}
switch(e.key) {
case 'Escape':
e.preventDefault();
setIsSearchMode(false);
resetTextInputValue();
hideMenu();
break;
case 'Enter':
e.preventDefault();
if (_.isEmpty(inputValue) && !_.isNull(value)) {
// User pressed Enter on an empty text field, but value is set to something
// This means the user cleared the input and pressed enter, meaning he wants to clear the value
setValue(null);
hideMenu();
return;
}
if (_.isEmpty(gridSelection)) {
hideMenu();
return;
}
let id = null;
if (gridSelection.length) {
id = Repository ? gridSelection[0].id : gridSelection[0][idIx];
}
if (id !== value) {
setValue(id);
}
if (onSubmit) {
onSubmit(id);
}
hideMenu();
break;
case 'ArrowDown':
e.preventDefault();
showMenu();
temporarilySetIsNavigatingViaKeyboard();
setTimeout(() => {
self.children.grid.selectNext();
}, 10);
break;
case 'ArrowUp':
e.preventDefault();
showMenu();
temporarilySetIsNavigatingViaKeyboard();
setTimeout(() => {
self.children.grid.selectPrev();
}, 10);
break;
default:
}
},
onInputChangeText = (value) => {
if (disableDirectEntry) {
return;
}
if (_.isEmpty(value)) {
setValue(null);
}
setTextInputValue(value);
clearTimeout(typingTimeout.current);
typingTimeout.current = setTimeout(() => {
searchForMatches(value);
}, 300);
},
onInputFocus = (e) => {
if (inputRef.current?.select) {
inputRef.current?.select();
}
},
onInputBlur = (e) => {
if (isEventStillInComponent(e)) {
// ignore the blur
return;
}
setIsSearchMode(false);
resetTextInputValue();
hideMenu();
},
onTriggerPress = async (e) => {
if (!isRendered) {
return;
}
clearGridFilters();
if (reloadOnTrigger && Repository) {
await Repository.reload();
}
if (getIsMenuShown()) {
hideMenu();
} else {
showMenu();
}
},
onTriggerBlur = (e) => {
if (!getIsMenuShown()) {
return;
}
if (isEventStillInComponent(e)) {
// ignore the blur
return;
}
setIsSearchMode(false);
resetTextInputValue();
hideMenu();
},
onXButtonPress = () => {
setValue(null);
clearGridFilters();
clearGridSelection();
},
onEyeButtonPress = async () => {
const id = value;
if (!Repository.isLoaded) {
await Repository.load();
}
if (Repository.isLoading) {
await Repository.waitUntilDoneLoading();
}
let record = Repository.getById(id); // first try to get from entities in memory
if (!record && Repository.getSingleEntityFromServer) {
record = await Repository.getSingleEntityFromServer(id);
}
if (!record) {
alert('Record could not be found!');
return;
}
setViewerSelection([record]);
setIsViewerShown(true);
},
onViewerClose = () => setIsViewerShown(false),
onCheckButtonPress = () => {
hideMenu();
},
onGridLayout = (e) => {
// This method is to determine if we need to flip the grid above the input
// because the menu is partially offscreen
if (CURRENT_MODE !== UI_MODE_WEB || !e.nativeEvent.layout.height) {
return;
}
// we reach this point only if the grid has rendered with a height.
if (!getIsGridLayoutRunWithRender()) {
// we reach this point only on the *first* time onGridLayout runs with a height.
// determine if the menu is partially offscreen
const
menuRect = menuRef.current.getBoundingClientRect(),
inputRect = inputRef.current.getBoundingClientRect(),
menuOverflows = menuRect.bottom > window.innerHeight;
if (menuOverflows) {
// flip it
setIsMenuAbove(true);
} else {
setIsMenuAbove(false);
}
setMenuRenderedHeight(e.nativeEvent.layout.height);
setIsGridLayoutRunWithRender(true);
}
},
isEventStillInComponent = (e) => {
const {
relatedTarget
} = e;
return !relatedTarget ||
!menuRef.current ||
!triggerRef.current ||
triggerRef.current === relatedTarget ||
triggerRef.current.contains(relatedTarget) ||
menuRef.current === relatedTarget ||
menuRef.current?.contains(relatedTarget);
},
getFilterName = (isId) => {
// Only used for remote repositories
// Gets the filter name of the query, which becomes the condition sent to server
let filterName = FILTER_NAME;
if (Repository.isRemote) {
const
schema = Repository.getSchema(),
displayFieldName = schema.model.displayProperty,
displayFieldDef = schema.getPropertyDefinition(displayFieldName);
// Verify displayField is a real field
if (isId) {
const idFieldName = schema.model.idProperty;
filterName = idFieldName;
} else if (!displayFieldDef.isVirtual) {
filterName = displayFieldName + ' LIKE';
}
}
return filterName;
},
clearGridFilters = async () => {
if (Repository) {
if (!Repository.isDestroyed) {
if (Repository.isLoading) {
await Repository.waitUntilDoneLoading();
}
const filterName = getFilterName();
if (Repository.hasFilter(filterName)) {
Repository.clearFilters(filterName);
if (Repository.isRemote && !Repository.isAutoLoad) {
await Repository.reload();
}
}
}
} else {
setFilteredData(data);
}
},
clearGridSelection = () => {
if (self?.children.grid?.deselectAll) {
self?.children.grid?.deselectAll();
}
},
searchForMatches = async (value) => {
let found;
if (Repository) {
if (!Repository.isDestroyed) {
if (Repository.isLoading) {
await Repository.waitUntilDoneLoading();
}
if (_.isEmpty(value)) {
clearGridFilters();
return;
}
// Set filter
const
idRegex = /^id:(.*)$/,
isId = _.isString(value) && !!value.match(idRegex),
filterName = getFilterName(isId);
if (Repository.isRemote) {
// remote
const filterValue = _.isEmpty(value) ? null : (isId ? value.match(idRegex)[1] : (searchHasInitialPercent ? '%' : '') + value + '%');
await Repository.filter(filterName, filterValue);
if (!Repository.isAutoLoad) {
await Repository.reload();
}
} else {
// local
Repository.filter({
name: filterName,
fn: (entity) => {
const
displayValue = entity.displayValue,
regex = new RegExp('^' + value, 'i'); // case-insensitive
return displayValue.match(regex);
},
});
}
if (!isId) {
setNewEntityDisplayValue(value); // capture the search query so we can tell Grid what to use for a new entity's displayValue
}
}
} else {
// Search through data
const regex = new RegExp('^' + value, 'i'); // case-insensitive
found = _.filter(data, (item) => {
if (_.isString(item[displayIx]) && _.isString(value)) {
return item[displayIx].match(regex);
}
return item[displayIx] == value; // equality, not identity
});
setFilteredData(found);
}
if (!getIsMenuShown()) {
showMenu();
}
setIsSearchMode(true);
};
useEffect(() => {
// on render, focus the input
if (!isRendered) {
return () => {};
}
if (autoFocus && !inputRef.current.isFocused()) {
inputRef.current.focus();
}
return () => {
if (Repository && !Repository.isUnique && !Repository.isDestroyed) {
clearGridFilters();
}
};
}, [isRendered]);
useEffect(() => {
(async () => {
setIsSearchMode(false);
await setDisplayValue(value);
if (!isReady) {
setIsReady(true);
}
})();
}, [value]);
if (!isReady) {
return null;
}
const inputIconElement = icon ? <Icon as={icon} size="md" className="text-grey-300 ml-1 mr-2" /> : null;
let xButton = null,
eyeButton = null,
trigger = null,
input = null,
inputClone = null,
checkButton = null,
grid = null,
dropdownMenu = null,
assembledComponents = null;
if (showXButton) {
xButton = <IconButton
{...testProps('xBtn')}
icon={Xmark}
_icon={{
size: 'sm',
className: 'text-grey-600',
}}
isDisabled={isDisabled || _.isNil(value)}
onPress={onXButtonPress}
tooltip="Clear selection"
className={clsx(
'h-full',
'mr-1',
styles.FORM_COMBO_TRIGGER_CLASSNAME
)}
/>;
}
if (showEyeButton && Editor) {
eyeButton = <IconButton
{...testProps('eyeBtn')}
icon={Eye}
_icon={{
size: 'sm',
className: 'text-grey-600',
}}
isDisabled={isDisabled || _.isNil(value)}
onPress={onEyeButtonPress}
tooltip="View selected record"
className={clsx(
'h-full',
'mr-1',
styles.FORM_COMBO_TRIGGER_CLASSNAME
)}
/>;
}
const triggerClassName = clsx(
'Combo-trigger',
'self-stretch',
'h-auto',
'border',
'border-l-0',
'border-gray-400',
'rounded-l-none',
'rounded-r-md',
styles.FORM_COMBO_TRIGGER_CLASSNAME,
);
trigger = <IconButton
{...testProps('trigger')}
ref={triggerRef}
icon={CaretDown}
_icon={{
size: 'md',
className: 'text-grey-500',
}}
isDisabled={isDisabled}
onPress={onTriggerPress}
onBlur={onTriggerBlur}
className={triggerClassName}
/>;
if (CURRENT_MODE === UI_MODE_WEB) {
input = disableDirectEntry ?
<Pressable
{...testProps('toggleMenuBtn')}
ref={inputRef}
onPress={toggleMenu}
className={clsx(
'Combo-toggleMenuBtn',
'h-auto',
'self-stretch',
'flex-1',
'flex-row',
'justify-center',
'items-center',
'm-0',
'p-2',
'bg-white',
'border',
'border-grey-400',
'rounded-r-none',
styles.FORM_COMBO_INPUT_BG
)}
>
{inputIconElement}
<TextNative
numberOfLines={1}
ellipsizeMode="head"
className={clsx(
'Combo-TextNative',
'flex-1',
_.isEmpty(textInputValue) ? 'text-grey-400' : 'text-black',
styles.FORM_COMBO_INPUT_CLASSNAME
)}
>{_.isEmpty(textInputValue) ? placeholder : textInputValue}</TextNative>
</Pressable> :
<Input
{...testProps('input')}
ref={inputRef}
reference="ComboInput"
value={textInputValue}
autoSubmit={true}
isDisabled={isDisabled}
onChangeValue={onInputChangeText}
onKeyPress={onInputKeyPress}
onFocus={onInputFocus}
onBlur={onInputBlur}
InputLeftElement={inputIconElement}
autoSubmitDelay={500}
placeholder={placeholder}
className={clsx(
'Combo-Input',
'grow',
'h-auto',
'self-stretch',
'flex-1',
'm-0',
'rounded-tr-none',
'rounded-br-none',
styles.FORM_COMBO_INPUT_CLASSNAME
)}
{..._input}
/>;
}
if (CURRENT_MODE === UI_MODE_NATIVE) {
// This input and trigger are for show
// They just show the current getDisplayValue and open the menu
const displayValue = getDisplayValue();
input = <Pressable
{...testProps('showMenuBtn')}
onPress={showMenu}
className={clsx(
'h-full',
'flex-1',
'flex-row',
'justify-center',
'items-center',
'bg-white',
'border',
'border-grey-400',
'rounded-r-none',
styles.FORM_COMBO_INPUT_BG,
)}
>
{inputIconElement}
<TextNative
numberOfLines={1}
ellipsizeMode="head"
className={clsx(
// 'h-full',
// 'flex-1',
'm-0',
'p-2',
_.isEmpty(displayValue) ? 'text-grey-400' : 'text-black',
styles.FORM_COMBO_INPUT_CLASSNAME,
)}
>{_.isEmpty(displayValue) ? placeholder : displayValue}</TextNative>
</Pressable>;
}
if (getIsMenuShown()) {
const gridProps = _.pick(props, [
'Editor',
'model',
'Repository',
// 'data',
'idIx',
'displayIx',
// 'value',
'disableAdd',
'disableEdit',
'disableDelete',
'disableView',
'disableCopy',
'disableDuplicate',
'disablePrint',
'selectorId',
'selectorSelected',
'selectorSelectedField',
'usePermissions',
]);
if (!_.isEmpty(_grid)){
_.assign(gridProps, _grid);
}
if (!isInTag) {
gridProps.value = props.value;
}
if (!Repository) {
gridProps.data = filteredData;
}
const
WhichGrid = isEditor ? WindowedGridEditor : Grid,
gridStyle = {};
if (CURRENT_MODE === UI_MODE_WEB) {
gridStyle.height = menuHeight || styles.FORM_COMBO_MENU_HEIGHT;
}
let gridClassName = clsx(
'h-full',
'w-full',
);
if (CURRENT_MODE === UI_MODE_NATIVE) {
gridClassName += ' h-[400px] max-h-[100%]';
}
if (gridProps.className) {
gridClassName += ' ' + gridProps.className;
}
grid = <WhichGrid
showHeaders={false}
showHovers={true}
getRowProps={getRowProps}
autoAdjustPageSizeToHeight={false}
newEntityDisplayValue={newEntityDisplayValue}
newEntityDisplayProperty={newEntityDisplayProperty}
disablePresetButtons={!isEditor}
alternateRowBackgrounds={false}
showSelectHandle={false}
onLayout={onGridLayout}
onChangeSelection={(selection) => {
if (Repository && selection[0]?.isPhantom) {
// do nothing
return;
}
setGridSelection(selection);
if (Repository) {
// When we first open the menu, we try to match the selection to the value, ignore this
if (selection[0]?.displayValue === getDisplayValue()) {
return;
}
// when user selected the record matching the current value, kill search mode
if (selection[0]?.id === value) {
setIsSearchMode(false);
resetTextInputValue();
if (hideMenuOnSelection && !isNavigatingViaKeyboard && !isEditor) {
hideMenu();
}
return;
}
setValue(selection[0] ? selection[0].id : null);
} else {
// When we first open the menu, we try to match the selection to the value, ignore this
if (selection[0] && selection[0][displayIx] === getDisplayValue()) {
return;
}
// when user selected the record matching the current value, kill search mode
if (selection[0] && selection[0][idIx] === value) {
setIsSearchMode(false);
resetTextInputValue();
if (hideMenuOnSelection && !isNavigatingViaKeyboard) {
hideMenu();
}
return;
}
setValue(selection[0] ? selection[0][idIx] : null);
}
if (_.isEmpty(selection)) {
return;
}
if (hideMenuOnSelection && !isNavigatingViaKeyboard && !isEditor) {
hideMenu();
}
}}
onAdd={(selection) => {
const entity = _.isArray(selection) ? selection[0] : selection;
if (entity.id !== value && !isInTag) {
// Select it and set the value of the combo.
setGridSelection(selection);
setValue(entity.id);
}
if (onGridAdd) {
onGridAdd(selection);
}
}}
onSave={(selection) => {
const entity = _.isArray(selection) ? selection[0] : selection;
if (!isInTag) {
if (entity?.id !== value) { // Tag doesn't use value, so don't do this comparison in the Tag
// Either a phantom record was just solidified into a real record, or a new (non-phantom) record was added.
// Select it and set the value of the combo.
setGridSelection(selection);
setValue(entity.id);
} else {
// we're not changing the Combo's value, but we might still need to change its displayValue
setDisplayValue(entity.id);
}
}
if (onGridSave) {
onGridSave(selection);
}
}}
onDelete={onGridDelete}
onRowPress={(item, e) => {
if (onRowPress) {
onRowPress(item, e);
return;
}
const id = Repository ? item.id : item[idIx];
if (id === value && !isEditor) {
hideMenu();
onInputFocus();
}
if (onSubmit) {
onSubmit(id);
}
}}
reference="grid"
parent={self}
style={gridStyle}
{...gridProps}
className={gridClassName}
{..._editor}
/>;
if (CURRENT_MODE === UI_MODE_WEB) {
if (!disableDirectEntry) {
inputClone = <Box
className="Combo-inputClone-Box"
style={{
height: inputHeight,
}}
>
<Input
{...testProps('input')}
ref={inputCloneRef}
reference="ComboInputClone"
value={textInputValue}
autoSubmit={true}
isDisabled={isDisabled}
onChangeValue={onInputChangeText}
onKeyPress={onInputKeyPress}
onFocus={onInputFocus}
onBlur={onInputBlur}
InputLeftElement={inputIconElement}
autoSubmitDelay={500}
placeholder={placeholder}
className={clsx(
'Combo-inputClone-Input',
'grow',
'h-full',
'flex-1',
'm-0',
'rounded-tr-none',
'rounded-br-none',
styles.FORM_COMBO_INPUT_CLASSNAME,
)}
{..._input}
/>
</Box>;
}
dropdownMenu = <Popover
isOpen={getIsMenuShown()}
onClose={() => {
hideMenu();
}}
trigger={emptyFn}
className="dropdownMenu-Popover block"
initialFocusRef={inputCloneRef}
>
<PopoverBackdrop className="PopoverBackdrop bg-[#000]" />
<Box
ref={menuRef}
className={clsx(
'dropdownMenu-Box',
'flex-1',
'overflow-auto',
'bg-white',
'p-0',
'rounded-none',
'border',
'border-grey-400',
'shadow-md',
'max-w-full',
)}
style={{
// If flipped, position above input; otherwise, below
top: isMenuAbove
? (top - menuRenderedHeight) // above
: top, // below
left,
width,
minWidth: 100,
}}
>
{isMenuAbove ?
<>
{grid}
{inputClone}
</> :
<>
{inputClone}
{grid}
</>}
</Box>
</Popover>;
}
if (CURRENT_MODE === UI_MODE_NATIVE) {
if (isEditor) {
// in RN, an editor has no way to accept the selection of the grid, so we need to add a check button to do this
checkButton = <IconButton
{...testProps('checkBtn')}
icon={Check}
_icon={{
size: 'sm',
className: 'text-grey-600',
}}
onPress={onCheckButtonPress}
isDisabled={!value}
className={clsx(
'h-full',
'border',
'border-grey-400',
'rounded-md',
styles.FORM_COMBO_TRIGGER_CLASSNAME,
)}
/>;
}
const inputAndTriggerClone = // for RN, this is the actual input and trigger, as we need them to appear up above in the modal
<HStack
className={clsx(
'h-[40px]',
'bg-white',
)}
>
{xButton}
{eyeButton}
{disableDirectEntry ?
<TextNative
ref={inputRef}
numberOfLines={1}
ellipsizeMode="head"
className={clsx(
'h-full',
'flex-1',
'm-0',
'p-2',
'border',
'border-grey-400',
'rounded-r-none',
styles.FORM_COMBO_INPUT_CLASSNAME
)}
>{textInputValue}</TextNative> :
<Input
{...testProps('input')}
ref={inputRef}
reference="ComboInput"
value={textInputValue}
autoSubmit={true}
isDisabled={isDisabled}
onChangeValue={onInputChangeText}
onKeyPress={onInputKeyPress}
onFocus={onInputFocus}
onBlur={onInputBlur}
InputLeftElement={inputIconElement}
autoSubmitDelay={500}
placeholder={placeholder}
className={clsx(
'h-full',
'flex-1',
'm-0',
'rounded-r-none',
styles.FORM_COMBO_INPUT_CLASSNAME,
)}
{..._input}
/>}
<IconButton
{...testProps('hideMenuBtn')}
icon={CaretDown}
_icon={{
size: 'sm',
className: 'text-primary-800',
}}
isDisabled={isDisabled}
onPress={() => hideMenu()}
className={clsx(
'h-full',
'border',
'border-grey-400',
'rounded-l-none',
'rounded-r-md',
styles.FORM_COMBO_TRIGGER_CLASSNAME,
)}
/>
{checkButton}
</HStack>;
let modalBackdrop = <ModalBackdrop />
if (CURRENT_MODE === UI_MODE_NATIVE) {
// Gluestack's ModalBackdrop was not working on Native,
// so workaround is to do it manually for now
modalBackdrop = <Pressable
onPress={() => setIsMenuShown(false)}
className={clsx(
'ModalBackdrop-replacment',
'h-full',
'w-full',
'absolute',
'top-0',
'left-0',
'bg-[#000]',
'opacity-50',
)}
/>;
}
dropdownMenu = <Modal
isOpen={true}
safeAreaTop={true}
onClose={() => setIsMenuShown(false)}
className={clsx(
'h-full',
'w-full',
)}
>
{modalBackdrop}
<VStackNative
className={clsx(
'h-[400px]',
'w-[80%]',
'max-w-[400px]',
)}
>
{inputAndTriggerClone}
{grid}
</VStackNative>
</Modal>;
}
}
let className = clsx(
'Combo-HStack',
'flex-1',
'items-stretch',
'h-auto',
'self-stretch',
'justify-center',
'items-stretch'
);
if (props.className) {
className += ' ' + props.className;
}
if (minimizeForRow) {
className += ' h-auto min-h-0';
}
if (isRendered && additionalButtons?.length && containerWidth < 500) {
// be responsive for small screen sizes and bump additionalButtons to the next line
assembledComponents =
<VStackNative
testID={testID}
className="Combo-VStack"
>
<HStack
className={className}
>
{xButton}
{eyeButton}
{input}
{trigger}
{dropdownMenu}
</HStack>
<HStack className="mt-1">
{additionalButtons}
</HStack>
</VStackNative>;
} else {
assembledComponents =
<HStackNative
testID={testID}
onLayout={onLayout}
className={className}
>
{xButton}
{eyeButton}
{input}
{trigger}
{additionalButtons}
{dropdownMenu}
</HStackNative>;
}
if (isViewerShown && Editor) {
const propsForViewer = _.pick(props, [
'disableCopy',
'disableDuplicate',
'disablePrint',
'disableView',
'value',
'Repository',
'data',
'displayField',
'displayIx',
'fields',
'idField',
'idIx',
'model',
'name',
]);
assembledComponents =
<>
{assembledComponents}
<Modal
isOpen={true}
onClose={onViewerClose}
>
<Editor
editorType={EDITOR_TYPE__WINDOWED}
isEditorViewOnly={true}
parent={self}
reference="viewer"
selection={viewerSelection}
onEditorClose={onViewerClose}
className={clsx(
'w-full',
'p-0',
)}
{...propsForViewer}
{...viewerProps}
/>
</Modal>
</>;
}
return assembledComponents;
});
export const Combo = withComponent(
withAlert(
withData(
withValue(
withTooltip(
ComboComponent
)
)
)
)
);
function withAdditionalProps(WrappedComponent) {
return (props) => {
return <WrappedComponent
isEditor={true}
hideMenuOnSelection={false}
disableView={true}
disableCopy={true}
disableDuplicate={true}
disablePrint={true}
{...props}
/>;
};
}
export const ComboEditor = withAdditionalProps(Combo);
export default Combo;