import React, { FunctionComponent, useCallback, ReactElement, useRef, useEffect, } from 'react'; import memoizeOne from 'memoize-one'; import { withAnalyticsContext, withAnalyticsEvents, WithAnalyticsEventsProps, } from '@atlaskit/analytics-next'; import { ExtensionManifest } from '@atlaskit/editor-common'; import Form from '@atlaskit/form'; import { FieldDefinition, Parameters, OnSaveCallback, isTabGroup, } from '@atlaskit/editor-common/extensions'; import _isEqual from 'lodash/isEqual'; import { fireAnalyticsEvent, EVENT_TYPE, ACTION_SUBJECT, ACTION, } from '../../plugins/analytics'; import LoadingState from './LoadingState'; import Header from './Header'; import ErrorMessage from './ErrorMessage'; import { serialize, deserialize } from './transformers'; import { InjectedIntlProps, injectIntl } from 'react-intl'; import ButtonGroup from '@atlaskit/button/button-group'; import Button from '@atlaskit/button/custom-theme-button'; import { FormFooter } from '@atlaskit/form'; import { pluginKey as extensionPluginKey } from '../../plugins/extension/plugin-key'; import WithPluginState from '../WithPluginState'; import FormContent from './FormContent'; import { messages } from './messages'; import { OnFieldChange, ValidationErrors } from './types'; function ConfigForm({ canSave, errorMessage, extensionManifest, fields, firstVisibleFieldName, hasParsedParameters, intl, isLoading, onCancel, onFieldChange, parameters, submitting, }: { canSave: boolean; errorMessage: string | null; extensionManifest: ExtensionManifest; fields?: FieldDefinition[]; firstVisibleFieldName?: string; hasParsedParameters: boolean; isLoading: boolean; onCancel: () => void; onFieldChange: OnFieldChange; parameters: Parameters; submitting: boolean; } & InjectedIntlProps) { if (isLoading || (!hasParsedParameters && errorMessage === null)) { return ; } if (errorMessage || !fields) { return ; } return ( ( <>
)} /> ); } const ConfigFormIntl = injectIntl(ConfigForm); const WithOnFieldChange: FunctionComponent<{ getState: () => { values: Parameters; errors: Record }; autoSave: boolean; handleSubmit: (parameters: Parameters) => void; children: (onFieldChange: OnFieldChange) => ReactElement; }> = ({ getState, autoSave, handleSubmit, children }) => { const getStateRef = useRef< () => { values: Parameters; errors: ValidationErrors } >(getState); useEffect(() => { getStateRef.current = getState; }, [getState]); const handleFieldChange = useCallback( (name: string, isDirty: boolean) => { if (!autoSave) { return; } // Don't trigger submit if nothing actually changed if (!isDirty) { return; } const { errors, values } = getStateRef.current(); // Get only values that does not contain errors const validValues: Parameters = {}; for (const key of Object.keys(values)) { if (!errors[key]) { // not has error validValues[key] = values[key]; } } handleSubmit(validValues); }, [autoSave, handleSubmit], ); return children(handleFieldChange); }; type Props = { extensionManifest?: ExtensionManifest; fields?: FieldDefinition[]; parameters?: Parameters; autoSave?: boolean; autoSaveTrigger?: unknown; showHeader?: boolean; closeOnEsc?: boolean; onChange: OnSaveCallback; onCancel: () => void; errorMessage: string | null; isLoading?: boolean; } & WithAnalyticsEventsProps; type State = { hasParsedParameters: boolean; currentParameters: Parameters; firstVisibleFieldName?: string; }; class ConfigPanel extends React.Component { onFieldChange: OnFieldChange | null; constructor(props: Props) { super(props); this.state = { hasParsedParameters: false, currentParameters: {}, firstVisibleFieldName: props.fields ? this.getFirstVisibleFieldName(props.fields) : undefined, }; this.onFieldChange = null; } componentDidMount() { const { fields, parameters, createAnalyticsEvent } = this.props; this.parseParameters(fields, parameters); fireAnalyticsEvent(createAnalyticsEvent)({ payload: { action: ACTION.OPENED, actionSubject: ACTION_SUBJECT.CONFIG_PANEL, eventType: EVENT_TYPE.UI, attributes: {}, }, }); } componentWillUnmount() { fireAnalyticsEvent(this.props.createAnalyticsEvent)({ payload: { action: ACTION.CLOSED, actionSubject: ACTION_SUBJECT.CONFIG_PANEL, eventType: EVENT_TYPE.UI, attributes: {}, }, }); } componentDidUpdate(prevProps: Props) { const { parameters, fields, autoSaveTrigger } = this.props; if ( (parameters && parameters !== prevProps.parameters) || (fields && (!prevProps.fields || !_isEqual(fields, prevProps.fields))) ) { this.parseParameters(fields, parameters); } if (fields && (!prevProps.fields || !_isEqual(fields, prevProps.fields))) { this.setFirstVisibleFieldName(fields); } if (prevProps.autoSaveTrigger !== autoSaveTrigger) { if (this.onFieldChange) { this.onFieldChange('', true); } } } handleKeyDown = (e: React.KeyboardEvent) => { if ((e.key === 'Esc' || e.key === 'Escape') && this.props.closeOnEsc) { this.props.onCancel(); } }; // https://product-fabric.atlassian.net/browse/DST-2697 // workaround for DST-2697, remove this function once fix. backfillTabFormData = ( fields: FieldDefinition[], formData: Parameters, currentParameters: Parameters, ): Parameters => { const mergedTabGroups = fields.filter(isTabGroup).reduce( (acc, field) => ({ ...acc, [field.name]: { ...(currentParameters[field.name] || {}), ...(formData[field.name] || {}), }, }), {}, ); return { ...formData, ...mergedTabGroups }; }; handleSubmit = async (formData: Parameters) => { const { fields, extensionManifest, onChange } = this.props; if (!extensionManifest || !fields) { return; } try { const serializedData = await serialize( extensionManifest, this.backfillTabFormData( fields, formData, this.state.currentParameters, ), fields, ); onChange(serializedData); } catch (error) { // eslint-disable-next-line no-console console.error(`Error serializing parameters`, error); } }; parseParameters = async ( fields?: FieldDefinition[], parameters?: Parameters, ) => { const { extensionManifest } = this.props; if (!extensionManifest || !fields || fields.length === 0) { // do not parse while fields are not returned return; } if (typeof parameters === 'undefined') { this.setState({ currentParameters: {}, hasParsedParameters: true }); return; } const currentParameters = await deserialize( extensionManifest, parameters, fields, ); this.setState({ currentParameters, hasParsedParameters: true }); }; // memoized to prevent rerender on new parameters renderHeader = memoizeOne((extensionManifest: ExtensionManifest) => { const { onCancel, showHeader } = this.props; if (!showHeader) { return null; } return (
); }); getFirstVisibleFieldName = memoizeOne((fields: FieldDefinition[]) => { function nonHidden(field: FieldDefinition) { if ('isHidden' in field) { return !field.isHidden; } return true; } // finds the first visible field, true for FieldSets too const firstVisibleField = fields.find(nonHidden); let newFirstVisibleFieldName; if (firstVisibleField) { // if it was a fieldset, go deeper trying to locate the field if (firstVisibleField.type === 'fieldset') { const firstVisibleFieldWithinFieldset = firstVisibleField.fields.find( nonHidden, ); newFirstVisibleFieldName = firstVisibleFieldWithinFieldset && firstVisibleFieldWithinFieldset.name; } else { newFirstVisibleFieldName = firstVisibleField.name; } } return newFirstVisibleFieldName; }); setFirstVisibleFieldName = (fields: FieldDefinition[]) => { const newFirstVisibleFieldName = this.getFirstVisibleFieldName(fields); if (newFirstVisibleFieldName !== this.state.firstVisibleFieldName) { this.setState({ firstVisibleFieldName: newFirstVisibleFieldName, }); } }; render() { const { extensionManifest } = this.props; if (!extensionManifest) { return ; } const { autoSave, errorMessage, fields, isLoading, onCancel } = this.props; const { currentParameters, hasParsedParameters, firstVisibleFieldName, } = this.state; const { handleSubmit, handleKeyDown } = this; return (
{({ formProps, getState, submitting }) => { return ( {(onFieldChange) => { this.onFieldChange = onFieldChange; return ( {this.renderHeader(extensionManifest)} ); }}
); }} ); } } export default withAnalyticsContext({ source: 'ConfigPanel' })( withAnalyticsEvents()(ConfigPanel), );