import { FormPageState, ReportBI, WixPatternsContainer, ActionsBarConfig, isMobileDevice, SchemaSource, Schema, } from '@wix/bex-core'; import { action, computed, makeObservable, observable } from 'mobx'; import { FieldValues, UseFormReturn } from 'react-hook-form'; import { EntityPageStateBIReporter } from './EntityPageStateBIReporter'; import { EventEmitter, TypedEmitter } from '@wix/bex-core/events'; import { WixPatternsRouterState } from '../WixPatternsRouterState'; type OnSaveParams = { widgetsFormData: { [key: string]: any }; }; interface EntityPageStateParamsBase { /** * A function that fetches the entity data. * @returns a promise that resolves to an object containing the entity. */ fetch: () => Promise<{ entity: T | undefined }>; /** * A function to handle the save action. * @returns promise that resolves to an object that contains a 'updatedEntity' object. */ onSave: (params: OnSaveParams) => Promise<{ updatedEntity: T }>; /** * An optional object with an optional message, or it can directly get a string message. */ saveSuccessToast?: { message?: string } | string; /** * An optional function to display an error toast message when a save operation fails. * @returns An object with a `message` and an `action` object. Alternatively, it can return an object with an optional `message` and a `tryAgain` flag, or a string message. * The previous return types are still supported but are now deprecated. */ saveErrorToast?: ( err: unknown, params: { retry: () => void }, ) => | { message?: string; /** * @deprecated use `action` instead */ tryAgain?: boolean; action?: { text: string; onClick: () => void }; } | string; /** * An optional function to transform the updated entity data before it is passed to the collection page. * @param entity the updated entity. * @returns an object representing the entity in the collection page. */ transformEntityToCollectionItem?: (entity: T) => any; form: UseFormReturn; /** * An object representing the container, possibly for dependency injection or component management. */ container: WixPatternsContainer; /** * A string representing the ID of the parent page. * Must be passed if not using Patterns Router */ parentPageId?: string; /** * A string representing the path of the parent page. * Must be passed if using Patterns Router */ parentPath?: string; /** * An object representing the router state */ routerState?: WixPatternsRouterState | null; /** * An optional object to override the actions bar. */ actionsBarConfig?: ActionsBarConfig; /** * An optional schema source describing how to load the schema and access * the entity's data. When present, the page can read fields from the source * instead of relying only on hand-wired fetch/onSave callbacks. */ schemaSource?: SchemaSource; } export type EntityPageStateParams< T, V extends FieldValues = FieldValues, > = EntityPageStateParamsBase; export interface EntityPageStateInterface {} export interface EntityPageWidgetState { validate: () => Promise<{ isValid: boolean; values?: any }>; isDirty: boolean; } export class EntityPageState implements EntityPageStateInterface { readonly fetch: EntityPageStateParams['fetch']; readonly onSave: EntityPageStateParams['onSave']; readonly saveSuccessToast?: EntityPageStateParams['saveSuccessToast']; readonly saveErrorToast?: EntityPageStateParams['saveErrorToast']; readonly transformEntityToCollectionItem: (entity: T) => any; _entity: T | null = null; _schema: Schema | null = null; private readonly _parentPageId?: string; private readonly _parentPath?: string; actionsBarConfig?: EntityPageStateParams['actionsBarConfig']; readonly schemaSource?: SchemaSource; readonly container: WixPatternsContainer; readonly reportBI: ReportBI; readonly bi: EntityPageStateBIReporter; _routerState: WixPatternsRouterState | null = null; readonly formPage: FormPageState; readonly events = new EventEmitter() as TypedEmitter<{ save: () => void; saveError: (err: unknown) => void; }>; constructor(params: EntityPageStateParams) { this.fetch = params.fetch; this.onSave = params.onSave; this.saveSuccessToast = params.saveSuccessToast; this.saveErrorToast = params.saveErrorToast; this.formPage = new FormPageState({ form: params.form }); this._parentPageId = params.parentPageId; this.transformEntityToCollectionItem = params.transformEntityToCollectionItem || ((anEntity: T) => anEntity); this._parentPath = params.parentPath; this.schemaSource = params.schemaSource; this.container = params.container; this._routerState = params.routerState ?? null; if (this._routerState) { this._entity = this._routerState.currentState?._chosenEntity?.entity; } this.reportBI = this.container.createBILogger({ componentType: 'Entity page', pageType: 'Entity', route: window.location.pathname, }); this.bi = new EntityPageStateBIReporter({ reportBI: this.reportBI, entityPageState: this, }); this.bi.init(); makeObservable(this, { handleSubmit: action, _entity: observable.ref, setEntity: action, _schema: observable.ref, setSchema: action, isFormDirty: computed, _withinRouter: computed, }); } init() { this._checkPageParams(); const { formPage: { initTask }, container, } = this; const { onBeforeUnload } = container; const onBeforeUnloadSubscription = onBeforeUnload?.( action((event: Event) => { if (isMobileDevice()) { return; } const { form } = this; // Check both react-hook-form's state AND the explicitly set flag if ( form.formState.isSubmitSuccessful || this.formPage._isSubmitSuccessful || !this.isFormDirty ) { return; } event.preventDefault(); this.bi.reportOnBeforeUnload(); }), ); initTask.runOnce(async () => { const [data, loadedSchema] = await Promise.all([ this.fetch(), this.schemaSource?.loadSchema(), container.initTask.status.promise, ]); this.setEntity(data.entity ?? null); if (loadedSchema) { this.setSchema(loadedSchema.schema); } this.bi.appLoaded?.(); }); return () => { onBeforeUnloadSubscription?.remove(); }; } retryFetch() { this.formPage.retryFetch(); } setEntity(newEntity: T | null) { this._entity = newEntity; } get entity(): T | null { return this._entity; } setSchema(schema: Schema | null) { this._schema = schema; } get schema(): Schema | null { return this._schema; } get isFetching() { return this.formPage.isFetching; } get form() { return this.formPage.form; } set isSubmitSuccessful(flag: boolean) { this.formPage.isSubmitSuccessful = flag; } get isFormDirty() { return this.formPage.isDirty; } // backwards compatibility set _isFormDirty(val: boolean) { this.formPage._isFormDirty = val; } get _withinRouter() { return !!this._routerState; } onCancel() { this.bi.reportButtonClick({ ctaName: 'Cancel', additionalInfo: this.actionsBarConfig?.cancelCta?.biAdditionalInfo, }); this.navigateToParent(); } navigateToParent(updatedEntity?: any) { const entityStringKey = this._entity ? '_updatedEntity' : '_createdEntity'; const entityObject = { [entityStringKey]: updatedEntity }; if (this._withinRouter) { this._routerState!.navigate(this._parentPath!, { state: entityObject }); } else { this.container.navigateTo!({ pageId: this._parentPageId!, }); } } _showErrorToast(err: any, e?: React.BaseSyntheticEvent) { const errorToast = this.saveErrorToast?.(err, { retry: () => this.handleSubmit(e), }); const message = typeof errorToast === 'string' ? errorToast : errorToast?.message; const showTryAgain = typeof errorToast === 'string' ? true : errorToast?.tryAgain !== false; const action = typeof errorToast === 'string' ? undefined : errorToast?.action; this.container.showToast?.({ type: 'ERROR', biName: 'cairo-entity-page-save-error-toast', message: message ?? this.container.translate( err?.code === 'ERR_NETWORK' ? 'cairo.entityPage.saveError-offline.toast.description' : 'cairo.entityPage.saveError-technical.toast.description', ), action: action || showTryAgain ? { uiType: 'LINK', text: action?.text ?? this.container.translate('cairo.toast.retry'), onClick: () => { if (action?.onClick) { action.onClick(); } else { this.bi.reportSaveTryAgain(); this.handleSubmit(e); } }, removeToastOnClick: true, } : undefined, }); } _showSuccessToast() { const message = typeof this.saveSuccessToast === 'string' ? this.saveSuccessToast : this.saveSuccessToast?.message; this.container.showToast?.({ type: 'SUCCESS', biName: 'cairo-entity-page-save-success-toast', message: message ?? this.container.translate('cairo.entityPage.success.toast'), }); } async handleSubmit(e?: React.BaseSyntheticEvent) { if (this.formPage.submitTask.status.isLoading) { return; } this.events.emit('save'); const { isValid, data: widgetsFormData } = await this.formPage.validate(); if (!isValid) { this.bi.reportButtonClick({ ctaName: 'Save', isValid: false, additionalInfo: this.actionsBarConfig?.saveCta?.biAdditionalInfo, }); return; } const { form, formPage: { submitTask }, bi, } = this; submitTask.run(async () => { let savedEntity: T | undefined; let submitSuccessful = false; await form.handleSubmit( async () => { bi.reportButtonClick({ ctaName: 'Save', isValid: true, additionalInfo: this.actionsBarConfig?.saveCta?.biAdditionalInfo, }); try { if (!this.isFetching) { const onSaveResult = await this.onSave({ widgetsFormData, }); savedEntity = onSaveResult?.updatedEntity; } this._showSuccessToast(); submitSuccessful = true; // Mark as successful locally // Explicitly set isSubmitSuccessful to prevent onBeforeUnload from triggering this.isSubmitSuccessful = true; } catch (err) { this._showErrorToast(err, e); this.container.errorMonitor.captureException(err); this.events.emit('saveError', err); form.setError('root.serverError', {}); // https://react-hook-form.com/docs/useform/seterror } }, () => { bi.reportButtonClick({ ctaName: 'Save', isValid: false, additionalInfo: this.actionsBarConfig?.saveCta?.biAdditionalInfo, }); }, // can be used to report validation errors )(e); if (!submitSuccessful) { return; } const transformedItem = savedEntity && this.transformEntityToCollectionItem ? this.transformEntityToCollectionItem(savedEntity) : savedEntity; this.navigateToParent(transformedItem); }); await this.formPage.submitTask.status.promise; } get initTask() { return this.formPage.initTask; } get submitTask() { return this.formPage.submitTask; } private _checkPageParams() { if (this._withinRouter) { if (this._parentPath === undefined) { throw new Error('parentPath was not defined in useEntityPage'); } } else { if (this._parentPageId === undefined) { throw new Error('parentPageId was not defined in useEntityPage'); } if (!this.container.navigateTo) { throw new Error('navigateTo was not defined in Patterns Provider'); } } } }