@onehat/ui
Version:
Base UI for OneHat apps
579 lines (543 loc) • 16.7 kB
JavaScript
import { useEffect, useCallback, useRef, useState, isValidElement, } from 'react';
import {
Box,
HStack,
Icon,
ScrollView,
Text,
VStack,
VStackNative,
} from '@project-components/Gluestack';
import clsx from 'clsx';
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
} from 'react-native-reanimated';
import {
EDIT,
} from '../../Constants/Commands.js';
import {
EDITOR_TYPE__SIDE,
EDITOR_TYPE__SMART,
} from '../../Constants/Editor.js';
import {
extractCssPropertyFromClassName,
hasHeight,
hasWidth,
hasFlex,
} from '../../Functions/tailwindFunctions.js';
import UiGlobals from '../../UiGlobals.js';
import withComponent from '../Hoc/withComponent.js';
import inArray from '../../Functions/inArray.js';
import getComponentFromType from '../../Functions/getComponentFromType.js';
import buildAdditionalButtons from '../../Functions/buildAdditionalButtons.js';
import testProps from '../../Functions/testProps.js';
import DynamicFab from '../Fab/DynamicFab.js';
import Toolbar from '../Toolbar/Toolbar.js';
import ArrowUp from '../Icons/ArrowUp.js';
import Button from '../Buttons/Button.js';
import Label from '../Form/Label.js';
import Pencil from '../Icons/Pencil.js';
import Footer from '../Layout/Footer.js';
import _ from 'lodash';
const FAB_FADE_TIME = 300; // ms
function Viewer(props) {
const {
viewerCanDelete = false,
items = [], // Columns, FieldSets, Fields, etc to define the form
ancillaryItems = [], // additional items which are not controllable form elements, but should appear in the form
showAncillaryButtons = false,
columnDefaults = {}, // defaults for each Column defined in items (above)
record,
additionalViewButtons,
canRecordBeEdited,
viewerSetup, // this fn will be executed after the viewer setup is complete
disableLabels = false,
// withComponent
self,
// withData
Repository,
// withPermissions
canUser,
showPermissionsError,
// withEditor
editorType,
onEditMode,
onClose,
onDelete,
// parent container
selectorId,
selectorSelected,
selectorSelectedField,
} = props,
scrollViewRef = useRef(),
ancillaryItemsRef = useRef({}),
ancillaryButtons = useRef([]),
setAncillaryButtons = (array) => {
ancillaryButtons.current = array;
},
getAncillaryButtons = () => {
return ancillaryButtons.current;
},
isMultiple = _.isArray(record),
[containerWidth, setContainerWidth] = useState(),
[isFabVisible, setIsFabVisible] = useState(false),
fabOpacity = useSharedValue(0),
fabAnimatedStyle = useAnimatedStyle(() => {
return {
opacity: withTiming(fabOpacity.value, { duration: FAB_FADE_TIME }), // Smooth fade animation
pointerEvents: fabOpacity.value > 0 ? 'auto' : 'none', // Disable interaction when invisible
};
}),
isSideEditor = editorType === EDITOR_TYPE__SIDE,
isSmartEditor = editorType === EDITOR_TYPE__SMART,
styles = UiGlobals.styles,
flex = props.flex || 1,
buildFromItems = () => {
return _.map(items, (item, ix) => buildFromItem(item, ix, columnDefaults));
},
buildFromItem = (item, ix, defaults) => {
if (!item) {
return null;
}
if (isValidElement(item)) {
return item;
}
let {
type,
title,
name,
label,
items,
useSelectorId = false,
isHidden = false,
isHiddenInViewMode = false,
getDynamicProps,
...itemPropsToPass
} = item,
viewerTypeProps = {};
if (isHidden) {
return null;
}
if (isHiddenInViewMode) {
return null;
}
if (!itemPropsToPass.className) {
itemPropsToPass.className = '';
}
const propertyDef = name && Repository?.getSchema().getPropertyDefinition(name);
if (!type) {
if (propertyDef?.viewerType?.type) {
const
{
type: t,
...p
} = propertyDef.viewerType;
type = t;
viewerTypeProps = p;
} else {
type = 'Text';
}
}
const isCombo = type?.match && type.match(/Combo/);
if (item.hasOwnProperty('autoLoad')) {
viewerTypeProps.autoLoad = item.autoLoad;
} else {
if (isCombo && Repository?.isRemote && !Repository?.isLoaded) {
viewerTypeProps.autoLoad = true;
}
}
if (type?.match(/(Tag|TagEditor)$/)) {
viewerTypeProps.isViewOnly = true;
}
const Element = getComponentFromType(type);
if (inArray(type, ['Column', 'Row', 'FieldSet'])) {
if (_.isEmpty(items)) {
return null;
}
let children;
const style = {};
if (type === 'Column') {
const isEverythingInOneColumn = containerWidth < styles.FORM_ONE_COLUMN_THRESHOLD;
if (itemPropsToPass.hasOwnProperty('flex')) {
if (!isEverythingInOneColumn) {
style.flex = itemPropsToPass.flex;
}
delete itemPropsToPass.flex;
}
if (itemPropsToPass.hasOwnProperty('w')) {
if (!isEverythingInOneColumn) {
style.width = itemPropsToPass.w;
}
delete itemPropsToPass.w;
}
// if (!style.flex && !style.width) {
// style.flex = 1;
// }
itemPropsToPass.className += ' Column';
// if (containerWidth < styles.FORM_ONE_COLUMN_THRESHOLD) {
// // everything is in one column
// if (hasFlex(itemPropsToPass)) {
// itemPropsToPass.className = extractCssPropertyFromClassName(itemPropsToPass.className, 'flex').className;
// if (itemPropsToPass.style?.flex) {
// delete itemPropsToPass.style.flex;
// }
// }
// if (hasWidth(itemPropsToPass)) {
// itemPropsToPass.className = extractCssPropertyFromClassName(itemPropsToPass.className, 'width').className;
// if (itemPropsToPass.style?.width) {
// delete itemPropsToPass.style.width;
// }
// }
// itemPropsToPass.className += ' w-full mb-1';
// }
}
if (type === 'Row') {
itemPropsToPass.className += ' Row w-full';
}
if (type === 'FieldSet' && item.showToggleAllCheckbox) {
itemPropsToPass.showToggleAllCheckbox = false; // don't allow it in view mode
}
const itemDefaults = item.defaults || {};
children = _.map(items, (item, ix) => {
return buildFromItem(item, ix, {...defaults, ...itemDefaults});
});
let elementClassName = 'Viewer-ElementFromItem';
const defaultsClassName = defaults.className;
if (defaultsClassName) {
elementClassName += ' ' + defaultsClassName;
}
const itemDefaultsClassName = itemDefaults.className;
if (itemDefaultsClassName) {
elementClassName += ' ' + itemDefaultsClassName;
}
const itemPropsToPassClassName = itemPropsToPass.className;
if (itemPropsToPassClassName) {
elementClassName += ' ' + itemPropsToPassClassName;
}
const editorTypeClassName = viewerTypeProps.className;
if (editorTypeClassName) {
elementClassName += ' ' + editorTypeClassName;
}
let defaultsToPass = {},
itemDefaultsToPass = {};
if (type === 'FieldSet') { // don't pass for Row and Column, as they use regular <div>s for web
defaultsToPass = defaults;
itemDefaultsToPass = itemDefaults;
}
return <Element
key={ix}
title={title}
{...defaultsToPass}
{...itemDefaultsToPass}
{...itemPropsToPass}
{...viewerTypeProps}
className={elementClassName}
style={style}
>{children}</Element>;
}
if (!label && Repository && propertyDef?.title) {
label = propertyDef.title;
}
let value = record?.properties[name]?.displayValue || null;
const
schema = record?.repository.getSchema(),
propertyDefinition = schema?.getPropertyDefinition(name);
if (propertyDefinition?.isFk) {
// value above is the id, get the actual display value
const fkDisplayField = propertyDefinition.fkDisplayField;
if (record.properties[fkDisplayField]) {
value = record.properties[fkDisplayField].displayValue;
}
}
let elementClassName = clsx(
'Viewer-field',
'basis-auto',
'grow',
'shrink',
);
const defaultsClassName = defaults.className;
if (defaultsClassName) {
elementClassName += ' ' + defaultsClassName;
}
const itemPropsToPassClassName = itemPropsToPass.className;
if (itemPropsToPassClassName) {
elementClassName += ' ' + itemPropsToPassClassName;
}
const viewerTypeClassName = viewerTypeProps.className;
if (viewerTypeClassName) {
elementClassName += ' ' + viewerTypeClassName;
}
let element = <Element
{...testProps('field-' + name)}
value={value}
isEditable={false}
parent={self}
reference={name}
{...itemPropsToPass}
{...viewerTypeProps}
className={elementClassName}
/>;
if (item.additionalViewButtons) {
element = <HStack className="Viewer-HStack1 flex-wrap">
{element}
{buildAdditionalButtons(item.additionalViewButtons, self)}
</HStack>;
}
if (!disableLabels && label) {
const style = {};
if (defaults?.labelWidth) {
style.width = defaults.labelWidth;
}
if (!style.width) {
style.width = '50px';
}
if (containerWidth > styles.FORM_STACK_ROW_THRESHOLD) {
element = <HStack className="Viewer-HStack2 py-1 w-full">
<Label style={style}>{label}</Label>
{element}
</HStack>;
} else {
element = <VStack className="Viewer-HStack3 w-full py-1 mt-3">
<Label style={style}>{label}</Label>
{element}
</VStack>;
}
}
return <HStack key={ix} className="Viewer-HStack4 px-2 pb-1">{element}</HStack>;
},
buildAncillary = () => {
const components = [];
setAncillaryButtons([]);
if (ancillaryItems.length) {
// add the "scroll to top" button
getAncillaryButtons().push({
icon: ArrowUp,
key: 'scrollToTop',
reference: 'scrollToTop',
onPress: () => scrollToAncillaryItem(0),
tooltip: 'Scroll to top',
});
_.each(ancillaryItems, (item, ix) => {
let {
type,
title = null,
icon,
selectorId = null,
...itemPropsToPass
} = item;
if (isMultiple && type !== 'Attachments') {
return;
}
if (icon) {
// NOTE: this assumes that if one Ancillary item has an icon, they all do.
// If they don't, the ix will be wrong.
getAncillaryButtons().push({
icon,
key: 'ancillary-' + ix,
onPress: () => { scrollToAncillaryItem(ix +1)}, // offset for the "scroll to top" button
tooltip: title,
});
}
if (type.match(/Grid/) && !itemPropsToPass.h) {
itemPropsToPass.h = 400;
}
let className = 'Viewer-ancillary-' + type;
if (itemPropsToPass.className) {
className += ' ' + itemPropsToPass.className;
}
const
Element = getComponentFromType(type),
element = <Element
{...testProps('ancillary-' + type)}
selectorId={selectorId}
selectorSelected={selectorSelected || record}
selectorSelectedField={selectorSelectedField}
canEditorViewOnly={true}
canCrud={false}
uniqueRepository={true}
parent={self}
{...itemPropsToPass}
className={className}
canRowsReorder={false}
/>;
if (title) {
if (record?.displayValue) {
title += ' for ' + record.displayValue;
}
title = <Text className={`${styles.VIEWER_ANCILLARY_FONTSIZE} font-bold`}>{title}</Text>;
if (icon) {
title = <HStack className="items-center"><Icon as={icon} className="w-[32px] h-[32px] mr-2" />{title}</HStack>
}
}
components.push(<VStack
ref={(el) => (ancillaryItemsRef.current[ix +1 /* offset for "scroll to top" */] = el)}
key={'ancillary-' + ix}
className="my-3"
>
{title}
{element}
</VStack>);
});
}
return components;
},
onLayout = (e) => {
setContainerWidth(e.nativeEvent.layout.width);
},
scrollToAncillaryItem = (ix) => {
ancillaryItemsRef.current[ix]?.scrollIntoView({
behavior: 'smooth',
block: 'start',
});
},
onScroll = useCallback(
_.debounce((e) => {
if (!showAncillaryButtons) {
return;
}
const
scrollY = e.nativeEvent.contentOffset.y,
isFabVisible = scrollY > 50;
fabOpacity.value = isFabVisible ? 1 : 0;
if (isFabVisible) {
setIsFabVisible(true);
} else {
// delay removal from DOM until fade-out is complete
setTimeout(() => setIsFabVisible(isFabVisible), FAB_FADE_TIME);
}
}, 100), // delay
[]
);
useEffect(() => {
if (viewerSetup && record?.getSubmitValues) {
viewerSetup(record.getSubmitValues());
}
}, [record]);
if (self) {
self.ref = scrollViewRef;
}
const
showDeleteBtn = onDelete && viewerCanDelete,
showCloseBtn = !isSideEditor && !isSmartEditor && onClose,
showFooter = (showDeleteBtn || showCloseBtn);
let additionalButtons = null,
viewerComponents = null,
ancillaryComponents = null,
fab = null;
if (containerWidth) { // we need to render this component twice in order to get the container width. Skip this on first render
additionalButtons = buildAdditionalButtons(additionalViewButtons);
viewerComponents = buildFromItems();
ancillaryComponents = buildAncillary();
if (showAncillaryButtons && !_.isEmpty(getAncillaryButtons())) {
fab = <Animated.View style={fabAnimatedStyle}>
<DynamicFab
buttons={getAncillaryButtons()}
collapseOnPress={false}
tooltip="Scroll to Ancillary Item"
/>
</Animated.View>;
}
}
let canEdit = true;
if (canRecordBeEdited && !canRecordBeEdited([record])) {
canEdit = false;
}
const style = props.style || {};
if (!hasWidth(props) && !hasFlex(props)) {
style.flex = 1;
}
let className = clsx(
'Viewer-VStackNative',
'h-full',
'bg-white',
);
if (props.className) {
className += ' ' + props.className;
}
const footer = showFooter ?
<Footer className="justify-end">
{showDeleteBtn &&
<HStack className="flex-1 justify-start">
<Button
{...testProps('deleteBtn')}
key="deleteBtn"
onPress={onDelete}
className={clsx(
'text-white',
'bg-warning-500',
'hover:bg-warning-600',
)}
text="Delete"
/>
</HStack>}
{showCloseBtn &&
<Button
{...testProps('closeBtn')}
key="closeBtn"
onPress={onClose}
className="text-white"
text="Close"
/>}
</Footer> : null;
const scrollToTopAnchor = <Box ref={(el) => (ancillaryItemsRef.current[0] = el)} className="h-0" />;
return <VStackNative
{...testProps(self)}
style={style}
onLayout={onLayout}
className={className}
>
{containerWidth && <>
<ScrollView
_web={{ height: 1 }}
ref={scrollViewRef}
onScroll={onScroll}
scrollEventThrottle={16 /* ms */}
className={clsx(
'Viewer-ScrollView',
'w-full',
'pb-1',
'flex-1',
)}
>
{scrollToTopAnchor}
{canEdit && onEditMode &&
<Toolbar className="justify-end">
<HStack className="flex-1 items-center">
<Text className="text-[20px] ml-1 text-grey-500">View Mode</Text>
</HStack>
{(!canUser || canUser(EDIT)) &&
<Button
{...testProps('toEditBtn')}
key="editBtn"
onPress={onEditMode}
icon={Pencil}
_icon={{
size: 'sm',
className: 'text-white'
}}
className="text-white"
text="To Edit"
tooltip="Switch to Edit Mode"
/>}
</Toolbar>}
{!_.isEmpty(additionalButtons) &&
<Toolbar className="justify-end flex-wrap gap-2">
{additionalButtons}
</Toolbar>}
{showAncillaryButtons && !_.isEmpty(getAncillaryButtons()) &&
<Toolbar className="justify-start flex-wrap gap-2">
<Text>Scroll:</Text>
{buildAdditionalButtons(_.omitBy(getAncillaryButtons(), (btnConfig) => btnConfig.reference === 'scrollToTop'))}
</Toolbar>}
{containerWidth >= styles.FORM_ONE_COLUMN_THRESHOLD ? <HStack className="Viewer-formComponents-HStack p-4 gap-4 justify-center">{viewerComponents}</HStack> : null}
{containerWidth < styles.FORM_ONE_COLUMN_THRESHOLD ? <VStack className="Viewer-formComponents-VStack p-4">{viewerComponents}</VStack> : null}
<VStack className="Viewer-AncillaryComponents m-2 pt-4 px-2">{ancillaryComponents}</VStack>
</ScrollView>
{footer}
{isFabVisible && fab}
</>}
</VStackNative>;
}
export default withComponent(Viewer);