import { RefObject } from 'react'; import { action, computed, makeObservable, observable } from 'mobx'; import { ConditionalModalState, TAG_MAX_LENGTH, TagsBIReporter, TagsManagementState, TagsSearchState, ToastConfig, } from '@wix/bex-core'; import { InputStatus, MessageModalLayoutProps } from '@wix/design-system'; import { cairoTagsModalStartLoad } from '@wix/bex-core/bi'; import { Tag } from '@wix/bex-utils/@wix/ambassador-os-tags-v1-tag/types'; import { addEventListener } from '@wix/bex-core/util'; export interface TagsModalStateParams { readonly tagsManagementState: TagsManagementState; readonly entityTypeName: string; readonly showUndoToast?: boolean; } const actionOrigin = 'Manage Tags Modal'; // Render window for the tags list — the full list (up to 1000 tags) is in // memory, but rendering it all at once makes the modal sluggish const SHOWN_ITEMS_WINDOW_SIZE = 100; export class TagsModalState { readonly tagsManagementState; readonly tagsSearchState; readonly manageTagsModalState = new ConditionalModalState(); readonly confirmModalState = new ConditionalModalState< Partial >(); readonly container; readonly tagsBIReporter: TagsBIReporter; readonly entityTypeName: string; readonly showUndoToast: boolean; origin?: string = undefined; tagSubOrigin?: string = undefined; editedTagId: string | null = null; editedTagErrorMessage: string | null = null; isShowingNewTagListItem: boolean = false; tagToDelete?: Tag; toastConfigs = observable.array([] as ToastConfig[], { deep: false, }); ref: RefObject | null = null; shownItemsLimit: number = SHOWN_ITEMS_WINDOW_SIZE; constructor({ tagsManagementState, entityTypeName, showUndoToast = true, }: TagsModalStateParams) { this.tagsManagementState = tagsManagementState; this.tagsSearchState = new TagsSearchState({ tagsManagementState, }); this.entityTypeName = entityTypeName; this.showUndoToast = showUndoToast; this.container = tagsManagementState.container; this.tagsBIReporter = new TagsBIReporter({ collection: this.tagsManagementState.collection, reportBi: this.tagsManagementState.container.createBILogger({ componentType: 'ManageTagsModal', ...tagsManagementState.fqdn, }), location: 'Manage tags modal', }); makeObservable(this, { editedTagId: observable, editedTagErrorMessage: observable, isShowingNewTagListItem: observable, shownItemsLimit: observable, shownItems: computed, showMoreItems: action.bound, init: action, openManageTagsModal: action, closeManageTagsModal: action, handleSearchChange: action, handleSearchClear: action, handleCreateCtaClick: action, handleEditCtaClick: action, handleCancelEditCtaClick: action, openConfirmDeleteModal: action, createTagFromSearch: action, createTagFromList: action, updateTag: action, deleteTag: action, showNotification: action, hideNotification: action, }); } init() { const disposers = [ addEventListener( this.tagsManagementState.events, 'showToast', (toastConfig: ToastConfig) => { this.showNotification(toastConfig); }, ), addEventListener( this.tagsManagementState.events, 'hideToast', (toastConfig: ToastConfig) => { this.hideNotification(toastConfig); }, ), () => { window.clearTimeout(this.handleCreateCtaTimeout); window.clearTimeout(this.showNotificationTimeout); window.clearTimeout(this.createTagFromListTimeout); window.clearTimeout(this.updateTagTimeout); }, ]; return () => { disposers.forEach((d) => d?.()); }; } openManageTagsModal({ origin, tagSubOrigin, }: { origin: string; tagSubOrigin?: string; }) { this.origin = origin; this.tagSubOrigin = tagSubOrigin; this.shownItemsLimit = SHOWN_ITEMS_WINDOW_SIZE; this.manageTagsModalState.modal.open(); this._reportBiModalOpened(); this.tagsManagementState.overrideShowToast = true; } closeManageTagsModal() { this.editedTagId = null; this.editedTagErrorMessage = null; this.isShowingNewTagListItem = false; this.tagsSearchState.setSearchValue(''); this._reportSearchEventsBi('blur'); this.manageTagsModalState.modal.close(); this._reportModalClosedBi(); this.origin = undefined; this.tagsManagementState.overrideShowToast = false; this.hideNotification(); } openConfirmDeleteModal(props?: Partial) { this.confirmModalState.open({ theme: 'destructive', primaryButtonText: this.container.translate( 'cairo.tags.manageTags.modal.deleteModal.delete.CTA', ), secondaryButtonText: this.container.translate( 'cairo.tags.manageTags.modal.deleteModal.cancel.CTA', ), title: this.container.translate( 'cairo.tags.manageTags.modal.deleteModal.title', ), ...props, }); } handleSearchChange(newSearchValue: string) { this.editedTagId = null; this.isShowingNewTagListItem = false; this.shownItemsLimit = SHOWN_ITEMS_WINDOW_SIZE; this._reportSearchChangedBi(newSearchValue); this.tagsSearchState.setSearchValue(newSearchValue); } handleSearchClear() { this._reportSearchEventsBi('clear'); this.shownItemsLimit = SHOWN_ITEMS_WINDOW_SIZE; this.tagsSearchState.setSearchValue(''); } showMoreItems() { this.shownItemsLimit += SHOWN_ITEMS_WINDOW_SIZE; } handleCreateCtaClick() { this._showAllItems(); this.tagsSearchState.setSearchValue(''); this.editedTagId = null; this.isShowingNewTagListItem = true; this.handleCreateCtaTimeout = window.setTimeout( () => this._scrollContentTo('bottom'), 100, ); } handleEditCtaClick(tag: Tag) { this._reportSearchEventsBi('select'); this.editedTagId = tag.id!; } handleCancelEditCtaClick() { this.editedTagId = null; this.isShowingNewTagListItem = false; } showNotification(toastConfig: ToastConfig) { this.toastConfigs.push(toastConfig); this.showNotificationTimeout = window.setTimeout(() => { this.hideNotification(toastConfig); }, 6000); } hideNotification(toastConfig?: ToastConfig) { if (toastConfig) { this.toastConfigs.remove(toastConfig); } else { this.toastConfigs.clear(); } } async createTagFromSearch() { if (!this.searchValue || this.shownItems.length) { return; } this._showAllItems(); const newName = this.searchValue.slice(0, TAG_MAX_LENGTH); this._reportSearchEventsBi('add'); this._createTag({ name: newName, isFromSearch: true, onSuccess: () => this._scrollContentTo('bottom'), }); this.tagsSearchState.setSearchValue(''); } async createTagFromList(newName: string) { this._showAllItems(); const { status, statusMessage } = this._isValidName(newName); if (status === 'error') { this.editedTagErrorMessage = statusMessage; this.createTagFromListTimeout = window.setTimeout( () => this._scrollContentTo('bottom'), 100, ); return; } this._createTag({ name: newName, }); this.isShowingNewTagListItem = false; } async updateTag({ tag, newName, isLastTag, }: { tag: Tag; newName: string; isLastTag: boolean; }) { if (newName === tag.name) { this.editedTagId = null; return; } const { status, statusMessage } = this._isValidName(newName); if (status === 'error') { this.editedTagErrorMessage = statusMessage; if (isLastTag) { this.updateTagTimeout = window.setTimeout( () => this._scrollContentTo('bottom'), 100, ); } return; } this.editedTagId = null; const previousName = tag.name!; const isFromSearch = this.tagsSearchState.searchValue !== ''; this.tagsManagementState.updateTag({ tagToUpdate: { ...tag, name: newName }, previousName, isFromSearch, filteredListSize: this.shownItems.length, origin: actionOrigin, tagSubOrigin: this.tagSubOrigin, tagsBIReporter: this.tagsBIReporter, optimisticActionParams: { keepPosition: true, successToast: { biName: 'cairo-save-rename-tag-success', message: this.container.translate( 'cairo.tags.manageTags.modal.toast.rename.success', ), }, showUndoToast: this.showUndoToast, errorToast: (error) => { const resolvedError = this.container.errorHandler.getResolvedError?.(error); return { biName: 'cairo-save-rename-tag-error', message: resolvedError?.message ?? this.container.translate( 'cairo.tags.manageTags.modal.toast.rename.error', ), type: 'ERROR', }; }, }, }); } async deleteTag(tagToDelete: Tag) { this._reportSearchEventsBi('select'); this.tagToDelete = tagToDelete; const isFromSearch = this.tagsSearchState.searchValue !== ''; this.openConfirmDeleteModal({ primaryButtonOnClick: async () => { this.tagsManagementState.deleteTag({ tagToDelete, isFromSearch, filteredListSize: this.shownItems.length, origin: actionOrigin, tagSubOrigin: this.tagSubOrigin, tagsBIReporter: this.tagsBIReporter, optimisticActionParams: { successToast: { biName: 'cairo-delete-tag-success', message: this.container.translate( 'cairo.tags.manageTags.modal.toast.delete.success', ), }, showUndoToast: this.showUndoToast, errorToast: (error) => { const resolvedError = this.container.errorHandler.getResolvedError?.(error); return { biName: 'cairo-delete-tag-error', message: resolvedError?.message ?? 'cairo.tags.manageTags.modal.toast.delete.error', type: 'ERROR', }; }, }, }); }, }); } get searchValue() { return this.tagsSearchState.searchValue; } get shownItems() { return [...this.tagsSearchState.items] .sort( (tag1, tag2) => new Date(tag1.createdDate!).valueOf() - new Date(tag2.createdDate!).valueOf(), ) .slice(0, this.shownItemsLimit); } get isOpen() { return this.manageTagsModalState.isOpen; } get toastConfig() { return this.toastConfigs[this.toastConfigs.length - 1] ?? null; } private handleCreateCtaTimeout?: number; private showNotificationTimeout?: number; private createTagFromListTimeout?: number; private updateTagTimeout?: number; // A created tag sorts to the end of the list — the render window must cover // the full list so the deferred scroll-to-bottom actually reveals it private _showAllItems() { this.shownItemsLimit = Number.MAX_SAFE_INTEGER; } private async _createTag({ name, isFromSearch = false, onSuccess, }: { name: string; isFromSearch?: boolean; onSuccess?: (tag: Tag) => void; }) { this.tagsManagementState.createTag({ name, isFromSearch, filteredListSize: this.shownItems.length, onSuccess, origin: actionOrigin, tagSubOrigin: this.tagSubOrigin, tagsBIReporter: this.tagsBIReporter, visualType: 'notification', optimisticActionParams: { successToast: { biName: 'cairo-save-new-tag-success', message: this.container.translate( 'cairo.tags.manageTags.modal.toast.create.success', ), }, errorToast: (error) => { const resolvedError = this.container.errorHandler.getResolvedError?.(error); return { biName: 'cairo-save-new-tag-error', message: resolvedError?.message ?? this.container.translate( 'cairo.tags.manageTags.modal.toast.create.error', ), action: resolvedError?.action, }; }, }, }); this.editedTagErrorMessage = null; } private _isValidName(tagName: string) { if (tagName.length === 0) { return this._getValidationMessage( this.container.translate( 'cairo.tags.manageTags.modal.list.input.validation.emptyName', ), ); } if (tagName.length > TAG_MAX_LENGTH) { return this._getValidationMessage( this.container.translate( 'cairo.tags.manageTags.modal.list.input.validation.maxLength', { count: TAG_MAX_LENGTH }, ), ); } if ( this.tagsManagementState.items.find( ({ name }) => name!.toLowerCase() === tagName.toLowerCase(), ) ) { return this._getValidationMessage( this.container.translate( 'cairo.tags.manageTags.modal.list.input.validation.exists', ), ); } return { status: '' as InputStatus, statusMessage: '' }; } private _reportBiModalOpened() { this.tagsBIReporter.reportBi( cairoTagsModalStartLoad({ numTags: this.tagsManagementState.items?.length, origin: this.origin, }), ); } private _reportModalClosedBi() { this.tagsBIReporter.onModalClose({ origin: this.origin }); } private _reportSearchChangedBi(searchValue: string) { if (searchValue !== '') { this.tagsBIReporter.onSearch(searchValue); } else { this.tagsBIReporter._clearAllEvents(); } } private _reportSearchEventsBi(event: 'add' | 'select' | 'clear' | 'blur') { return this.tagsBIReporter.events.emit(event); } private _scrollContentTo(direction: 'top' | 'bottom' = 'top') { this.ref?.current?.scrollTo({ top: direction === 'top' ? 0 : this.ref?.current?.scrollHeight, behavior: 'smooth', }); } private _getValidationMessage(statusMessage: string) { return { status: 'error' as InputStatus, statusMessage, }; } }