superdesk/superdesk-client-core

View on GitHub
scripts/apps/authoring-react/authoring-react.tsx

Summary

Maintainability
F
1 wk
Test Coverage
import React from 'react';
import {
    IArticle,
    IAuthoringFieldV2,
    IContentProfileV2,
    IAuthoringAction,
    IVocabularyItem,
    IAuthoringStorage,
    IFieldsAdapter,
    IBaseRestApiResponse,
    IStorageAdapter,
    IPropsAuthoring,
    ITopBarWidget,
    IExposedFromAuthoring,
    IKeyBindings,
    IAuthoringOptions,
    IStoreValueIncomplete,
    IAuthoringSectionTheme,
} from 'superdesk-api';
import {
    ButtonGroup,
    Loader,
    SubNav,
    IconButton,
} from 'superdesk-ui-framework/react';
import * as Layout from 'superdesk-ui-framework/react/components/Layouts';
import {gettext} from 'core/utils';
import {AuthoringSection} from './authoring-section/authoring-section';
import {EditorTest} from './ui-framework-authoring-test';
import {uiFrameworkAuthoringPanelTest, appConfig} from 'appConfig';
import {widgetReactIntegration} from 'apps/authoring/widgets/widgets';
import {AuthoringWidgetLayoutComponent} from './widget-layout-component';
import {WidgetHeaderComponent} from './widget-header-component';
import {registerToReceivePatches, unregisterFromReceivingPatches} from 'apps/authoring-bridge/receive-patches';
import {addInternalEventListener} from 'core/internal-events';
import {
    showUnsavedChangesPrompt,
    IUnsavedChangesActionWithSaving,
} from 'core/ui/components/prompt-for-unsaved-changes';
import {assertNever} from 'core/helpers/typescript-helpers';
import {WithInteractiveArticleActionsPanel} from 'core/interactive-article-actions-panel/index-hoc';
import {sdApi} from 'api';
import {AuthoringToolbar} from './subcomponents/authoring-toolbar';
import {addInternalWebsocketEventListener, addWebsocketEventListener} from 'core/notification/notification';
import {AUTHORING_FIELD_PREFERENCES} from 'core/constants';
import {AuthoringActionsMenu} from './subcomponents/authoring-actions-menu';
import {Map} from 'immutable';
import {getField} from 'apps/fields';
import {preferences} from 'api/preferences';
import {dispatchEditorEvent, addEditorEventListener} from './authoring-react-editor-events';
import {previewAuthoringEntity} from './preview-article-modal';
import {WithKeyBindings} from './with-keybindings';
import {IFontSizeOption, ITheme, ProofreadingThemeModal} from './toolbar/proofreading-theme-modal';
import {showModal} from '@superdesk/common';
import ng from 'core/services/ng';
import {focusFirstChildInput} from 'utils/focus-first-child-input';

export function getFieldsData<T>(
    item: T,
    fields: Map<string, IAuthoringFieldV2>,
    fieldsAdapter: IFieldsAdapter<T>,
    authoringStorage: IAuthoringStorage<T>,
    storageAdapter: IStorageAdapter<T>,
    language: string,
) {
    return fields.map((field) => {
        const fieldEditor = getField(field.fieldType);

        const storageValue = (() => {
            if (fieldsAdapter[field.id]?.retrieveStoredValue != null) {
                return fieldsAdapter[field.id].retrieveStoredValue(item, authoringStorage);
            } else {
                return storageAdapter.retrieveStoredValue(item, field.id, field.fieldType);
            }
        })();

        const operationalValue = (() => {
            if (fieldEditor.toOperationalFormat != null) {
                return fieldEditor.toOperationalFormat(
                    storageValue,
                    field.fieldConfig,
                    language,
                );
            } else {
                return storageValue;
            }
        })();

        return operationalValue;
    }).toMap();
}

function serializeFieldsDataAndApplyOnEntity<T extends IBaseRestApiResponse>(
    item: T,
    fieldsProfile: Map<string, IAuthoringFieldV2>,
    fieldsData: Map<string, unknown>,
    userPreferencesForFields: {[key: string]: unknown},
    fieldsAdapter: IFieldsAdapter<T>,
    storageAdapter: IStorageAdapter<T>,
    preferIncomplete: IStoreValueIncomplete,
): T {
    let result: T = item;

    fieldsProfile.forEach((field) => {
        const fieldEditor = getField(field.fieldType);
        const valueOperational = fieldsData.get(field.id);

        const storageValue = (() => {
            if (fieldEditor.toStorageFormat != null) {
                return fieldEditor.toStorageFormat(
                    valueOperational,
                    field.fieldConfig,
                );
            } else {
                return valueOperational;
            }
        })();

        if (fieldsAdapter[field.id]?.storeValue != null) {
            result = fieldsAdapter[field.id].storeValue(storageValue, result, field.fieldConfig, preferIncomplete);
        } else {
            result = storageAdapter.storeValue(storageValue, field.id, result, field.fieldConfig, field.fieldType);
        }
    });

    return result;
}

const SPELLCHECKER_PREFERENCE = 'spellchecker:status';

const ANPA_CATEGORY = {
    vocabularyId: 'categories',
    fieldId: 'anpa_category',
};

function getInitialState<T extends IBaseRestApiResponse>(
    item: {saved: T; autosaved: T},
    profile: IContentProfileV2,
    userPreferencesForFields: IStateLoaded<T>['userPreferencesForFields'],
    spellcheckerEnabled: boolean,
    fieldsAdapter: IFieldsAdapter<T>,
    authoringStorage: IAuthoringStorage<T>,
    storageAdapter: IStorageAdapter<T>,
    language: string,
    validationErrors: IAuthoringValidationErrors,
    defaultTheme: ITheme,
    proofReadingTheme: ITheme,
): IStateLoaded<T> {
    const allFields = profile.header.merge(profile.content);

    const itemOriginal = item.saved;
    const itemWithChanges = item.autosaved ?? itemOriginal;

    const fieldsOriginal = getFieldsData(
        itemOriginal,
        allFields,
        fieldsAdapter,
        authoringStorage,
        storageAdapter,
        language,
    );

    const fieldsDataWithChanges: Map<string, unknown> = itemOriginal === itemWithChanges
        ? fieldsOriginal
        : getFieldsData(
            itemWithChanges,
            allFields,
            fieldsAdapter,
            authoringStorage,
            storageAdapter,
            language,
        );

    const toggledFields = {};

    allFields
        .filter((field) => field.fieldConfig.allow_toggling === true)
        .forEach((field) => {
            const val = fieldsDataWithChanges.get(field.id);

            const FieldEditorConfig = getField(field.fieldType);

            toggledFields[field.id] = FieldEditorConfig.hasValue(val);
        });

    const initialState: IStateLoaded<T> = {
        initialized: true,
        loading: false,
        itemOriginal: itemOriginal,
        itemWithChanges: itemWithChanges,
        autosaveEtag: item.autosaved?._etag ?? null,
        fieldsDataOriginal: fieldsOriginal,
        fieldsDataWithChanges: fieldsDataWithChanges,
        profile: profile,
        toggledFields: toggledFields,
        userPreferencesForFields,
        spellcheckerEnabled,
        validationErrors: validationErrors,
        allThemes: {
            default: defaultTheme,
            proofreading: proofReadingTheme,
        },
        proofreadingEnabled: false,
    };

    return initialState;
}

function getKeyBindingsFromActions<T>(actions: Array<ITopBarWidget<T>>): IKeyBindings {
    return actions
        .filter((action) => action.keyBindings != null)
        .reduce((acc, action) => {
            return {
                ...acc,
                ...action.keyBindings,
            };
        }, {});
}

export const getUiThemeFontSize = (value: IFontSizeOption) => {
    if (value === 'small') {
        return '1.4rem';
    } else if (value === 'medium') {
        return '1.6rem';
    } else if (value === 'large') {
        return '1.8rem';
    } else {
        assertNever(value);
    }
};

export const getUiThemeFontSizeHeading = (value: IFontSizeOption) => {
    if (value === 'small') {
        return '2.3rem';
    } else if (value === 'medium') {
        return '2.8rem';
    } else if (value === 'large') {
        return '3.2rem';
    } else {
        assertNever(value);
    }
};

/**
 * Toggling a field "off" hides it and removes its values.
 * Toggling to "on", displays field's input and allows setting a value.
 *
 * Only fields that have toggling enabled in content profile will be present in this object.
 * `true` means field is available - `false` - hidden.
 */
export type IToggledFields = {[fieldId: string]: boolean};
export type IAuthoringValidationErrors = {[fieldId: string]: string};

interface IStateLoaded<T> {
    initialized: true;
    itemOriginal: T;
    itemWithChanges: T;
    autosaveEtag: string | null;
    fieldsDataOriginal: Map<string, unknown>;
    fieldsDataWithChanges: Map<string, unknown>;
    profile: IContentProfileV2;
    userPreferencesForFields: {[key: string]: unknown};
    toggledFields: IToggledFields;
    spellcheckerEnabled: boolean;
    validationErrors: IAuthoringValidationErrors;

    /**
     * Prevents changes to state while async operation is in progress(e.g. saving).
     */
    loading: boolean;
    allThemes: {default: ITheme, proofreading: ITheme};
    proofreadingEnabled: boolean;
}

type IState<T> = {initialized: false} | IStateLoaded<T>;

export class AuthoringReact<T extends IBaseRestApiResponse> extends React.PureComponent<IPropsAuthoring<T>, IState<T>> {
    private cleanupFunctionsToRunBeforeUnmounting: Array<() => void>;
    private _mounted: boolean;
    private componentRef: HTMLElement | null;

    constructor(props: IPropsAuthoring<T>) {
        super(props);

        this.state = {
            initialized: false,
        };

        this.save = this.save.bind(this);
        this.forceLock = this.forceLock.bind(this);
        this.discardUnsavedChanges = this.discardUnsavedChanges.bind(this);
        this.initiateClosing = this.initiateClosing.bind(this);
        this.handleFieldChange = this.handleFieldChange.bind(this);
        this.handleFieldsDataChange = this.handleFieldsDataChange.bind(this);
        this.handleUnsavedChanges = this.handleUnsavedChanges.bind(this);
        this.computeLatestEntity = this.computeLatestEntity.bind(this);
        this.setUserPreferences = this.setUserPreferences.bind(this);
        this.cancelAutosave = this.cancelAutosave.bind(this);
        this.getVocabularyItems = this.getVocabularyItems.bind(this);
        this.toggleField = this.toggleField.bind(this);
        this.updateItemWithChanges = this.updateItemWithChanges.bind(this);
        this.showThemeConfigModal = this.showThemeConfigModal.bind(this);
        this.onItemChange = this.onItemChange.bind(this);
        this.setLoadingState = this.setLoadingState.bind(this);
        this.setRef = this.setRef.bind(this);

        const setStateOriginal = this.setState.bind(this);

        this.setState = (...args) => {
            const {state} = this;

            // disallow changing state while loading (for example when saving is in progress)
            const allow: boolean = (() => {
                if (state.initialized !== true) {
                    return true;
                } else if (args[0]['loading'] === false) {
                    // it is allowed to change state while loading
                    // only if it sets loading to false
                    return true;
                } else {
                    return state.loading === false;
                }
            })();

            if (allow) {
                setStateOriginal(...args);
            }
        };

        widgetReactIntegration.pinWidget = () => {
            this.props.onSideWidgetChange({
                ...this.props.sideWidget,
                pinned: !(this.props.sideWidget?.pinned ?? false),
            });
        };

        widgetReactIntegration.getActiveWidget = () => {
            return this.props.sideWidget?.name ?? null;
        };

        widgetReactIntegration.getPinnedWidget = () => {
            const pinned = this.props.sideWidget?.pinned === true;

            if (pinned) {
                return this.props.sideWidget.name;
            } else {
                return null;
            }
        };

        widgetReactIntegration.closeActiveWidget = () => {
            this.props.onSideWidgetChange(null);
        };

        widgetReactIntegration.WidgetHeaderComponent = WidgetHeaderComponent;
        widgetReactIntegration.WidgetLayoutComponent = AuthoringWidgetLayoutComponent;

        widgetReactIntegration.disableWidgetPinning = props.disableWidgetPinning ?? false;

        this.cleanupFunctionsToRunBeforeUnmounting = [];

        this.componentRef = null;
    }

    setRef(ref: HTMLElement) {
        this.componentRef = ref;
    }

    setLoadingState(state: IStateLoaded<T>, loading: boolean): Promise<void> {
        return new Promise<void>((resolve) => {
            this.setState({
                ...state,
                loading,
            }, () => {
                setTimeout(() => {
                    /**
                     * Timeout is used to wait until the view re-renders with a loading indicator.
                     * This is a workaround for rare scenarios where a field has a lot of data
                     * and takes a long time to synchronously serialize to storage format causing
                     * the browser to lock up for some time.
                     *
                     * Without the timeout, loading indicator would only get shown AFTER the long task had finished.
                     */
                    resolve();
                });
            });
        });
    }

    initiateUnmounting(): Promise<void> {
        if (!this.state.initialized) {
            return Promise.resolve();
        } else {
            return this.props.authoringStorage.autosave.flush();
        }
    }

    cancelAutosave(): Promise<void> {
        const {authoringStorage} = this.props;

        authoringStorage.autosave.cancel();

        if (this.state.initialized && this.state.autosaveEtag != null) {
            return authoringStorage.autosave.delete(this.state.itemOriginal['_id'], this.state.autosaveEtag);
        } else {
            return Promise.resolve();
        }
    }

    private showThemeConfigModal(state: IStateLoaded<T>) {
        showModal(({closeModal}) => {
            return (
                <ProofreadingThemeModal
                    onHide={closeModal}
                    onThemeChange={(res) => {
                        this.setState({
                            ...state,
                            allThemes: {
                                default: res.default,
                                proofreading: res.proofreading,
                            },
                        });
                    }}
                />
            );
        });
    }

    /**
     * This is a relatively computationally expensive operation that serializes all fields.
     * It is meant to be called when an article is to be saved/autosaved.
     */
    computeLatestEntity(options?: {preferIncomplete?: IStoreValueIncomplete}): T {
        const state = this.state;

        if (state.initialized !== true) {
            throw new Error('Authoring not initialized');
        }

        const allFields = state.profile.header.merge(state.profile.content);

        const itemWithFieldsApplied = serializeFieldsDataAndApplyOnEntity(
            state.itemWithChanges,
            allFields,
            state.fieldsDataWithChanges,
            state.userPreferencesForFields,
            this.props.fieldsAdapter,
            this.props.storageAdapter,
            options?.preferIncomplete ?? false,
        );

        return itemWithFieldsApplied;
    }

    handleFieldChange(fieldId: string, data: unknown) {
        const {state} = this;

        if (state.initialized !== true) {
            throw new Error('can not change field value when authoring is not initialized');
        }

        const {onFieldChange} = this.props;
        const fieldsDataUpdated = state.fieldsDataWithChanges.set(fieldId, data);

        this.setState({
            ...state,
            fieldsDataWithChanges: onFieldChange == null
                ? fieldsDataUpdated
                : onFieldChange(fieldId, fieldsDataUpdated, this.computeLatestEntity),
        });
    }

    handleFieldsDataChange(fieldsData: Map<string, unknown>): void {
        const {state} = this;

        if (state.initialized) {
            this.setState({
                ...state,
                fieldsDataWithChanges: fieldsData,
            });
        }
    }

    hasUnsavedChanges() {
        if (this.state.initialized) {
            return (this.state.itemOriginal !== this.state.itemWithChanges)
                || (this.state.fieldsDataOriginal !== this.state.fieldsDataWithChanges);
        } else {
            return false;
        }
    }

    getVocabularyItems(vocabularyId): Array<IVocabularyItem> {
        const vocabulary = sdApi.vocabularies.getAll().get(vocabularyId);

        if (vocabularyId === ANPA_CATEGORY.vocabularyId) {
            return vocabulary.items;
        }

        const anpaCategoryQcodes: Array<string> = this.state.initialized ?
            (this.state.fieldsDataWithChanges.get(ANPA_CATEGORY.fieldId) as Array<any> ?? [])
            : [];

        if (vocabulary.service == null || vocabulary.service?.all != null) {
            return vocabulary.items.filter(
                (vocabularyItem) => {
                    if (vocabularyItem.service == null) {
                        return true;
                    } else {
                        return anpaCategoryQcodes.some((qcode) => vocabularyItem.service[qcode] != null);
                    }
                },
            );
        } else if (anpaCategoryQcodes.some((qcode) => vocabulary.service?.[qcode] != null)) {
            return vocabulary.items;
        } else {
            return [];
        }
    }

    componentDidMount() {
        const authThemes = ng.get('authThemes');

        this._mounted = true;

        const {authoringStorage} = this.props;

        Promise.all(
            [
                authoringStorage.getEntity(this.props.itemId).then((item) => {
                    const itemCurrent = item.autosaved ?? item.saved;

                    return authoringStorage.getContentProfile(itemCurrent, this.props.fieldsAdapter).then((profile) => {
                        return {item, profile};
                    });
                }),
                authoringStorage.getUserPreferences(),
                authThemes.get('theme'),
                authThemes.get('proofreadTheme'),
            ],
        ).then((res) => {
            const [{item, profile}, userPreferences, defaultTheme, proofReadingTheme] = res;

            const spellcheckerEnabled =
                userPreferences[SPELLCHECKER_PREFERENCE].enabled
                ?? userPreferences[SPELLCHECKER_PREFERENCE].default
                ?? true;

            const initialState = getInitialState(
                item,
                profile,
                userPreferences[AUTHORING_FIELD_PREFERENCES] ?? {},
                spellcheckerEnabled,
                this.props.fieldsAdapter,
                this.props.authoringStorage,
                this.props.storageAdapter,
                this.props.getLanguage(item.autosaved ?? item.saved),
                {},
                defaultTheme,
                proofReadingTheme,
            );

            this.props.onEditingStart?.(initialState.itemWithChanges);

            this.setState(initialState);

            if (this.componentRef != null) {
                this.cleanupFunctionsToRunBeforeUnmounting.push(focusFirstChildInput(this.componentRef).cancel);
            }
        });

        registerToReceivePatches(this.props.itemId, (patch) => {
            const {state} = this;

            if (state.initialized) {
                this.setState({
                    ...state,
                    itemWithChanges: {
                        ...state.itemWithChanges,
                        ...patch,
                    },
                });
            }
        });

        this.cleanupFunctionsToRunBeforeUnmounting.push(addEditorEventListener('spellchecker__request_status', () => {
            if (this.state.initialized) {
                dispatchEditorEvent('spellchecker__set_status', this.state.spellcheckerEnabled);
            }
        }));

        this.cleanupFunctionsToRunBeforeUnmounting.push(
            addInternalEventListener(
                'replaceAuthoringDataWithChanges',
                (event) => {
                    const {state} = this;
                    const article = event.detail;

                    if (state.initialized) {
                        this.setState(this.updateItemWithChanges(state, article));
                    }
                },
            ),
        );

        this.cleanupFunctionsToRunBeforeUnmounting.push(
            addInternalEventListener(
                'dangerouslyOverwriteAuthoringData',
                (event) => {
                    if (event.detail._id === this.props.itemId) {
                        const patch = event.detail;

                        const {state} = this;

                        if (state.initialized) {
                            if (state.itemOriginal === state.itemWithChanges) {
                                /**
                                 * if object references are the same before patching
                                 * they should be the same after patching too
                                 * in order for checking for changes to work correctly
                                 * (reference equality is used for change detection)
                                 */

                                const patched = {
                                    ...state.itemOriginal,
                                    ...patch,
                                };

                                this.setState({
                                    ...state,
                                    itemOriginal: patched,
                                    itemWithChanges: patched,
                                });
                            } else {
                                this.setState({
                                    ...state,
                                    itemWithChanges: {
                                        ...state.itemWithChanges,
                                        ...patch,
                                    },
                                    itemOriginal: {
                                        ...state.itemOriginal,
                                        ...patch,
                                    },
                                });
                            }
                        }
                    }
                },
            ),
        );

        /**
         * Update UI when locked in another session,
         * regardless whether by same or different user.
         */
        this.cleanupFunctionsToRunBeforeUnmounting.push(
            addInternalWebsocketEventListener('item:lock', (data) => {
                const {user, lock_session, lock_time, _etag} = data.extra;

                const state = this.state;

                if (state.initialized && (state.itemOriginal._id === data.extra.item)) {
                    /**
                     * Only patch these fields to preserve
                     * unsaved changes.
                     * FINISH: remove IArticle usage
                     */
                    const patch: Partial<IArticle> = {
                        _etag,
                        lock_session,
                        lock_time,
                        lock_user: user,
                        lock_action: 'edit',
                    };

                    if (!this.hasUnsavedChanges()) {
                        /**
                         * if object references are the same before patching
                         * they should be the same after patching too
                         * in order for checking for changes to work correctly
                         * (reference equality is used for change detection)
                         */

                        const patched = {
                            ...state.itemOriginal,
                            ...patch,
                        };

                        this.setState({
                            ...state,
                            itemOriginal: patched,
                            itemWithChanges: patched,
                        });
                    } else {
                        this.setState({
                            ...state,
                            itemOriginal: {
                                ...state.itemOriginal,
                                ...patch,
                            },
                            itemWithChanges: {
                                ...state.itemWithChanges,
                                ...patch,
                            },
                        });
                    }
                }
            }),
        );

        /**
         * Reload item if updated while locked in another session.
         * Unless there are unsaved changes.
         */
        this.cleanupFunctionsToRunBeforeUnmounting.push(
            addWebsocketEventListener('resource:updated', (event) => {
                const {_id, resource} = event.extra;
                const state = this.state;

                if (state.initialized !== true) {
                    return;
                }

                if (
                    this.props.resourceNames.includes(resource) !== true
                    || state.itemOriginal._id !== _id
                ) {
                    return;
                }

                if (authoringStorage.isLockedInCurrentSession(state.itemOriginal)) {
                    return;
                }

                if (this.hasUnsavedChanges()) {
                    return;
                }

                authoringStorage.getEntity(state.itemOriginal._id).then((item) => {
                    this.setState(getInitialState(
                        item,
                        state.profile,
                        state.userPreferencesForFields,
                        state.spellcheckerEnabled,
                        this.props.fieldsAdapter,
                        this.props.authoringStorage,
                        this.props.storageAdapter,
                        this.props.getLanguage(item.autosaved ?? item.saved),
                        state.validationErrors,
                        state.allThemes.default,
                        state.allThemes.proofreading,
                    ));
                });
            }),
        );

        this.cleanupFunctionsToRunBeforeUnmounting.push(
            addInternalEventListener('dangerouslyForceReloadAuthoring', () => {
                const state = this.state;

                if (state.initialized !== true) {
                    return;
                }

                authoringStorage.getEntity(state.itemOriginal._id).then((item) => {
                    this.setState(getInitialState(
                        item,
                        state.profile,
                        state.userPreferencesForFields,
                        state.spellcheckerEnabled,
                        this.props.fieldsAdapter,
                        this.props.authoringStorage,
                        this.props.storageAdapter,
                        this.props.getLanguage(item.autosaved ?? item.saved),
                        state.validationErrors,
                        state.allThemes.default,
                        state.allThemes.proofreading,
                    ));
                });
            }),
        );
    }

    componentWillUnmount() {
        this._mounted = false;

        const state = this.state;

        if (state.initialized) {
            this.props.onEditingEnd?.(state.itemWithChanges);
        }

        unregisterFromReceivingPatches();

        for (const fn of this.cleanupFunctionsToRunBeforeUnmounting) {
            fn();
        }
    }

    componentDidUpdate(_prevProps, prevState: IState<T>) {
        const {authoringStorage} = this.props;
        const state = this.state;

        if (
            state.initialized
            && prevState.initialized
            && authoringStorage.isLockedInCurrentSession(state.itemOriginal)
        ) {
            const articleChanged = (state.itemWithChanges !== prevState.itemWithChanges)
                || (state.fieldsDataWithChanges !== prevState.fieldsDataWithChanges);

            if (articleChanged) {
                if (this.hasUnsavedChanges()) {
                    authoringStorage.autosave.schedule(
                        () => {
                            return this.computeLatestEntity({preferIncomplete: true});
                        },
                        (autosaved) => {
                            this.setState({
                                ...state,
                                autosaveEtag: autosaved._etag,
                            });
                        },
                    );
                }
            }
        }
    }

    handleUnsavedChanges(state: IStateLoaded<T>): Promise<T> {
        return new Promise((resolve, reject) => {
            if (!this.hasUnsavedChanges()) {
                resolve(state.itemOriginal);
                return;
            }

            return showUnsavedChangesPrompt(true).then(({action, closePromptFn}) => {
                if (action === IUnsavedChangesActionWithSaving.cancelAction) {
                    closePromptFn();
                    reject();
                } else if (action === IUnsavedChangesActionWithSaving.discardChanges) {
                    this.discardUnsavedChanges(state).then(() => {
                        closePromptFn();

                        if (this.state.initialized) {
                            resolve(this.state.itemOriginal);
                        }
                    });
                } else if (action === IUnsavedChangesActionWithSaving.save) {
                    this.save(state).then(() => {
                        closePromptFn();

                        if (this.state.initialized) {
                            resolve(this.state.itemOriginal);
                        }
                    });
                } else {
                    assertNever(action);
                }
            });
        });
    }

    save(state: IStateLoaded<T>): Promise<T> {
        const {authoringStorage} = this.props;

        if ((this.props.validateBeforeSaving ?? true) === true) {
            const {profile} = state;
            const allFields = profile.header.merge(profile.content);

            const validationErrors: IAuthoringValidationErrors = allFields.toArray()
                .filter((field) => {
                    if (field.fieldConfig.required === true) {
                        const FieldEditorConfig = getField(field.fieldType);

                        return !FieldEditorConfig.hasValue(state.fieldsDataWithChanges.get(field.id));
                    } else {
                        return false;
                    }
                }).reduce<IAuthoringValidationErrors>((acc, field) => {
                    acc[field.id] = gettext('Field is required');

                    return acc;
                }, {});

            if (Object.keys(validationErrors).length > 0) {
                this.setState({
                    ...state,
                    validationErrors,
                });

                return Promise.reject('validation errors were found');
            }
        }

        return this.setLoadingState(state, true)
            .then(() => this.cancelAutosave())
            .then(() => {
                return authoringStorage.saveEntity(
                    this.computeLatestEntity(),
                    state.itemOriginal,
                ).then((item: T) => {
                    const nextState = getInitialState(
                        {saved: item, autosaved: item},
                        state.profile,
                        state.userPreferencesForFields,
                        state.spellcheckerEnabled,
                        this.props.fieldsAdapter,
                        this.props.authoringStorage,
                        this.props.storageAdapter,
                        this.props.getLanguage(item),
                        {}, // clear validation errors
                        state.allThemes.default,
                        state.allThemes.proofreading,
                    );

                    if (this._mounted) {
                        this.setState(nextState);
                    }

                    return item;
                });
            });
    }

    /**
     * Unlocks article from other user that holds the lock
     * and locks for current user.
     */
    forceLock(state: IStateLoaded<T>): void {
        const {authoringStorage} = this.props;

        authoringStorage.forceLock(state.itemOriginal);
    }

    discardUnsavedChanges(state: IStateLoaded<T>): Promise<void> {
        return this.cancelAutosave().then(() => {
            return new Promise((resolve) => {
                const stateNext: IStateLoaded<T> = {
                    ...state,
                    itemWithChanges: state.itemOriginal,
                    fieldsDataWithChanges: state.fieldsDataOriginal,
                };

                this.setState(stateNext, () => {
                    resolve();
                });
            });
        });
    }

    /**
     * Closing is initiated, the logic to handle unsaved changes runs
     * and unless closing is cancelled by user action in the UI this.props.onClose is called.
     */
    initiateClosing(state: IStateLoaded<T>): void {
        if (this.hasUnsavedChanges() !== true) {
            this.props.onClose();
            return;
        }

        const {authoringStorage} = this.props;

        this.setLoadingState(state, true).then(() => {
            authoringStorage.closeAuthoring(
                this.computeLatestEntity(),
                state.itemOriginal,
                () => {
                    authoringStorage.autosave.cancel();

                    return authoringStorage.autosave.delete(state.itemOriginal._id, state.autosaveEtag);
                },
                () => this.props.onClose(),
            ).then(() => {
                /**
                 * The promise will also resolve
                 * if user decides to cancel closing.
                 */
                if (this._mounted) {
                    this.setLoadingState(state, false);
                }
            });
        });
    }

    setUserPreferences(val: IStateLoaded<T>['userPreferencesForFields']) {
        const state = this.state;

        if (state.initialized !== true) {
            return;
        }

        preferences.update(AUTHORING_FIELD_PREFERENCES, val);

        this.setState({
            ...state,
            userPreferencesForFields: val,
        });
    }

    toggleField(fieldId: string) {
        if (!this.state.initialized) {
            return;
        }

        const {profile, itemWithChanges, toggledFields, fieldsDataWithChanges} = this.state;
        const allFields = profile.header.merge(profile.content);
        const field = allFields.get(fieldId);
        const FieldEditorConfig = getField(field.fieldType);
        const {fieldsAdapter, getLanguage} = this.props;

        const toggledValueNext: boolean = !toggledFields[fieldId];

        const onToggledOn = fieldsAdapter[fieldId]?.onToggledOn ?? FieldEditorConfig.onToggledOn;

        /**
         * When toggled to "off", clear current value by setting an empty one.
         * Removing a value entirely wouldn't work, because our REST API
         * doesn't support patches that can remove keys.
         *
         * When toggled to "on", set value returned from `onToggledOn` if it is defined.
         */
        const fieldValuesNext = toggledValueNext === true
            ? onToggledOn == null ?
                fieldsDataWithChanges
                : fieldsDataWithChanges.set(
                    fieldId,
                    onToggledOn({
                        language: getLanguage(this.state.itemWithChanges),
                        config: field.fieldConfig,
                        editorPreferences: this.state.userPreferencesForFields[field.id],
                        fieldsData: this.state.fieldsDataWithChanges,
                    }),
                )
            : fieldsDataWithChanges.set(
                fieldId,
                FieldEditorConfig.getEmptyValue(field.fieldConfig, getLanguage(itemWithChanges)),
            );

        this.setState({
            ...this.state,
            toggledFields: {
                ...toggledFields,
                [fieldId]: toggledValueNext,
            },
            fieldsDataWithChanges: fieldValuesNext,
        });
    }

    updateItemWithChanges(state: IStateLoaded<T>, itemPartial: Partial<T>): IStateLoaded<T> {
        const {profile} = state;
        const fields = profile.header.merge(profile.content);

        const itemPatched = {
            ...state.itemWithChanges,
            ...itemPartial,
        };

        const fieldsDataNext = getFieldsData(
            itemPatched,
            fields,
            this.props.fieldsAdapter,
            this.props.authoringStorage,
            this.props.storageAdapter,
            this.props.getLanguage(itemPatched),
        );

        return {
            ...state,
            itemWithChanges: itemPatched,
            fieldsDataWithChanges: fieldsDataNext,
        };
    }

    onItemChange(state: IStateLoaded<T>, itemWithChanges: T) {
        this.setState({
            ...state,
            itemWithChanges,
        });
    }

    render() {
        const state = this.state;
        const {authoringStorage, fieldsAdapter, storageAdapter, getLanguage, getSidePanel} = this.props;

        if (state.initialized !== true) {
            return null;
        }

        // TODO: remove test code
        if (uiFrameworkAuthoringPanelTest) {
            return (
                <div>
                    <EditorTest />
                </div>
            );
        }

        const exposed: IExposedFromAuthoring<T> = {
            item: state.itemWithChanges,
            contentProfile: state.profile,
            getLatestItem: this.computeLatestEntity,
            fieldsData: state.fieldsDataWithChanges,
            handleFieldsDataChange: this.handleFieldsDataChange,
            hasUnsavedChanges: () => this.hasUnsavedChanges(),
            handleUnsavedChanges: () => this.handleUnsavedChanges(state),
            save: () => this.save(state),
            initiateClosing: () => this.initiateClosing(state),
            keepChangesAndClose: () => this.props.onClose(),
            onItemChange: (item: T) => this.onItemChange(state, item),
            stealLock: () => this.forceLock(state),
            authoringStorage: authoringStorage,
            storageAdapter: storageAdapter,
            fieldsAdapter: fieldsAdapter,
            sideWidget: this.props.sideWidget?.name ?? null,
            toggleSideWidget: (name) => {
                if (name == null || this.props.sideWidget?.name === name) {
                    this.props.onSideWidgetChange(null);
                } else {
                    this.props.onSideWidgetChange({
                        name: name,
                        pinned: false,
                    });
                }
            },
        };

        const authoringOptions: IAuthoringOptions<T> | null =
            this.props.getInlineToolbarActions != null ? this.props.getInlineToolbarActions(exposed) : null;
        const readOnly = state.initialized ? authoringOptions?.readOnly : false;
        const OpenWidgetComponent = getSidePanel == null ? null : this.props.getSidePanel(exposed, readOnly);

        const authoringActions: Array<IAuthoringAction> = (() => {
            const actions = this.props.getActions?.(exposed) ?? [];
            const coreActions: Array<IAuthoringAction> = [];

            if (appConfig.features.useTansaProofing !== true) {
                if (state.spellcheckerEnabled) {
                    const nextValue = false;

                    coreActions.push({
                        label: gettext('Disable spellchecker'),
                        onTrigger: () => {
                            this.setState({
                                ...state,
                                spellcheckerEnabled: nextValue,
                            });

                            dispatchEditorEvent('spellchecker__set_status', nextValue);

                            preferences.update(SPELLCHECKER_PREFERENCE, {
                                type: 'bool',
                                enabled: nextValue,
                                default: true,
                            });
                        },
                        keyBindings: {
                            'ctrl+shift+y': () => {
                                this.setState({
                                    ...state,
                                    spellcheckerEnabled: nextValue,
                                });

                                dispatchEditorEvent('spellchecker__set_status', nextValue);

                                preferences.update(SPELLCHECKER_PREFERENCE, {
                                    type: 'bool',
                                    enabled: nextValue,
                                    default: true,
                                });
                            },
                        },
                    });
                } else {
                    coreActions.push({
                        label: gettext('Enable spellchecker'),
                        onTrigger: () => {
                            const nextValue = true;

                            this.setState({
                                ...state,
                                spellcheckerEnabled: true,
                            });

                            dispatchEditorEvent('spellchecker__set_status', nextValue);

                            preferences.update(SPELLCHECKER_PREFERENCE, {
                                type: 'bool',
                                enabled: nextValue,
                                default: true,
                            });
                        },
                        keyBindings: {
                            'ctrl+shift+y': () => {
                                const nextValue = true;

                                this.setState({
                                    ...state,
                                    spellcheckerEnabled: true,
                                });

                                dispatchEditorEvent('spellchecker__set_status', nextValue);

                                preferences.update(SPELLCHECKER_PREFERENCE, {
                                    type: 'bool',
                                    enabled: nextValue,
                                    default: true,
                                });
                            },
                        },
                    });
                }
            }

            return [...coreActions, ...actions];
        })();

        const keyBindingsFromAuthoringActions: IKeyBindings = authoringActions.reduce((acc, action) => {
            return {
                ...acc,
                ...(action.keyBindings ?? {}),
            };
        }, {});

        const widgetsCount = this.props.getSidebarWidgetsCount(exposed);

        const widgetKeybindings: IKeyBindings = {};

        for (let i = 0; i < widgetsCount; i++) {
            widgetKeybindings[`ctrl+alt+${i + 1}`] = () => {
                const nextWidgetName: string = this.props.getSideWidgetNameAtIndex(exposed.item, i);

                this.props.onSideWidgetChange({
                    name: nextWidgetName,
                    pinned: this.props.sideWidget?.pinned ?? false,
                });
            };
        }

        const primaryToolbarWidgets: Array<ITopBarWidget<T>> = authoringOptions?.actions != null ? [
            ...authoringOptions.actions,
            {
                group: 'end',
                priority: 0.4,
                component: () => {
                    return (
                        <AuthoringActionsMenu getActions={() => authoringActions} />
                    );
                },
                availableOffline: true,
            },
        ] : [];

        const pinned = this.props.sideWidget?.pinned === true;

        const printPreviewAction = (() => {
            const execute = () => {
                previewAuthoringEntity(
                    state.itemWithChanges,
                    state.profile,
                    state.fieldsDataWithChanges,
                );
            };

            const preview = {
                jsxButton: () => {
                    return (
                        <IconButton
                            icon="preview-mode"
                            ariaValue={gettext('Print preview')}
                            onClick={() => {
                                execute();
                            }}
                        />
                    );
                },
                keybindings: {
                    'ctrl+shift+i': () => {
                        execute();
                    },
                },
            };

            return preview;
        })();

        const allKeyBindings: IKeyBindings = {
            ...printPreviewAction.keybindings,
            ...getKeyBindingsFromActions(authoringOptions?.actions ?? []),
            ...keyBindingsFromAuthoringActions,
            ...widgetKeybindings,
        };

        const activeTheme = state.proofreadingEnabled ? state.allThemes.proofreading : state.allThemes.default;

        const uiTheme: IAuthoringSectionTheme = {
            backgroundColor: activeTheme.theme,
            backgroundColorSecondary: activeTheme.themeColorSecondary,
            textColor: activeTheme.textColor,
            fontFamily: activeTheme.fontFamily,
            fieldTheme: {
                headline: {
                    fontSize: getUiThemeFontSizeHeading(activeTheme.headline),
                },
                abstract: {
                    fontSize: getUiThemeFontSize(activeTheme.abstract),
                },
                body_html: {
                    fontSize: getUiThemeFontSize(activeTheme.body),
                },
            },
        };

        return (
            <div style={{display: 'contents'}} ref={this.setRef}>
                {
                    state.loading && (
                        <Loader overlay />
                    )
                }

                <WithKeyBindings keyBindings={allKeyBindings}>
                    <WithInteractiveArticleActionsPanel location="authoring">
                        {(panelState, panelActions) => {
                            return (
                                <Layout.AuthoringFrame
                                    header={
                                        primaryToolbarWidgets.length < 1
                                        && this.props.getAuthoringPrimaryToolbarWidgets == null
                                            ? undefined
                                            : (
                                                <SubNav>
                                                    <AuthoringToolbar
                                                        entity={state.itemWithChanges}
                                                        coreWidgets={primaryToolbarWidgets}
                                                        extraWidgets={
                                                            this.props.getAuthoringPrimaryToolbarWidgets(exposed)
                                                        }
                                                        backgroundColor={authoringOptions?.toolbarBgColor}
                                                    />
                                                </SubNav>
                                            )
                                    }
                                    main={(
                                        <Layout.AuthoringMain
                                            noPaddingForContent
                                            headerCollapsed={this.props.headerCollapsed}
                                            toolBar={this.props.hideSecondaryToolbar ? undefined : (
                                                <React.Fragment>
                                                    <div
                                                        style={{
                                                            paddingInlineEnd: 16,
                                                            display: 'flex',
                                                            alignItems: 'center',
                                                            gap: 8,
                                                        }}
                                                    >
                                                        {
                                                            this.props.secondaryToolbarWidgets
                                                                .map((Component, i) => {
                                                                    return (
                                                                        <Component
                                                                            key={i}
                                                                            item={state.itemWithChanges}
                                                                        />
                                                                    );
                                                                })
                                                        }
                                                    </div>

                                                    <ButtonGroup align="end">

                                                        {printPreviewAction.jsxButton()}

                                                        {this.props.themingEnabled === true && (
                                                            <>
                                                                <IconButton
                                                                    icon="adjust"
                                                                    ariaValue={gettext('Toggle theme')}
                                                                    onClick={() => {
                                                                        this.setState({
                                                                            ...state,
                                                                            proofreadingEnabled:
                                                                                !state.proofreadingEnabled,
                                                                        });
                                                                    }}
                                                                />
                                                                <IconButton
                                                                    icon="switches"
                                                                    ariaValue={gettext('Configure themes')}
                                                                    onClick={() => {
                                                                        this.showThemeConfigModal(state);
                                                                    }}
                                                                />
                                                            </>
                                                        )}

                                                    </ButtonGroup>

                                                </React.Fragment>
                                            )}
                                            authoringHeader={(
                                                <AuthoringSection
                                                    fields={state.profile.header}
                                                    fieldsData={state.fieldsDataWithChanges}
                                                    onChange={this.handleFieldChange}
                                                    language={getLanguage(state.itemWithChanges)}
                                                    userPreferencesForFields={state.userPreferencesForFields}
                                                    useHeaderLayout
                                                    setUserPreferencesForFields={this.setUserPreferences}
                                                    getVocabularyItems={this.getVocabularyItems}
                                                    toggledFields={state.toggledFields}
                                                    toggleField={this.toggleField}
                                                    readOnly={readOnly}
                                                    validationErrors={state.validationErrors}
                                                    item={state.itemWithChanges}
                                                />
                                            )}
                                        >
                                            <AuthoringSection
                                                uiTheme={uiTheme}
                                                padding="3.2rem 4rem 5.2rem 4rem"
                                                fields={state.profile.content}
                                                fieldsData={state.fieldsDataWithChanges}
                                                onChange={this.handleFieldChange}
                                                language={getLanguage(state.itemWithChanges)}
                                                userPreferencesForFields={state.userPreferencesForFields}
                                                setUserPreferencesForFields={this.setUserPreferences}
                                                getVocabularyItems={this.getVocabularyItems}
                                                toggledFields={state.toggledFields}
                                                toggleField={this.toggleField}
                                                readOnly={readOnly}
                                                validationErrors={state.validationErrors}
                                                item={state.itemWithChanges}
                                            />
                                        </Layout.AuthoringMain>
                                    )}
                                    sideOverlay={!pinned && OpenWidgetComponent != null && OpenWidgetComponent}
                                    sideOverlayOpen={!pinned && OpenWidgetComponent != null}
                                    sidePanel={pinned && OpenWidgetComponent != null && OpenWidgetComponent}
                                    sidePanelOpen={pinned && OpenWidgetComponent != null}
                                    sideBar={this.props.getSidebar?.(exposed)}
                                />
                            );
                        }}
                    </WithInteractiveArticleActionsPanel>
                </WithKeyBindings>
            </div>
        );
    }
}