import { WixPatternsContainer, CollectionOptimisticActions, CollectionState, FiltersMap, ModalState, ReportBI, TaskState, } from '@wix/bex-core'; import { action, computed, makeObservable, observable } from 'mobx'; import { EventEmitter, TypedEmitter } from '@wix/bex-core/events'; import { ViewsService } from '../services'; import { v4 as uuid } from 'uuid'; import { ALL_ITEMS_SORT_IND, ALL_ITEMS_VIEW_ID, AllItemsViewProps, BasicCategory, Category, PREDEFINED_VIEWS_SORT_IND, PrivateView, SAVED_VIEWS_SORT_IND, View, ViewActionNameBI, ViewBase, } from '../model'; import { AutoCompleteReadonlyState } from '../components/CollectionViewsDropdown/AutoCompleteReadonlyState'; import { ViewsLocalSearchState } from './ViewsLocalSearchState'; const MAX_SAVED_VIEWS = 50; const updatePresetViews = ( presets: Category[], hiddenPresetIds: string[], ): PrivateView[] => { return presets.reduce( (views, cat, ind) => [ ...views, ...(cat.views ?.filter((p) => !hiddenPresetIds.includes(p.id)) .map(({ isDefaultView, ...p }) => ({ ...p, categoryId: cat.id, categorySortInd: `${ind + +PREDEFINED_VIEWS_SORT_IND}`, isPreset: true, })) ?? []), ], [] as PrivateView[], ); }; const toNonPresetViews = ( views: View[], ): PrivateView[] => { return views.map((v) => ({ ...v, isPreset: false, categorySortInd: v.id === ALL_ITEMS_VIEW_ID ? ALL_ITEMS_SORT_IND : SAVED_VIEWS_SORT_IND, })); }; const getBasicCategories = ( presets: Category[], ): Record => { return presets.reduce( (prev, { views, ...basicCategory }) => ({ ...prev, [basicCategory.id]: basicCategory, }), {}, ); }; export interface ViewsStateParams { oldNamespace: string; presets?: Category[]; container: WixPatternsContainer; fqdn: string; initialSelect?: true | ((item: View, index: number) => boolean); allItemsViewProps?: AllItemsViewProps; reportBI: ReportBI; } export class ViewsState { currentView: PrivateView | null; defaultViewId: string | null; _initialViewIdFromUrl?: string | null; readonly initialSelect?: true | ((item: View, index: number) => boolean); readonly viewsService: ViewsService; readonly collection: CollectionState>; readonly optimisticActions: CollectionOptimisticActions< PrivateView, FiltersMap >; readonly saveViewModal = new ModalState(); readonly renameViewModal = new ModalState(); readonly autoCompleteReadonlyState = new AutoCompleteReadonlyState(); readonly localSearchState; readonly reportBI; readonly events = new EventEmitter() as TypedEmitter<{ selectAndRefresh: (needFiltersReset?: boolean) => unknown; beforeSelectView: () => unknown; afterSelectView: (params: { view: PrivateView }) => unknown; undoAction: (params: { actionName: ViewActionNameBI; viewId: string; newName?: string; }) => unknown; tryAgainAction: (params: { actionName: ViewActionNameBI }) => unknown; errorAction: (params: { actionName: ViewActionNameBI; createdAt: number; viewId: string; }) => unknown; }>; readonly presets: Category[]; readonly allItemsViewProps: AllItemsViewProps; readonly basicCategories: Record; readonly initTask = new TaskState(); readonly container; _migrateOrdersViews?: (state: ViewsState) => unknown; _fixMissingContactsViews?: (state: ViewsState) => unknown; constructor(params: Readonly>) { const { container, fqdn, reportBI } = params; this.container = container; this.reportBI = reportBI; this.initialSelect = params.initialSelect; this.presets = params.presets ?? []; this.allItemsViewProps = params.allItemsViewProps ?? {}; const viewsStorage = container._createComponentSettings({ namespace: 'cairo', }); const { environment } = container; const defaultViewStorage = environment.providerType === 'bm' ? viewsStorage : container.createEssentials({ dataCapsule: { namespace: 'cairo', scope: environment.componentId, storageStrategy: environment.providerType === 'giza' ? 'userPreferences' : undefined, }, }).dataCapsule; this.viewsService = new ViewsService({ viewsStorage, defaultViewStorage, fqdn, monitor: container.internalMonitor, errorHandler: container.errorHandler, translate: container.translate, }); this.collection = new CollectionState>({ ...container, events: {}, queryName: [fqdn, 'views'].join('/'), fetchData: async () => { const { viewsService, collection, allItemsViewProps, container } = this; if (collection.initTask.status.isLoading) { await viewsService.migrateFromLocalStorage(); await this._migrateOrdersViews?.(this); await this._fixMissingContactsViews?.(this); } const result = await viewsService.fetchViews(); const { views = [], hiddenPresetIds = [] } = result ?? {}; // Check if "All Items" view exists or was deleted by user const allItemsExists = views.some((v) => v.id === ALL_ITEMS_VIEW_ID); const allItemsWasDeleted = hiddenPresetIds.includes(ALL_ITEMS_VIEW_ID); const shouldAddAllItems = !allItemsExists && !allItemsWasDeleted && !allItemsViewProps.hidden; if (shouldAddAllItems) { await container.initTask.status.promise; const hasExistingDefault = views.some((v) => v.isDefaultView); const allItemsView: View = { id: ALL_ITEMS_VIEW_ID, name: allItemsViewProps.name ?? container.translate('cairo.viewsDropdown.allItems.label'), isDefaultView: !hasExistingDefault, selectedColumns: allItemsViewProps.selectedColumns, sortDirections: allItemsViewProps.sortDirections, orderedColumns: allItemsViewProps.orderedColumns, spreadsheetSelectedColumns: allItemsViewProps.spreadsheetSelectedColumns, spreadsheetOrderedColumns: allItemsViewProps.spreadsheetOrderedColumns, layout: allItemsViewProps.layout, }; views.unshift(allItemsView); } const items = [] as PrivateView[]; items.push(...(toNonPresetViews(views) ?? [])); items.push(...updatePresetViews(this.presets, hiddenPresetIds)); const allItemsIdx = items.findIndex((i) => i.id === ALL_ITEMS_VIEW_ID); if (allItemsIdx > 0) { const [allItems] = items.splice(allItemsIdx, 1); items.unshift(allItems); } return { items, }; }, itemKey: ({ id }) => id, itemName: ({ name }) => name, filters: {}, }); this.localSearchState = new ViewsLocalSearchState({ views: this }); this.optimisticActions = new CollectionOptimisticActions({ container, collection: this.collection, orderBy: ({ sort }) => [ ...(sort || []), { fieldName: (item) => (item.id === ALL_ITEMS_VIEW_ID ? '0' : '1'), order: 'asc', }, { fieldName: (item) => item.categorySortInd, order: 'asc', }, ], }); this.basicCategories = getBasicCategories(this.presets); this.currentView = null; this.defaultViewId = null; makeObservable(this, { isReady: computed, isDefaultView: computed, currentView: observable.ref, defaultViewId: observable.ref, selectView: action, unselectView: action, setDefaultViewId: action, _onActionUndo: action, reachedViewsLimit: computed, isCurrentViewSelected: computed, }); } _getAllItemsAndPresetViews() { return updatePresetViews(this.presets, []); } _getInitialSelectItem(items: PrivateView[]): PrivateView | undefined { const { initialSelect, _initialViewIdFromUrl, defaultViewId } = this; if (initialSelect == null) { if (_initialViewIdFromUrl) { return items.find((item) => item.id === _initialViewIdFromUrl); } if (defaultViewId) { return items.find((item) => item.id === defaultViewId); } const defaultPresetId = this.getDefaultPreset() ?? ALL_ITEMS_VIEW_ID; return items.find((item) => item.id === defaultPresetId); } if (initialSelect === true) { return items[0]; } return items.find((item, index) => initialSelect(item, index)); } _onActionUndo( id: string, actionName: ViewActionNameBI, newName?: string, ): void { this.events.emit('undoAction', { actionName, viewId: id, newName }); this.selectView({ id, needFiltersReset: false }); } _onTryAgain(actionName: ViewActionNameBI): void { this.events.emit('tryAgainAction', { actionName }); } _onError( actionName: ViewActionNameBI, createdAt: number, oldViewId?: string, viewId?: string, ): void { if (oldViewId) { this.selectView({ id: oldViewId, needFiltersReset: false }); } else { this.unselectView(); } this.events.emit('errorAction', { actionName, createdAt, viewId: viewId ?? oldViewId ?? '', }); } init() { const { collection, localSearchState } = this; const disposers = [ collection.init(), this.optimisticActions.init(), localSearchState.init(), ]; this.initTask.runOnce(async () => { await collection.initTask.status.promise; this.setDefaultViewId(await this.viewsService.fetchDefaultViewId()); }); return () => { disposers.forEach((disposer) => disposer()); }; } getViews(): PrivateView[] { return this.collection.result.items.slice(); } getView(viewId: string): PrivateView | undefined { return this.getViews().find(({ id }) => id === viewId); } getDefaultPreset(): string | undefined { return this.presets .reduce((prev, cur) => [...prev, ...cur.views], [] as View[]) .find((view) => view.isDefaultView)?.id; } get isDefaultView() { return this.currentView?.id === this.defaultViewId; } async saveViewChanges(viewBase: ViewBase): Promise | null> { if (!this.currentView) { return null; } const updatedView = { ...this.currentView, ...viewBase, }; return this.saveView({ newView: updatedView, hasUndo: true, oldViewId: this.currentView.id, }); } createNewView(name: string, viewBase: ViewBase): PrivateView { return { ...viewBase, categorySortInd: SAVED_VIEWS_SORT_IND, isPreset: false, name, id: uuid(), extra: this.currentView?.extra, }; } async saveView({ newView, hasUndo = false, isNew = false, oldViewId, }: { newView: PrivateView; hasUndo?: boolean; isNew?: boolean; oldViewId?: string; }): Promise> { const createdAt = Date.now(); const immediateAction = () => { this.selectView({ id: newView.id }); }; this.optimisticActions.createOne(newView, { submit: async () => { await this.viewsService.saveView(newView as View); return [newView]; }, showUndoToast: hasUndo, onUndo: () => { this._onActionUndo( newView.id, isNew ? 'view-save-new' : 'view-save-changes', ); }, onTryAgain: () => { immediateAction(); this._onTryAgain(isNew ? 'view-save-new' : 'view-save-changes'); }, onError: (_: unknown) => { this._onError( isNew ? 'view-save-new' : 'view-save-changes', createdAt, oldViewId, newView.id, ); }, successToast: { biName: isNew ? 'cairo-save-new-view-success' : 'cairo-save-view-changes-success', message: isNew ? this.collection.translate('cairo.saveView.Success.toast', { viewName: newView.name, }) : this.collection.translate( 'cairo.manageView.saveChanges.Success.toast', { viewName: newView.name, }, ), }, errorToast: (err) => { const resolvedError = this.container.errorHandler?.getResolvedError?.(err); return { biName: isNew ? 'cairo-save-new-view-error' : 'cairo-save-view-changes-error', message: resolvedError?.message!, ...(resolvedError?.action ? { action: resolvedError?.action } : {}), type: 'ERROR', }; }, }); immediateAction(); return newView; } public async deleteView(view: PrivateView): Promise { const { viewsService, currentView } = this; const { id, isPreset } = view; const isCurrentViewSelected = currentView?.id === id; const idForUndo = currentView?.id; const prevDefaultView = this.defaultViewId; const createdAt = Date.now(); const immediateAction = async () => { if (id === this.defaultViewId) { await this.viewsService.updateDefaultView(undefined); this.setDefaultViewId(null); } if (isCurrentViewSelected) { this.unselectView(); } }; this.optimisticActions.deleteOne(view, { submit: async () => { if (isPreset) { await viewsService.updateHiddenPreset(id); } else { await viewsService.deleteView(view); // Track "All Items" deletion so it won't be recreated on next load if (id === ALL_ITEMS_VIEW_ID) { await viewsService.updateHiddenPreset(id); } } }, onUndo: () => { if (!idForUndo) { return; } this.setDefaultViewId(prevDefaultView); this._onActionUndo(idForUndo, 'view-delete'); }, onTryAgain: async () => { await immediateAction(); this._onTryAgain('view-delete'); }, onError: (_: unknown) => { this.setDefaultViewId(prevDefaultView); this._onError('view-delete', createdAt, idForUndo); }, showUndoToast: true, successToast: { biName: 'cairo-delete-view-success', message: this.collection.translate('cairo.deleteView.Success.toast', { viewName: view.name, }), }, errorToast: (err) => { const resolvedError = this.container.errorHandler?.getResolvedError?.(err); return { biName: 'cairo-delete-view-error', message: resolvedError?.message!, ...(resolvedError?.action ? { action: resolvedError?.action } : {}), type: 'ERROR', }; }, }); immediateAction(); return; } async renameView( newView: PrivateView, oldName: string, ): Promise> { const createdAt = Date.now(); const immediateAction = () => { this.selectView({ id: newView.id }); }; this.optimisticActions.createOne(newView, { submit: async () => { await this.viewsService.saveView(newView as View, { isRename: true, }); return [newView]; }, onUndo: () => { this._onActionUndo(newView.id, 'view-rename', newView.name); }, onTryAgain: () => { immediateAction(); this._onTryAgain('view-rename'); }, onError: (_: unknown) => { this._onError('view-rename', createdAt, newView.id); }, showUndoToast: true, successToast: { biName: 'cairo-rename-view-success', message: this.collection.translate( 'cairo.manageView.Rename.Success.toast', { oldViewName: oldName, newViewName: newView.name, }, ), }, errorToast: (err) => { const resolvedError = this.container.errorHandler?.getResolvedError?.(err); return { biName: 'cairo-rename-view-error', message: resolvedError?.message!, ...(resolvedError?.action ? { action: resolvedError?.action } : {}), type: 'ERROR', }; }, }); immediateAction(); return newView; } async setAsDefaultView( newView: PrivateView, isDefault: boolean, ): Promise> { const createdAt = Date.now(); const prevDefaultView = this.defaultViewId; const biName = isDefault ? 'view-set-as-default' : 'view-unset-as-default'; const immediateAction = () => { this.setDefaultViewId(isDefault ? newView.id : null); }; this.optimisticActions.createOne(newView, { submit: async () => { await this.viewsService.updateDefaultView( isDefault ? newView : undefined, ); return [newView]; }, onUndo: () => { this.setDefaultViewId(prevDefaultView); this._onActionUndo(newView.id, biName); }, onTryAgain: () => { immediateAction(); this._onTryAgain(biName); }, onError: (_: unknown) => { this.setDefaultViewId(prevDefaultView); this._onError(biName, createdAt, newView.id); }, showUndoToast: isDefault, successToast: isDefault ? { biName: 'cairo-set-default-view-success', message: this.collection.translate( 'cairo.manageView.setAsDefault.success.toast', { viewName: newView.name, }, ), } : undefined, errorToast: (err) => { const resolvedError = this.container.errorHandler?.getResolvedError?.(err); return { biName: 'cairo-set-default-view-error', message: resolvedError?.message ?? this.collection.translate( 'cairo.manageView.setAsDefault.error.toast', { viewName: newView.name, }, ), ...(resolvedError?.action ? { action: resolvedError?.action } : {}), type: 'ERROR', }; }, }); immediateAction(); return newView; } get isReady(): boolean { const { isError, isFetching, isSuccess } = this.collection.result.status; return !isError && !isFetching && isSuccess; } setDefaultViewId(defaultViewId: string | null) { this.defaultViewId = defaultViewId; } unselectView() { this.currentView = null; } selectView({ id, needFiltersReset, }: { id: string; needFiltersReset?: boolean; }): void { const view = this.getView(id); this.currentView = view ?? null; this.events.emit('selectAndRefresh', needFiltersReset); } get reachedViewsLimit(): boolean { return ( this.getViews().filter(({ isPreset }) => !isPreset).length >= MAX_SAVED_VIEWS ); } get isCurrentViewSelected(): boolean { return !!this.currentView?.id; } hasViewName(viewName: string): boolean { return this.getViews() .map(({ name }) => name) .includes(viewName); } }