@onehat/ui
Version:
Base UI for OneHat apps
1,581 lines (1,499 loc) • 45.7 kB
JavaScript
import { useEffect, useCallback, useState, useRef, 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 {
VIEW,
} from '../../Constants/Commands.js';
import {
EDITOR_TYPE__INLINE,
EDITOR_TYPE__WINDOWED,
EDITOR_TYPE__SIDE,
EDITOR_TYPE__SMART,
EDITOR_TYPE__PLAIN,
EDITOR_MODE__VIEW,
EDITOR_MODE__ADD,
EDITOR_MODE__EDIT,
} from '../../Constants/Editor.js';
import {
hasWidth,
hasFlex,
} from '../../Functions/tailwindFunctions.js';
import { useForm, Controller } from 'react-hook-form'; // https://react-hook-form.com/api/
import * as yup from 'yup'; // https://github.com/jquense/yup#string
import { yupResolver } from '@hookform/resolvers/yup';
import useForceUpdate from '../../Hooks/useForceUpdate.js';
import UiGlobals from '../../UiGlobals.js';
import withAlert from '../Hoc/withAlert.js';
import withComponent from '../Hoc/withComponent.js';
import withEditor from '../Hoc/withEditor.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 Toolbar from '../Toolbar/Toolbar.js';
import Button from '../Buttons/Button.js';
import IconButton from '../Buttons/IconButton.js';
import DynamicFab from '../Fab/DynamicFab.js';
import AngleLeft from '../Icons/AngleLeft.js';
import Eye from '../Icons/Eye.js';
import Rotate from '../Icons/Rotate.js';
import Pencil from '../Icons/Pencil.js';
import Plus from '../Icons/Plus.js';
import FloppyDiskRegular from '../Icons/FloppyDiskRegular.js';
import Trash from '../Icons/Trash.js';
import ArrowUp from '../Icons/ArrowUp.js';
import Xmark from '../Icons/Xmark.js';
import Check from '../Icons/Check.js';
import Footer from '../Layout/Footer.js';
import Label from '../Form/Label.js';
import _ from 'lodash';
// TODO: memoize field Components
// Modes:
// EDITOR_TYPE__INLINE
// Form is a single scrollable row, based on columnsConfig and Repository
//
// EDITOR_TYPE__WINDOWED
// EDITOR_TYPE__SIDE
// Form is a popup or side window, used for editing items in a grid. Integrated with Repository
//
// EDITOR_TYPE__SMART
// Form is a standalone editor
//
// EDITOR_TYPE__PLAIN
// Form is embedded on screen in some other way. Mainly use startingValues, items, validator
const FAB_FADE_TIME = 300; // ms
function Form(props) {
const {
editorType = EDITOR_TYPE__WINDOWED, // EDITOR_TYPE__INLINE | EDITOR_TYPE__WINDOWED | EDITOR_TYPE__SIDE | EDITOR_TYPE__SMART | EDITOR_TYPE__PLAIN
startingValues = {},
items = [], // Columns, FieldSets, Fields, etc to define the form
isItemsCustomLayout = false,
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)
columnsConfig, // Which columns are shown in Grid, so the inline editor can match. Used only for EDITOR_TYPE__INLINE
validator, // custom validator, mainly for EDITOR_TYPE__PLAIN
formHeader = null,
containerProps = {},
footerProps = {},
buttonGroupProps = {}, // buttons in footer
checkIsEditingDisabled = true,
disableLabels = false,
disableDirtyIcon = false,
alwaysShowCancelButton = false,
onBack,
onReset,
onInit,
onViewMode,
onValidityChange,
onDirtyChange,
submitBtnLabel,
onSubmit,
formSetup, // this fn will be executed after the form setup is complete
additionalEditButtons,
useAdditionalEditButtons = true,
additionalFooterButtons,
disableFooter = false,
hideResetButton = false,
showSelectHandle = false,
// sizing of outer container
maxHeight,
minHeight = 0,
maxWidth,
onLayout, // onLayout handler for main view
// withComponent
self,
// withData
Repository,
// withPermissions
canUser,
// withEditor
isEditorViewOnly = false,
isSaving = false,
getEditorMode = () => {},
onCancel,
onSave,
onClose,
onDelete,
editorStateRef,
disableView,
// parent container
selectorId,
selectorSelected,
selectorSelectedField,
// withAlert
alert,
} = props,
formRef = useRef(),
ancillaryItemsRef = useRef({}),
ancillaryButtons = useRef([]),
setAncillaryButtons = (array) => {
ancillaryButtons.current = array;
},
getAncillaryButtons = () => {
return ancillaryButtons.current;
},
styles = UiGlobals.styles,
record = props.record?.length === 1 ? props.record[0] : props.record;
let skipAll = false;
if (record?.isDestroyed) {
skipAll = true; // if record is destroyed, skip render, but allow hooks to still be called
// if (self?.parent?.parent?.setIsEditorShown) {
// self.parent.parent.setIsEditorShown(false); // close the editor
// }
}
const
isMultiple = _.isArray(record),
isSingle = _.isNil(record) || !_.isArray(record),
isPhantom = !skipAll && !!record?.isPhantom,
forceUpdate = useForceUpdate(),
[previousRecord, setPreviousRecord] = useState(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
};
}),
initialValues = _.merge(startingValues, (record && !record.isDestroyed ? record.submitValues : {})),
defaultValues = isMultiple ? getNullFieldValues(initialValues, Repository) : initialValues, // when multiple entities, set all default values to null
validatorToUse = (() => {
// If a custom validator is provided, use it
if (validator) {
return validator;
}
// If we have a Repository with a schema, use it (with modifications for multiple records)
if (Repository?.schema?.model?.validator) {
return isMultiple
? disableRequiredYupFields(Repository.schema.model.validator)
: Repository.schema.model.validator;
}
// For forms with no fields (like reports), create a schema that defaults to valid
if (!items || items.length === 0) {
return yup.object().default({}); // This will make the form valid by default
}
// Fallback to empty schema that allows any fields and defaults to valid
return yup.object().noUnknown(false).default({});
})(),
{
control,
formState,
handleSubmit,
// register,
// unregister,
reset,
// watch,
// resetField,
// setError,
// clearErrors,
setValue: formSetValue,
// setFocus,
getValues: formGetValues,
// getFieldState,
trigger,
} = useForm({
mode: 'onChange', // onChange | onBlur | onSubmit | onTouched | all
// reValidateMode: 'onChange', // onChange | onBlur | onSubmit
defaultValues,
// values: defaultValues,
// resetOptions: {
// keepDirtyValues: false, // user-interacted input will be retained
// keepErrors: false, // input errors will be retained with value update
// },
// criteriaMode: 'firstError', // firstError | all
// shouldFocusError: false,
// delayError: 0,
// shouldUnregister: false,
// shouldUseNativeValidation: false,
resolver: yupResolver(validatorToUse),
context: { isPhantom },
}),
buildFromColumnsConfig = () => {
// Only used in InlineEditor
// Build the fields that match the current columnsConfig in the grid
const
elements = [],
columnClassName = clsx(
'Form-column',
'justify-center',
'items-center',
'h-[60px]',
'border-r-1',
'border-r-grey-200',
'px-1',
styles.INLINE_EDITOR_MIN_WIDTH,
);
_.each(columnsConfig, (config, ix) => {
let {
fieldName,
isEditable = false,
editor = null,
editField,
renderer,
w,
flex,
onChange: onEditorChange,
useSelectorId = false,
getDynamicProps,
getIsRequired,
isHidden = false,
...configPropsToPass
} = config,
type,
editorTypeProps = {},
viewerTypeProps = {};
if (isHidden) {
return;
}
if (editField) {
// Sometimes, columns will be configured to display one field
// and edit a different field
fieldName = editField;
}
const propertyDef = fieldName && Repository?.getSchema().getPropertyDefinition(fieldName);
if (propertyDef?.isEditingDisabled && checkIsEditingDisabled) {
isEditable = false;
}
if (!_.isNil(editor)) {
// if editor is defined on column, use it
if (_.isString(editor)) {
type = editor;
} else if (_.isPlainObject(editor)) {
const {
type: t,
...p
} = editor;
type = t;
editorTypeProps = p;
}
} else {
// editor is not defined, fall back to property definition
if (isEditable) {
const {
type: t,
...p
} = propertyDef?.editorType;
type = t;
editorTypeProps = p;
} else if (propertyDef?.viewerType) {
const {
type: t,
...p
} = propertyDef?.viewerType;
type = t;
viewerTypeProps = p;
} else {
type = 'Text';
}
}
const isCombo = type?.match && type.match(/Combo/);
if (config.hasOwnProperty('autoLoad')) {
editorTypeProps.autoLoad = config.autoLoad;
} else {
if (isCombo && Repository?.isRemote && !Repository?.isLoaded) {
editorTypeProps.autoLoad = true;
}
}
if (isCombo) {
// editorTypeProps.showEyeButton = true;
if (_.isNil(configPropsToPass.showXButton)) {
editorTypeProps.showXButton = true;
}
}
const Element = getComponentFromType(type);
if (isEditorViewOnly || !isEditable) {
let value = null;
if (renderer) {
value = renderer(record);
} else {
if (record?.properties && record.properties[fieldName]) {
value = record.properties[fieldName].displayValue;
}
if (_.isNil(value) && record && record[fieldName]) {
value = record[fieldName];
}
if (_.isNil(value) && startingValues && startingValues[fieldName]) {
value = startingValues[fieldName];
}
}
let elementClassName = 'Form-ElementFromColumnsConfig';
if (type === 'Text') {
elementClassName += ' flex items-center justify-center';
}
const
boxFlex = configPropsToPass.flex,
boxW = configPropsToPass.w;
delete configPropsToPass.w;
delete configPropsToPass.flex;
const configPropsToPassClassName = configPropsToPass.className;
if (configPropsToPassClassName) {
elementClassName += ' ' + configPropsToPassClassName;
}
const viewerTypeClassName = viewerTypeProps.className;
if (viewerTypeClassName) {
elementClassName += ' ' + viewerTypeClassName;
}
let element = <Element
{...testProps('field-' + fieldName)}
value={value}
minimizeForRow={true}
parent={self}
reference={fieldName}
{...configPropsToPass}
{...viewerTypeProps}
className={elementClassName}
/>;
const style = {};
if (boxFlex) {
style.flex = boxFlex;
}
if (boxW) {
style.width = boxW;
}
elements.push(<Box
key={fieldName + '-' + ix}
className={columnClassName}
style={style}
>{element}</Box>);
return;
}
elements.push(<Controller
key={'controller-' + ix}
name={fieldName}
// rules={rules}
control={control}
render={(args) => {
const {
field,
fieldState,
// formState,
} = args,
{
onChange,
onBlur,
name,
value,
// ref,
} = field,
{
isTouched,
isDirty,
error,
} = fieldState;
if (isValidElement(Element)) {
throw new Error('Should not yet be valid React element. Did you use <Element> instead of () => <Element> when defining it?')
}
if (useSelectorId) { // This causes the whole form to use selectorId
editorTypeProps.selectorId = selectorId;
editorTypeProps.selectorSelectedField = selectorSelectedField;
}
if (configPropsToPass.selectorId || editorTypeProps.selectorId) { // editorTypeProps.selectorId causes just this one field to use selectorId
if (_.isNil(configPropsToPass.selectorSelected)) {
editorTypeProps.selectorSelected = record;
}
}
let dynamicProps = {};
if (getDynamicProps) {
dynamicProps = getDynamicProps({ fieldState, formSetValue, formGetValues, formState });
}
let elementClassName = 'Form-Element';
if (showSelectHandle && ix === 0) {
elementClassName += ' ml-[40px]';
}
if (type.match(/Tag/)) {
elementClassName += ' overflow-auto';
}
if (!type.match(/Toggle/)) {
elementClassName += ' h-full';
}
const configPropsToPassClassName = configPropsToPass.className;
if (configPropsToPassClassName) {
elementClassName += ' ' + configPropsToPassClassName;
}
const editorTypeClassName = editorTypeProps.className;
if (editorTypeClassName) {
elementClassName += ' ' + editorTypeClassName;
}
const dynamicClassName = dynamicProps.className;
if (dynamicClassName) {
elementClassName += ' ' + dynamicClassName;
}
let element = <Element
{...testProps('field-' + name)}
name={name}
value={value}
onChangeValue={(newValue) => {
if (newValue === undefined) {
newValue = null; // React Hook Form doesn't respond well when setting value to undefined
}
onChange(newValue);
if (onEditorChange) {
onEditorChange(newValue, formSetValue, formGetValues, formState, trigger);
}
}}
onBlur={onBlur}
minimizeForRow={true}
parent={self}
reference={name}
{...configPropsToPass}
{...editorTypeProps}
{...dynamicProps}
className={elementClassName}
/>;
const dirtyIcon = isDirty && !disableDirtyIcon ?
<Icon
as={Pencil}
size="2xs"
className={clsx(
'absolute',
'top-[2px]',
'left-[2px]',
'text-grey-300',
)}
/> : null;
return <HStack
key={fieldName + '-HStack-' + ix}
className={clsx(
'Form-HStack1',
`flex-${flex}`,
error ? 'bg-[#fdd]' : 'bg-white',
columnClassName,
)}
style={{
width: w,
}}
>{dirtyIcon}{element}</HStack>;
}}
/>);
});
return <HStack>{elements}</HStack>;
},
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,
isEditable = true,
isEditingEnabledInPlainEditor,
label,
labelWidth,
items,
onChange: onEditorChange,
useSelectorId = false,
isHidden = false,
getDynamicProps,
getIsRequired,
...itemPropsToPass
} = item,
editorTypeProps = {},
viewerTypeProps = {};
if (isHidden) {
return null;
}
if (type === 'DisplayField') {
isEditable = false;
}
if (!itemPropsToPass.className) {
itemPropsToPass.className = '';
}
const propertyDef = name && Repository?.getSchema().getPropertyDefinition(name);
if (!useAdditionalEditButtons) {
item = _.omit(item, 'additionalEditButtons');
}
if (propertyDef?.isEditingDisabled && checkIsEditingDisabled) {
isEditable = false;
}
if (isEditingEnabledInPlainEditor && editorType === EDITOR_TYPE__PLAIN) {
// If this is a plain editor, allow the field to be editable, even if it's not editable in other editor types
isEditable = true;
}
if (!type) {
if (isEditable) {
const {
type: t,
...p
} = propertyDef?.editorType;
type = t;
editorTypeProps = p;
} else if (propertyDef?.viewerType) {
const {
type: t,
...p
} = propertyDef?.viewerType;
type = t;
viewerTypeProps = p;
} else {
type = 'Text';
}
}
const isCombo = type?.match && type.match(/Combo/);
if (item.hasOwnProperty('autoLoad')) {
editorTypeProps.autoLoad = item.autoLoad;
} else {
if (isCombo && Repository?.isRemote && !Repository?.isLoaded) {
editorTypeProps.autoLoad = true;
}
}
if (isCombo) {
// editorTypeProps.showEyeButton = true;
if (_.isNil(itemPropsToPass.showXButton)) {
editorTypeProps.showXButton = 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 (type === 'Row') {
itemPropsToPass.className += ' Row w-full';
}
const itemDefaults = item.defaults || {};
children = _.map(items, (item, ix) => {
return buildFromItem(item, ix, {...defaults, ...itemDefaults});
});
let elementClassName = 'Form-ElementFromItem gap-2';
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 = editorTypeProps.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={'column-Element-' + type + '-' + ix}
title={title}
{...defaultsToPass}
{...itemDefaultsToPass}
{...itemPropsToPass}
{...editorTypeProps}
className={elementClassName}
style={style}
>{children}</Element>;
}
if (!label && Repository && propertyDef?.title) {
label = propertyDef.title;
}
if (isEditorViewOnly || !isEditable) {
let value = null;
if (isSingle) {
value = record?.properties[name]?.displayValue || null;
if (_.isNil(value) && record && record[name]) {
value = record[name];
}
if (_.isNil(value) && startingValues && startingValues[name]) {
value = startingValues[name];
}
}
let elementClassName = 'field-' + name;
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}
parent={self}
reference={name}
{...itemPropsToPass}
{...viewerTypeProps}
className={elementClassName}
/>;
if (!disableLabels && label) {
const style = {};
if (defaults?.labelWidth) {
style.width = defaults.labelWidth;
}
if (labelWidth) {
style.width = labelWidth;
}
if (containerWidth > styles.FORM_STACK_ROW_THRESHOLD) {
if (!style.width) {
style.width = '160px';
}
element = <HStack className="Form-HStack1 w-full py-1">
<Label style={style}>{label}</Label>
{element}
</HStack>;
} else {
element = <VStack className="Form-VStack2 w-full py-1 mt-3">
<Label style={style}>{label}</Label>
{element}
</VStack>;
}
}
return <HStack key={'Form-HStack3-' + ix} className="Form-HStack3 w-full px-2 pb-1">{element}</HStack>;
}
// // These rules are for fields *outside* the model
// // but which want validation on the form anyway.
// // The useForm() resolver disables this
// const
// rules = {},
// rulesToCheck = [
// 'required',
// 'min',
// 'max',
// 'minLength',
// 'maxLength',
// 'pattern',
// 'validate',
// ];
// _.each(rulesToCheck, (rule) => {
// if (item.hasOwnProperty(rule)) {
// rules[rule] = item[rule];
// }
// });
return <Controller
key={'controller-' + ix}
name={name}
// rules={rules}
control={control}
render={(args) => {
const {
field,
fieldState,
// formState,
} = args,
{
onChange,
onBlur,
name,
value,
// ref,
} = field,
{
isTouched,
isDirty,
error,
} = fieldState;
if (isValidElement(Element)) {
throw new Error('Should not yet be valid React element. Did you use <Element> instead of () => <Element> when defining it?')
}
if (useSelectorId) { // This causes the whole form to use selectorId
editorTypeProps.selectorId = selectorId;
editorTypeProps.selectorSelectedField = selectorSelectedField;
}
if (itemPropsToPass.selectorId || editorTypeProps.selectorId) { // editorTypeProps.selectorId causes just this one field to use selectorId
if (_.isNil(itemPropsToPass.selectorSelected)) {
editorTypeProps.selectorSelected = record;
}
}
let dynamicProps = {};
if (getDynamicProps) {
dynamicProps = getDynamicProps({ fieldState, formSetValue, formGetValues, formState });
}
let elementClassName = 'Form-Element field-' + name + ' w-full';
const defaultsClassName = defaults.className;
if (defaultsClassName) {
elementClassName += ' ' + defaultsClassName;
}
const itemPropsToPassClassName = itemPropsToPass.className;
if (itemPropsToPassClassName) {
elementClassName += ' ' + itemPropsToPassClassName;
}
const editorTypeClassName = editorTypeProps.className;
if (editorTypeClassName) {
elementClassName += ' ' + editorTypeClassName;
}
const dynamicClassName = dynamicProps.className;
if (dynamicClassName) {
elementClassName += ' ' + dynamicClassName;
}
let element = <Element
{...testProps('field-' + name)}
name={name}
value={value}
onChangeValue={(newValue) => {
if (newValue === undefined) {
newValue = null; // React Hook Form doesn't respond well when setting value to undefined
}
onChange(newValue);
if (onEditorChange) {
onEditorChange(newValue, formSetValue, formGetValues, formState, trigger);
}
}}
onBlur={onBlur}
parent={self}
reference={name}
{...defaults}
{...itemPropsToPass}
{...editorTypeProps}
{...dynamicProps}
className={elementClassName}
/>;
let message = null;
if (error) {
message = error.message;
if (label && error.ref?.name) {
message = message.replace(error.ref.name, label);
}
}
if (message) {
message = <Text className="text-[#f00]">{message}</Text>;
}
element = <VStack className="Form-VStack4 flex-1">
{element}
{message}
</VStack>;
if (item.additionalEditButtons) {
const buttons = buildAdditionalButtons(item.additionalEditButtons, self, { fieldState, formSetValue, formGetValues, formState });
if (containerWidth > styles.FORM_STACK_ROW_THRESHOLD) {
element = <HStack className="Form-HStack5 flex-1 flex-wrap items-center gap-2">
{element}
{buttons}
</HStack>;
} else {
element = <VStack className="Form-VStack6 flex-1">
{element}
<HStack className="Form-HStack7-VStack flex-1 mt-1 flex-wrap">
{buttons}
</HStack>
</VStack>;
}
}
let isRequired = false,
requiredIndicator = null;
if (!isMultiple) { // Don't require fields if editing multiple records
if (getIsRequired) {
isRequired = getIsRequired(formGetValues, formState);
} else if (validatorToUse?.fields && validatorToUse.fields[name]?.exclusiveTests?.required) {
// submitted validator
isRequired = true;
} else if ((propertyDef?.validator?.spec && !propertyDef.validator.spec.optional) ||
(propertyDef?.requiredIfPhantom && isPhantom) ||
(propertyDef?.requiredIfNotPhantom && !isPhantom)) {
// property definition
isRequired = true;
}
if (isRequired) {
requiredIndicator = <Text
className={clsx(
'Form-requiredIndicator',
'self-center',
'text-[#f00]',
'text-[30px]',
'pr-1',
)}
>*</Text>;
}
}
if (!disableLabels && label && editorType !== EDITOR_TYPE__INLINE) {
const style = {};
if (defaults?.labelWidth) {
style.width = defaults.labelWidth;
}
if (labelWidth) {
style.width = labelWidth;
}
if (containerWidth > styles.FORM_STACK_ROW_THRESHOLD) {
if (!style.width) {
style.width = '160px';
}
element = <HStack className="Form-HStack8 w-full">
<Label style={style}>
{requiredIndicator}
{label}
</Label>
{element}
</HStack>;
} else {
element = <VStack className="Form-VStack9 w-full mt-3">
<Label style={style}>
{requiredIndicator}
{label}
</Label>
{element}
</VStack>;
}
} else if (disableLabels && requiredIndicator) {
element = <HStack className="Form-HStack10 w-full">
{requiredIndicator}
{element}
</HStack>;
}
const dirtyIcon = isDirty && !disableDirtyIcon ?
<Icon
as={Pencil}
size="2xs"
className={clsx(
'absolute',
'top-[2px]',
'left-[2px]',
'text-grey-300',
)}
/> : null;
return <HStack
key={'Controller-HStack-' + ix}
className={clsx(
'Form-HStack11',
'min-h-[50px]',
'w-full',
'flex-none',
error ? 'bg-[#fdd]' : '',
)}
>
{dirtyIcon}
{element}
</HStack>;
}}
/>;
},
buildAncillary = () => {
const components = [];
setAncillaryButtons([]);
if (ancillaryItems.length) {
// add the "scroll to top" button
getAncillaryButtons().push({
key: 'scrollToTop',
icon: ArrowUp,
reference: 'scrollToTop',
onPress: () => scrollToAncillaryItem(0),
tooltip: 'Scroll to top',
});
_.each(ancillaryItems, (item, ix) => {
let {
type,
title = null,
description = null,
icon,
selectorId,
selectorSelectedField,
...itemPropsToPass
} = item,
titleElement;
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({
key: 'ancillaryBtn-' + ix,
icon,
onPress: () => scrollToAncillaryItem(ix +1), // offset for the "scroll to top" button
tooltip: title,
});
}
if (type.match(/Grid/) && !itemPropsToPass.h) {
itemPropsToPass.h = 400;
}
const
Element = getComponentFromType(type),
element = <Element
{...testProps('ancillary-' + type)}
selectorId={selectorId}
selectorSelectedField={selectorSelectedField}
selectorSelected={selectorSelected || record}
uniqueRepository={true}
parent={self}
{...itemPropsToPass}
/>;
if (title) {
if (record?.displayValue) {
title += ' for ' + record.displayValue;
}
titleElement = <Text
className={clsx(
'Form-Ancillary-Title',
'font-bold',
styles.FORM_ANCILLARY_TITLE_CLASSNAME
)}
>{title}</Text>;
if (icon) {
titleElement = <HStack className="items-center"><Icon as={icon} className="w-[32px] h-[32px] mr-2" />{titleElement}</HStack>
}
}
if (description) {
description = <Text
className={clsx(
'Form-Ancillary-Description',
'italic',
styles.FORM_ANCILLARY_DESCRIPTION_CLASSNAME
)}
>{description}</Text>;
}
components.push(<VStack
ref={(el) => (ancillaryItemsRef.current[ix +1 /* offset for "scroll to top" */] = el)}
key={'ancillary-' + ix}
className={clsx(
'Form-VStack12',
'mx-1',
'my-3'
)}
>
{titleElement}
{description}
{element}
</VStack>);
});
}
return components;
},
onSubmitError = (errors, e) => {
if (editorType === EDITOR_TYPE__INLINE) {
alert(errors.message);
}
},
doReset = (values) => {
reset(values);
if (onReset) {
onReset(values, formSetValue, formGetValues, trigger);
}
},
onSaveDecorated = async (data, e) => {
// reset the form after a save
const result = await onSave(data, e);
if (result) {
const values = record.submitValues;
doReset(values);
}
},
onSubmitDecorated = async (data, e) => {
const result = await onSubmit(data, e);
if (result) {
const values = record.submitValues;
doReset(values);
}
},
onLayoutDecorated = (e) => {
if (onLayout) {
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 (skipAll) {
return;
}
if (record === previousRecord) {
if (onInit) {
onInit(initialValues, formSetValue, formGetValues, trigger);
}
} else {
setPreviousRecord(record);
doReset(defaultValues);
}
if (formSetup) {
formSetup(formSetValue, formGetValues, formState, trigger);
}
}, [record]);
useEffect(() => {
if (skipAll) {
return;
}
if (!Repository) {
return () => {
if (!_.isNil(editorStateRef)) {
editorStateRef.current = null; // clean up the editorStateRef on unmount
}
};
}
Repository.ons(['changeData', 'change'], forceUpdate);
return () => {
Repository.offs(['changeData', 'change'], forceUpdate);
if (!_.isNil(editorStateRef)) {
editorStateRef.current = null; // clean up the editorStateRef on unmount
}
};
}, [Repository]);
if (onValidityChange) {
useEffect(() => {
onValidityChange(formState.isValid);
}, [formState.isValid]);
}
if (onDirtyChange) {
useEffect(() => {
onDirtyChange(formState.isDirty);
}, [formState.isDirty]);
}
useEffect(() => {
// For forms with no items (like some reports), manually trigger validation to set valid state
if ((!items || items.length === 0) && !columnsConfig) {
setTimeout(() => { // ensure the form is fully initialized
trigger().then(() => {
// Force validity to true for empty forms
if (onValidityChange) {
onValidityChange(true);
}
});
}, 0);
}
}, [items, columnsConfig, trigger, onValidityChange]);
if (skipAll) {
return null;
}
// if (Repository && (!record || _.isEmpty(record) || record.isDestroyed)) {
// return null;
// }
if (!_.isNil(editorStateRef)) {
editorStateRef.current = formState; // Update state so withEditor can know what's going on
}
if (self) {
self.ref = formRef;
self.reset = doReset;
self.submit = handleSubmit;
self.formState = formState;
self.formSetValue = formSetValue;
self.formGetValues = formGetValues;
}
const style = props.style || {};
if (!hasWidth(props) && !hasFlex(props)) {
style.flex = 1;
}
if (maxWidth) {
style.maxWidth = maxWidth;
}
if (maxHeight) {
style.maxHeight = maxHeight;
}
let modeHeader = null,
formButtons = null,
scrollButtons = null,
footer = null,
footerButtons = null,
formComponents,
editor,
additionalButtons,
fab = null,
isSaveDisabled = false,
isSubmitDisabled = false,
showDeleteBtn = false,
showResetBtn = false,
showCloseBtn = false,
showCancelBtn = false,
showSaveBtn = false,
showSubmitBtn = false;
if (containerWidth) { // we need to render this component twice in order to get the container width. Skip this on first render
// create editor
if (editorType === EDITOR_TYPE__INLINE) {
editor = buildFromColumnsConfig();
// } else if (editorType === EDITOR_TYPE__PLAIN) {
// formComponents = buildFromItems();
// const formAncillaryComponents = buildAncillary();
// editor = <>
// <VStack className="p-4">{formComponents}</VStack>
// <VStack className="pt-4">{formAncillaryComponents}</VStack>
// </>;
} else {
formComponents = buildFromItems();
const formAncillaryComponents = buildAncillary();
editor = <>
{containerWidth >= styles.FORM_ONE_COLUMN_THRESHOLD && !isItemsCustomLayout ? <HStack className="Form-formComponents-HStack p-4 gap-4 justify-center">{formComponents}</HStack> : null}
{containerWidth < styles.FORM_ONE_COLUMN_THRESHOLD || isItemsCustomLayout ? <VStack className="Form-formComponents-VStack p-4">{formComponents}</VStack> : null}
{formAncillaryComponents.length ? <VStack className="Form-AncillaryComponents m-2 pt-4 px-2">{formAncillaryComponents}</VStack> : null}
</>;
additionalButtons = buildAdditionalButtons(additionalEditButtons);
if (inArray(editorType, [EDITOR_TYPE__SIDE, EDITOR_TYPE__SMART, EDITOR_TYPE__WINDOWED]) &&
isSingle && getEditorMode() === EDITOR_MODE__EDIT &&
(onBack || (onViewMode && !disableView))) {
modeHeader = <Toolbar>
<HStack className="flex-1 items-center">
{onBack &&
<Button
{...testProps('backBtn')}
key="backBtn"
onPress={onBack}
icon={AngleLeft}
_icon={{
size: 'sm',
className: 'text-white',
}}
className={clsx(
'mr-4'
)}
text="Back"
/>}
<Text className="text-[20px] ml-1 text-grey-500">Edit Mode</Text>
</HStack>
{onViewMode && !disableView && (!canUser || canUser(VIEW)) &&
<Button
{...testProps('toViewBtn')}
key="viewBtn"
onPress={onViewMode}
icon={Eye}
_icon={{
size: 'sm',
className: 'text-white',
}}
text="To View"
tooltip="Switch to View Mode"
/>}
</Toolbar>;
}
if (getEditorMode() === EDITOR_MODE__EDIT && !_.isEmpty(additionalButtons)) {
formButtons = <Toolbar className="justify-end flex-wrap gap-2">
{additionalButtons}
</Toolbar>;
}
if (showAncillaryButtons && !_.isEmpty(getAncillaryButtons())) {
fab = <Animated.View style={fabAnimatedStyle}>
<DynamicFab
buttons={getAncillaryButtons()}
collapseOnPress={false}
className="bottom-[55px]"
tooltip="Scroll to Ancillary Item"
/>
</Animated.View>;
}
}
// create footer
if (!formState.isValid) {
isSaveDisabled = true;
isSubmitDisabled = true;
}
if (_.isEmpty(formState.dirtyFields) && !isPhantom) {
isSaveDisabled = true;
}
if (onDelete && getEditorMode() === EDITOR_MODE__EDIT && isSingle) {
showDeleteBtn = true;
}
if (!isEditorViewOnly && !hideResetButton) {
showResetBtn = true;
}
// determine whether we should show the close or cancel button
if (alwaysShowCancelButton) {
showCancelBtn = true;
} else {
if (editorType !== EDITOR_TYPE__SIDE) {
if (isEditorViewOnly) {
showCloseBtn = true;
} else {
// if (editorType === EDITOR_TYPE__WINDOWED && onCancel) {
// showCancelBtn = true;
// }
if (formState.isDirty || isPhantom) {
if (isSingle && onCancel) {
showCancelBtn = true;
}
} else {
if (onClose) {
showCloseBtn = true;
}
}
}
} else {
// side editor only
if (isPhantom && isSingle && onCancel) {
showCancelBtn = true;
}
}
}
if (!isEditorViewOnly && onSave) {
showSaveBtn = true;
}
if (!!onSubmit) {
showSubmitBtn = true;
}
footerButtons =
<>
{onDelete && getEditorMode() === EDITOR_MODE__EDIT && isSingle &&
<HStack className="flex-1 justify-start">
<Button
{...testProps('deleteBtn')}
key="deleteBtn"
onPress={onDelete}
icon={Trash}
className={clsx(
'bg-warning-500',
'hover:bg-warning-700',
'text-white',
)}
text="Delete"
/>
</HStack>}
{showResetBtn &&
<IconButton
{...testProps('resetBtn')}
key="resetBtn"
onPress={() => doReset()}
icon={Rotate}
isDisabled={!formState.isDirty}
/>}
{showCancelBtn &&
<Button
{...testProps('cancelBtn')}
key="cancelBtn"
variant={editorType === EDITOR_TYPE__INLINE ? 'solid' : 'outline'}
icon={Xmark}
onPress={onCancel}
className="text-white"
text="Cancel"
/>}
{showCloseBtn &&
<Button
{...testProps('closeBtn')}
key="closeBtn"
variant={editorType === EDITOR_TYPE__INLINE ? 'solid' : 'outline'}
icon={Xmark}
onPress={onClose}
className="text-white"
text="Close"
/>}
{showSaveBtn &&
<Button
{...testProps('saveBtn')}
key="saveBtn"
onPress={(e) => handleSubmit(onSaveDecorated, onSubmitError)(e)}
icon={getEditorMode() === EDITOR_MODE__ADD ? Plus : FloppyDiskRegular}
isDisabled={isSaveDisabled}
className="text-white"
text={(getEditorMode() === EDITOR_MODE__ADD ? 'Add' : 'Save') + (props.record?.length > 1 ? ` (${props.record.length})` : '')}
/>}
{showSubmitBtn &&
<Button
{...testProps('submitBtn')}
key="submitBtn"
icon={Check}
onPress={(e) => handleSubmit(onSubmitDecorated, onSubmitError)(e)}
isDisabled={isSubmitDisabled}
className="text-white"
text={submitBtnLabel || 'Submit'}
/>}
{additionalFooterButtons && _.map(additionalFooterButtons, (props, ix) => {
let isDisabled = false;
if (props.disableOnInvalid) {
isDisabled = !formState.isValid;
}
const key = 'additionalFooterBtn-' + ix;
return <Button
{...testProps(key)}
key={key}
{...props}
onPress={(e) => handleSubmit(props.onPress, onSubmitError)(e)}
icon={props.icon || null}
text={props.text}
isDisabled={isDisabled}
/>;
})}
</>;
if (editorType === EDITOR_TYPE__INLINE) {
footer =
<Box
className={clsx(
'Form-inlineFooter-container',
'relative',
'w-full',
)}
>
<HStack
className={clsx(
'Form-inlineFooter',
'absolute',
'top-[5px]',
'left-[40px]',
'w-[100px]',
'min-w-[300px]',
'py-2',
'gap-2',
'justify-center',
'items-center',
'rounded-b-lg',
'bg-primary-700',
)}
>{footerButtons}</HStack>
</Box>;
} else {
if (!disableFooter) {
let footerClassName = clsx(
'Form-Footer',
'justify-end',
'gap-2',
);
if (editorType === EDITOR_TYPE__INLINE) {
footerClassName += clsx(
'sticky',
'self-start',
'justify-center',
'bg-primary-100',
'rounded-b-lg',
);
}
if (isSaving) {
footerClassName += ' border-t-2 border-t-[#f00]'
}
if (footerProps.className) {
footerClassName += ' ' + footerProps.className;
}
footer = <Footer {...footerProps} className={footerClassName}>
{footerButtons}
</Footer>;
}
}
} // END if (containerWidth)
let className = props.className || '';
className += ' Form-VStackNative';
const scrollToTopAnchor = <Box ref={(el) => (ancillaryItemsRef.current[0] = el)} className="h-0" />;
return <VStackNative
ref={formRef}
{...testProps(self)}
style={style}
onLayout={onLayoutDecorated}
className={className}
>
{!!containerWidth && <>
{editorType === EDITOR_TYPE__INLINE &&
editor}
{editorType !== EDITOR_TYPE__INLINE &&
<ScrollView
className={clsx(
'Form-ScrollView',
'w-full',
'flex-1',
'pb-1',
`web:min-h-[${minHeight}px]`,
)}
onScroll={onScroll}
scrollEventThrottle={16 /* ms */}
contentContainerStyle={{
// height: '100%',
}}
>
{scrollToTopAnchor}
{modeHeader}
{formHeader}
{formButtons}
{showAncillaryButtons && !_.isEmpty(getAncillaryButtons()) &&
<Toolbar className="justify-start flex-wrap gap-2">
<Text>Scroll:</Text>
{buildAdditionalButtons(_.omitBy(getAncillaryButtons(), (btnConfig) => btnConfig.reference === 'scrollToTop'))}
</Toolbar>}
{editor}
</ScrollView>}
{footer}
{isFabVisible && fab}
</>}
</VStackNative>;
}
// helper fns
function disableRequiredYupFields(validator) {
// based on https://github.com/jquense/yup/issues/1466#issuecomment-944386480
if (!validator) {
return null;
}
const nextSchema = validator.clone();
return nextSchema.withMutation((next) => {
if (typeof next.fields === 'object' && next.fields != null) {
for (const key in next.fields) {
const nestedField = next.fields[key];
let nestedFieldNext = nestedField.notRequired();
if (Array.isArray(nestedField.conditions) && nestedField.conditions.length > 0) {
// Next is done to disable required() inside a condition
// https://github.com/jquense/yup/issues/1002
nestedFieldNext = nestedFieldNext.when('whatever', (unused, schema) => {
return schema.notRequired();
});
}
next.fields[key] = nestedFieldNext;
}
}
});
}
function getNullFieldValues(initialValues, Repository) {
const ret = {};
if (Repository) {
const properties = Repository.getSchema().model.properties;
_.each(properties, (propertyDef) => {
ret[propertyDef.name] = null;
});
} else {
// takes a JSON object of fieldValues and sets them all to null
_.each(initialValues, (value, field) => {
ret[field] = null;
});
}
return ret;
}
export const FormEditor = withComponent(withAlert(withEditor(Form)));
export default withComponent(withAlert(Form));