mirror of
https://github.com/hanzoai/sentinel.git
synced 2026-09-29 22:34:33 +00:00
483 lines
14 KiB
TypeScript
483 lines
14 KiB
TypeScript
import {
|
|
Fragment,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
} from 'react';
|
|
import {Observer} from 'mobx-react';
|
|
|
|
import type {AlertProps} from 'sentry/components/alert';
|
|
import {Button} from 'sentry/components/button';
|
|
import PanelAlert from 'sentry/components/panels/panelAlert';
|
|
import {t} from 'sentry/locale';
|
|
import {defined} from 'sentry/utils';
|
|
import {sanitizeQuerySelector} from 'sentry/utils/sanitizeQuerySelector';
|
|
|
|
import FieldGroup from '../fieldGroup';
|
|
import FieldControl from '../fieldGroup/fieldControl';
|
|
import type {FieldGroupProps} from '../fieldGroup/types';
|
|
import FormContext from '../formContext';
|
|
import type FormModel from '../model';
|
|
import {MockModel} from '../model';
|
|
import type {FieldValue} from '../types';
|
|
|
|
import FormFieldControlState from './controlState';
|
|
|
|
/**
|
|
* Some fields don't need to implement their own onChange handlers, in
|
|
* which case we will receive an Event, but if they do we should handle
|
|
* the case where they return a value as the first argument.
|
|
*/
|
|
const getValueFromEvent = (valueOrEvent?: FieldValue | MouseEvent, e?: MouseEvent) => {
|
|
const event = e || valueOrEvent;
|
|
const value = defined(e) ? valueOrEvent : event?.target?.value;
|
|
|
|
return {value, event};
|
|
};
|
|
|
|
/**
|
|
* This is a list of field properties that can accept a function taking the
|
|
* form model, that will be called to determine the value of the prop upon an
|
|
* observed change in the model.
|
|
*
|
|
* This uses mobx's observation of the models observable fields.
|
|
*/
|
|
|
|
// !!Warning!! - the order of these props matters, as they are checked in order that they appear.
|
|
// One instance of a test that relies on this order is accountDetails.spec.tsx.
|
|
const propsToObserve = [
|
|
'help',
|
|
'highlighted',
|
|
'inline',
|
|
'visible',
|
|
'disabled',
|
|
] satisfies Array<keyof FormFieldProps>;
|
|
|
|
interface FormFieldPropModel extends FormFieldProps {
|
|
model: FormModel;
|
|
}
|
|
|
|
type ObservedFn<_P, T> = (props: FormFieldPropModel) => T;
|
|
type ObservedFnOrValue<P, T> = T | ObservedFn<P, T>;
|
|
|
|
type ObserverdPropNames = (typeof propsToObserve)[number];
|
|
|
|
type ObservedPropResolver = [
|
|
ObserverdPropNames,
|
|
() => ResolvedObservableProps[ObserverdPropNames],
|
|
];
|
|
|
|
/**
|
|
* Construct the type for properties that may be given observed functions
|
|
*/
|
|
interface ObservableProps {
|
|
disabled?: ObservedFnOrValue<{}, FieldGroupProps['disabled']>;
|
|
help?: ObservedFnOrValue<{}, FieldGroupProps['help']>;
|
|
highlighted?: ObservedFnOrValue<{}, FieldGroupProps['highlighted']>;
|
|
inline?: ObservedFnOrValue<{}, FieldGroupProps['inline']>;
|
|
visible?: ObservedFnOrValue<{}, FieldGroupProps['visible']>;
|
|
}
|
|
|
|
/**
|
|
* The same ObservableProps, once they have been resolved
|
|
*/
|
|
interface ResolvedObservableProps {
|
|
disabled?: FieldGroupProps['disabled'];
|
|
help?: FieldGroupProps['help'];
|
|
highlighted?: FieldGroupProps['highlighted'];
|
|
inline?: FieldGroupProps['inline'];
|
|
visible?: FieldGroupProps['visible'];
|
|
}
|
|
|
|
// XXX(epurkhiser): Many of these props are duplicated in form types. The forms
|
|
// interfaces need some serious consolidation
|
|
|
|
interface BaseProps {
|
|
/**
|
|
* Used to render the actual control
|
|
*/
|
|
children: (renderProps) => React.ReactNode;
|
|
/**
|
|
* Name of the field
|
|
*/
|
|
name: string;
|
|
// TODO(ts): These are actually props that are needed for some lower
|
|
// component. We should let the rendering component pass these in instead
|
|
defaultValue?: FieldValue;
|
|
formatMessageValue?: boolean | Function;
|
|
/**
|
|
* Transform data when saving on blur.
|
|
*/
|
|
getData?: (value: any) => any;
|
|
/**
|
|
* Should hide error message?
|
|
*/
|
|
hideErrorMessage?: boolean;
|
|
onBlur?: (value, event) => void;
|
|
onChange?: (value, event) => void;
|
|
onKeyDown?: (value, event) => void;
|
|
placeholder?: ObservedFnOrValue<{}, React.ReactNode>;
|
|
|
|
resetOnError?: boolean;
|
|
/**
|
|
* The message to display when saveOnBlur is false
|
|
*/
|
|
saveMessage?:
|
|
| React.ReactNode
|
|
| ((props: PassthroughProps & {value: FieldValue}) => React.ReactNode);
|
|
/**
|
|
* The alert type to use when saveOnBlur is false
|
|
*/
|
|
saveMessageAlertType?: AlertProps['type'];
|
|
/**
|
|
* When the field is blurred should it automatically persist its value into
|
|
* the model. Will show a confirm button 'save' otherwise.
|
|
*/
|
|
saveOnBlur?: boolean;
|
|
|
|
/**
|
|
* A function producing an optional component with extra information.
|
|
*/
|
|
selectionInfoFunction?: (
|
|
props: PassthroughProps & {value: FieldValue; error?: string}
|
|
) => React.ReactNode;
|
|
/**
|
|
* Used in the form model to transform the value
|
|
*/
|
|
setValue?: (value: FieldValue, props?: any) => any;
|
|
/**
|
|
* Extra styles to apply to the field
|
|
*/
|
|
style?: React.CSSProperties;
|
|
/**
|
|
* Transform input when a value is set to the model.
|
|
*/
|
|
transformInput?: (value: any) => any;
|
|
// used in prettyFormString
|
|
validate?: Function;
|
|
}
|
|
|
|
export interface FormFieldProps
|
|
extends BaseProps,
|
|
ObservableProps,
|
|
Omit<FieldGroupProps, keyof ResolvedObservableProps | 'children'> {}
|
|
|
|
/**
|
|
* ResolvedProps do NOT include props which may be given functions that are
|
|
* reacted on. Resolved props are used inside of makeField.
|
|
*/
|
|
type ResolvedProps = BaseProps & FieldGroupProps;
|
|
|
|
type PassthroughProps = Omit<
|
|
ResolvedProps,
|
|
| 'className'
|
|
| 'name'
|
|
| 'hideErrorMessage'
|
|
| 'flexibleControlStateSize'
|
|
| 'saveOnBlur'
|
|
| 'saveMessage'
|
|
| 'saveMessageAlertType'
|
|
| 'selectionInfoFunction'
|
|
| 'hideControlState'
|
|
| 'defaultValue'
|
|
>;
|
|
|
|
function FormField(props: FormFieldProps) {
|
|
const initialProps = useRef(props);
|
|
|
|
const {name, onBlur, onChange, onKeyDown} = props;
|
|
|
|
const context = useContext(FormContext);
|
|
const inputRef = useRef<HTMLElement>();
|
|
|
|
const [model] = useState<FormModel>(
|
|
// XXX: MockModel doesn't fully implement the FormModel interface
|
|
() => context.form ?? (new MockModel(props) as any)
|
|
);
|
|
|
|
// XXX(epurkhiser): This is a ***HUGE*** hack.
|
|
//
|
|
// When this was a class component it would re-create the MockModel every
|
|
// time and inject the new props into it. Now that it's a FC we cannot just
|
|
// re-create the mockModel every time as it will invalidate many use*
|
|
// dependnecies.
|
|
//
|
|
// We get around this by just updating the mock model props every render so
|
|
// anything calling `getValue` or `getError` will have the latest controlled
|
|
// values.
|
|
//
|
|
// TODO(epurkhiser): IN the future you should not be able to use anything
|
|
// wrapping FormField without it being wrapped in a Form. There's just too
|
|
// many things that break.
|
|
if (context.form === undefined) {
|
|
(model as any).props = props;
|
|
}
|
|
|
|
// Register field within the model
|
|
useEffect(() => {
|
|
model.setFieldDescriptor(name, initialProps.current);
|
|
return () => model.removeField(name);
|
|
}, [model, name]);
|
|
|
|
/**
|
|
* Update field value in form model
|
|
*/
|
|
const handleChange = useCallback(
|
|
(...args) => {
|
|
const {value, event} = getValueFromEvent(...args);
|
|
onChange?.(value, event);
|
|
model.setValue(name, value);
|
|
},
|
|
[model, onChange, name]
|
|
);
|
|
|
|
/**
|
|
* Notify model of a field being blurred
|
|
*/
|
|
const handleBlur = useCallback(
|
|
(...args) => {
|
|
const {value, event} = getValueFromEvent(...args);
|
|
|
|
onBlur?.(value, event);
|
|
// Always call this, so model can decide what to do
|
|
model.handleBlurField(name, value);
|
|
},
|
|
[model, onBlur, name]
|
|
);
|
|
|
|
/**
|
|
* Handle keydown to trigger a save on Enter
|
|
*/
|
|
const handleKeyDown = useCallback(
|
|
(...args) => {
|
|
const {value, event} = getValueFromEvent(...args);
|
|
|
|
if (event.key === 'Enter') {
|
|
model.handleBlurField(name, value);
|
|
}
|
|
|
|
onKeyDown?.(value, event);
|
|
},
|
|
[model, onKeyDown, name]
|
|
);
|
|
|
|
/**
|
|
* Handle saving an individual field via UI button
|
|
*/
|
|
const handleSaveField = useCallback(
|
|
() => model.handleSaveField(name, model.getValue(name)),
|
|
[model, name]
|
|
);
|
|
|
|
const handleCancelField = useCallback(
|
|
() => model.handleCancelSaveField(name),
|
|
[model, name]
|
|
);
|
|
|
|
/**
|
|
* Attempts to autofocus input field if field's name is in url hash.
|
|
*
|
|
* The ref must be forwarded for this to work.
|
|
*/
|
|
const handleInputMount = useCallback(
|
|
(node: HTMLElement | null) => {
|
|
if (node && !inputRef.current) {
|
|
// TODO(mark) Clean this up. FormContext could include the location
|
|
const hash = window.location?.hash;
|
|
|
|
if (!hash) {
|
|
return;
|
|
}
|
|
|
|
if (hash !== `#${name}`) {
|
|
return;
|
|
}
|
|
|
|
// Not all form fields have this (e.g. Select fields)
|
|
if (typeof node.focus === 'function') {
|
|
node.focus();
|
|
}
|
|
}
|
|
|
|
inputRef.current = node ?? undefined;
|
|
},
|
|
[name]
|
|
);
|
|
|
|
const id = useMemo(() => sanitizeQuerySelector(name), [name]);
|
|
|
|
const makeField = useCallback(
|
|
(resolvedObservedProps?: ResolvedObservableProps) => {
|
|
const {
|
|
className,
|
|
hideErrorMessage,
|
|
flexibleControlStateSize,
|
|
saveMessage,
|
|
saveMessageAlertType,
|
|
selectionInfoFunction,
|
|
hideControlState,
|
|
// Don't pass `defaultValue` down to input fields, will be handled in
|
|
// form model
|
|
defaultValue: _defaultValue,
|
|
...otherProps
|
|
} = props;
|
|
|
|
const fieldProps = {...otherProps, ...resolvedObservedProps} as PassthroughProps;
|
|
|
|
const saveOnBlurFieldOverride =
|
|
typeof props.saveOnBlur !== 'undefined' && !props.saveOnBlur;
|
|
|
|
return (
|
|
<Fragment>
|
|
<FieldGroup
|
|
id={id}
|
|
className={className}
|
|
flexibleControlStateSize={flexibleControlStateSize}
|
|
{...fieldProps}
|
|
>
|
|
{({alignRight, disabled, inline}) => (
|
|
<FieldControl
|
|
inline={inline}
|
|
alignRight={alignRight}
|
|
flexibleControlStateSize={flexibleControlStateSize}
|
|
hideControlState={hideControlState}
|
|
controlState={
|
|
<FormFieldControlState
|
|
model={model}
|
|
name={name}
|
|
hideErrorMessage={hideErrorMessage}
|
|
/>
|
|
}
|
|
>
|
|
<Observer>
|
|
{() => {
|
|
const error = model.getError(name);
|
|
const value = model.getValue(name);
|
|
|
|
return (
|
|
<Fragment>
|
|
{props.children({
|
|
ref: handleInputMount,
|
|
...fieldProps,
|
|
model,
|
|
name,
|
|
id,
|
|
onKeyDown: handleKeyDown,
|
|
onChange: handleChange,
|
|
onBlur: handleBlur,
|
|
// Fixes react warnings about input switching from controlled to uncontrolled
|
|
// So force to empty string for null values
|
|
value: value === null ? '' : value,
|
|
error,
|
|
disabled,
|
|
initialData: model.initialData,
|
|
'aria-describedby': `${id}_help`,
|
|
})}
|
|
</Fragment>
|
|
);
|
|
}}
|
|
</Observer>
|
|
</FieldControl>
|
|
)}
|
|
</FieldGroup>
|
|
{selectionInfoFunction && (
|
|
<Observer>
|
|
{() => {
|
|
const error = model.getError(name);
|
|
const value = model.getValue(name);
|
|
|
|
const isVisible =
|
|
typeof fieldProps.visible === 'function'
|
|
? fieldProps.visible({...props, ...fieldProps} as ResolvedProps)
|
|
: true;
|
|
|
|
return (
|
|
<Fragment>
|
|
{isVisible
|
|
? selectionInfoFunction({...fieldProps, error, value})
|
|
: null}
|
|
</Fragment>
|
|
);
|
|
}}
|
|
</Observer>
|
|
)}
|
|
{saveOnBlurFieldOverride && (
|
|
<Observer>
|
|
{() => {
|
|
const showFieldSave = model.getFieldState(name, 'showSave');
|
|
const value = model.getValue(name);
|
|
|
|
if (!showFieldSave) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<PanelAlert
|
|
type={saveMessageAlertType}
|
|
trailingItems={
|
|
<Fragment>
|
|
<Button onClick={handleCancelField} size="xs">
|
|
{t('Cancel')}
|
|
</Button>
|
|
<Button priority="primary" size="xs" onClick={handleSaveField}>
|
|
{t('Save')}
|
|
</Button>
|
|
</Fragment>
|
|
}
|
|
>
|
|
{typeof saveMessage === 'function'
|
|
? saveMessage({...fieldProps, value})
|
|
: saveMessage}
|
|
</PanelAlert>
|
|
);
|
|
}}
|
|
</Observer>
|
|
)}
|
|
</Fragment>
|
|
);
|
|
},
|
|
[
|
|
handleBlur,
|
|
handleCancelField,
|
|
handleChange,
|
|
handleInputMount,
|
|
handleKeyDown,
|
|
handleSaveField,
|
|
id,
|
|
model,
|
|
name,
|
|
props,
|
|
]
|
|
);
|
|
|
|
const observedProps = propsToObserve
|
|
.filter(p => typeof props[p] === 'function')
|
|
.map<ObservedPropResolver>(p => [
|
|
p,
|
|
() => (props[p] as ObservedFn<{}, any>)({...props, model}),
|
|
]);
|
|
|
|
// This field has no properties that require observation to compute their
|
|
// value, this field is static and will not be re-rendered.
|
|
if (observedProps.length === 0) {
|
|
return makeField();
|
|
}
|
|
|
|
const resolveObservedProps = (
|
|
resolvedProps: ResolvedObservableProps,
|
|
[propName, resolve]: ObservedPropResolver
|
|
) => ({
|
|
...resolvedProps,
|
|
[propName]: resolve(),
|
|
});
|
|
|
|
return (
|
|
<Observer>{() => makeField(observedProps.reduce(resolveObservedProps, {}))}</Observer>
|
|
);
|
|
}
|
|
|
|
export default FormField;
|