123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297 |
- import {useCallback, useEffect, useMemo, useState} from 'react';
- import styled from '@emotion/styled';
- import {Observer} from 'mobx-react';
- import {Button, ButtonProps} from 'sentry/components/button';
- import FormContext from 'sentry/components/forms/formContext';
- import FormModel, {FormOptions} from 'sentry/components/forms/model';
- import {Data, OnSubmitCallback} from 'sentry/components/forms/types';
- import Panel from 'sentry/components/panels/panel';
- import {t} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import {isRenderFunc} from 'sentry/utils/isRenderFunc';
- type RenderProps = {
- model: FormModel;
- };
- type RenderFunc = (props: RenderProps) => React.ReactNode;
- export interface FormProps
- extends Pick<
- FormOptions,
- | 'allowUndo'
- | 'resetOnError'
- | 'saveOnBlur'
- | 'apiEndpoint'
- | 'apiMethod'
- | 'onFieldChange'
- | 'onSubmitError'
- | 'onSubmitSuccess'
- > {
- additionalFieldProps?: {[key: string]: any};
- cancelLabel?: string;
- children?: React.ReactNode | RenderFunc;
- className?: string;
- 'data-test-id'?: string;
- extraButton?: React.ReactNode;
- footerClass?: string;
- footerStyle?: React.CSSProperties;
- hideFooter?: boolean;
- initialData?: Data;
- /**
- * A FormModel instance. If undefined a FormModel will be created for you.
- */
- model?: FormModel;
- /**
- * Callback fired when the form is cancelled via the cancel button.
- */
- onCancel?: (e: React.MouseEvent) => void;
- onPreSubmit?: () => void;
- /**
- * Callback to handle form submission.
- *
- * Defining this prop will replace the normal API submission behavior
- * and instead only call the provided callback.
- *
- * Your callback is expected to call `onSubmitSuccess` when the action succeeds and
- * `onSubmitError` when the action fails.
- */
- onSubmit?: OnSubmitCallback;
- /**
- * Ensure the form model isn't reset when the form unmounts
- */
- preventFormResetOnUnmount?: boolean;
- /**
- * Are changed required before the form can be submitted.
- */
- requireChanges?: boolean;
- /**
- * If set to true, preventDefault is not called
- */
- skipPreventDefault?: boolean;
- /**
- * Should the submit button be disabled.
- */
- submitDisabled?: boolean;
- submitLabel?: string;
- submitPriority?: ButtonProps['priority'];
- }
- function Form({
- 'data-test-id': dataTestId,
- allowUndo,
- apiEndpoint,
- apiMethod,
- cancelLabel,
- children,
- className,
- extraButton,
- footerClass,
- footerStyle,
- hideFooter,
- initialData,
- model,
- onCancel,
- onFieldChange,
- onPreSubmit,
- onSubmit,
- onSubmitError,
- onSubmitSuccess,
- preventFormResetOnUnmount,
- requireChanges,
- resetOnError,
- saveOnBlur,
- skipPreventDefault,
- submitDisabled,
- submitLabel,
- submitPriority,
- }: FormProps) {
- const [formModel] = useState(() => {
- const resolvedModel = model ?? new FormModel();
- // XXX(epurkhiser): We do this as part of the state construction to ensure
- // model data and options are set immediately
- //
- // TODO(epurkhiser): To have options and initialData be reactive properties
- // we'll have to make some changes to the cosnumers of models.
- if (initialData) {
- resolvedModel.setInitialData(initialData);
- }
- resolvedModel.setFormOptions({
- resetOnError,
- allowUndo,
- onFieldChange,
- onSubmitSuccess,
- onSubmitError,
- saveOnBlur,
- apiEndpoint,
- apiMethod,
- });
- return resolvedModel;
- });
- // Reset form model on un,out
- useEffect(
- () => () => {
- if (!preventFormResetOnUnmount) {
- formModel.reset();
- }
- },
- [formModel, preventFormResetOnUnmount]
- );
- const contextData = useMemo(
- () => ({saveOnBlur, form: formModel}),
- [formModel, saveOnBlur]
- );
- const handleSubmitSuccess = useCallback(
- data => {
- formModel.submitSuccess(data);
- onSubmitSuccess?.(data, formModel);
- },
- [formModel, onSubmitSuccess]
- );
- const handleSubmitError = useCallback(
- error => {
- formModel.submitError(error);
- onSubmitError?.(error, formModel);
- },
- [formModel, onSubmitError]
- );
- const handleSubmit = useCallback(
- e => {
- if (!skipPreventDefault) {
- e.preventDefault();
- }
- if (formModel.isSaving) {
- return;
- }
- onPreSubmit?.();
- onSubmit?.(
- formModel.getData(),
- handleSubmitSuccess,
- handleSubmitError,
- e,
- formModel
- );
- if (!onSubmit) {
- formModel.saveForm();
- }
- },
- [
- formModel,
- handleSubmitError,
- handleSubmitSuccess,
- onPreSubmit,
- onSubmit,
- skipPreventDefault,
- ]
- );
- const shouldShowFooter = typeof hideFooter !== 'undefined' ? !hideFooter : !saveOnBlur;
- return (
- <FormContext.Provider value={contextData}>
- <form
- onSubmit={handleSubmit}
- className={className ?? 'form-stacked'}
- data-test-id={dataTestId}
- >
- <div>
- {isRenderFunc<RenderFunc>(children) ? children({model: formModel}) : children}
- </div>
- {shouldShowFooter && (
- <StyledFooter
- className={footerClass}
- style={footerStyle}
- saveOnBlur={saveOnBlur}
- >
- {extraButton}
- <DefaultButtons>
- {onCancel && (
- <Observer>
- {() => (
- <Button
- disabled={formModel.isSaving}
- onClick={onCancel}
- style={{marginLeft: 5}}
- >
- {cancelLabel ?? t('Cancel')}
- </Button>
- )}
- </Observer>
- )}
- <Observer>
- {() => (
- <Button
- data-test-id="form-submit"
- priority={submitPriority ?? 'primary'}
- disabled={
- formModel.isError ||
- formModel.isSaving ||
- submitDisabled ||
- (requireChanges ? !formModel.formChanged : false)
- }
- type="submit"
- >
- {submitLabel ?? t('Save Changes')}
- </Button>
- )}
- </Observer>
- </DefaultButtons>
- </StyledFooter>
- )}
- </form>
- </FormContext.Provider>
- );
- }
- export default Form;
- const StyledFooter = styled('div')<{saveOnBlur?: boolean}>`
- display: flex;
- justify-content: flex-end;
- margin-top: 25px;
- border-top: 1px solid ${p => p.theme.innerBorder};
- background: none;
- padding: 16px 0 0;
- margin-bottom: 16px;
- ${p =>
- !p.saveOnBlur &&
- `
- ${Panel} & {
- margin-top: 0;
- padding-right: ${space(2)}
- }
- /* Better padding with form inside of a modal */
- [role='document'] & {
- padding-right: 30px;
- margin-left: -30px;
- margin-right: -30px;
- margin-bottom: -30px;
- margin-top: 16px;
- padding-bottom: 16px;
- }
- `};
- `;
- const DefaultButtons = styled('div')`
- display: grid;
- gap: ${space(1)};
- grid-auto-flow: column;
- justify-content: flex-end;
- flex: 1;
- `;
|