import { action, makeObservable, computed, observable } from 'mobx'; import { TagsModalState } from './TagsModalState'; import { Tag } from '@wix/bex-utils/@wix/ambassador-os-tags-v1-tag/types'; import { CollectionOptimisticActions, CollectionState, FiltersMap, KeyedItem, TAG_MAX_LENGTH, Tags, TagsBIReporter, TagsManagementState, } from '@wix/bex-core'; import { cairoFiltersPanelUsed } from '@wix/bex-core/bi'; import { v4 as uuid } from 'uuid'; import { TagsProps } from '../components/Tags'; import { TagsBulkAssignButtonState } from './TagsBulkAssignButtonState'; import { TagsBulkAssignAsyncState } from './TagsBulkAssignAsyncState'; import { TagsBulkAssignSyncState } from './TagsBulkAssignSyncState'; import { TagsAssignButtonConfig } from '../components/Tags/types'; const origin = 'bulk_assign_popover'; export interface BulkUpdateTagsParams { entityIds: string[]; assignTags: Tags; unassignTags: Tags; } export interface TagsBulkAssignPopoverStateParams extends Pick< TagsProps, | 'bulkUpdateTags' | 'asyncBulkUpdateTags' | 'getTags' | 'assignButtonConfig' | 'bulkUpdateTagsShowUndoToast' > { readonly tagsManagementState: TagsManagementState; readonly tagsModalState: TagsModalState; readonly collectionState: CollectionState; } export class TagsBulkAssignPopoverState { readonly tagsManagementState: TagsManagementState; readonly collectionState; readonly optimisticActions: CollectionOptimisticActions; readonly tagsModalState; readonly tagsBulkAssignButtonState: TagsBulkAssignButtonState; readonly bulkAssignSyncState: TagsBulkAssignSyncState; readonly bulkAssignAsyncState: TagsBulkAssignAsyncState; readonly container; readonly tagsBIReporter: TagsBIReporter; readonly getTags?: (item: T) => Tags; readonly assignButtonConfig?: TagsAssignButtonConfig; initialSelectedIds: string[] = []; initialIndeterminateTagIds: string[] = []; indeterminateTagIds: string[] = []; assignedTagIds: string[] = []; optimisticNewTag: Tag | null = null; isShown = false; searchValue: string = ''; triggerButtonRef: React.RefObject | null = null; constructor({ tagsManagementState, collectionState, tagsModalState, bulkUpdateTags, asyncBulkUpdateTags, getTags, assignButtonConfig, bulkUpdateTagsShowUndoToast, }: TagsBulkAssignPopoverStateParams) { this.tagsManagementState = tagsManagementState; this.collectionState = collectionState; this.optimisticActions = collectionState._optimisticActions ?? new CollectionOptimisticActions({ container: this.tagsManagementState.container, collection: this.collectionState, }); this.tagsBIReporter = new TagsBIReporter({ collection: this.tagsManagementState.collection, reportBi: this.tagsManagementState.container.createBILogger({ componentType: 'TagsBulkAssignPopover', ...tagsManagementState.fqdn, }), location: 'Bulk assign tags popover', }); this.tagsModalState = tagsModalState; this.container = tagsManagementState.container; this.getTags = getTags; this.assignButtonConfig = assignButtonConfig; this.bulkAssignSyncState = new TagsBulkAssignSyncState({ collectionState, optimisticActions: this.optimisticActions, container: this.container, tagsBIReporter: this.tagsBIReporter, getTags, bulkUpdateTags, bulkUpdateTagsShowUndoToast, }); this.bulkAssignAsyncState = new TagsBulkAssignAsyncState({ collectionState, optimisticActions: this.optimisticActions, container: this.container, tagsBIReporter: this.tagsBIReporter, asyncBulkUpdateTags, }); this.tagsBulkAssignButtonState = new TagsBulkAssignButtonState(); makeObservable(this, { init: action, isShown: observable, initialSelectedIds: observable, initialIndeterminateTagIds: observable, indeterminateTagIds: observable, searchValue: observable, showPopover: action, closePopover: action, applyChanges: action, handleManageTagsCtaClick: action, handleItemSelect: action, handleSearchChange: action, createAndAssignTag: action, retry: action, assignedTagIds: observable, optimisticNewTag: observable.ref, assignedTags: computed, }); } init() { const disposers = [ this.optimisticActions.init(), this.bulkAssignAsyncState.init(), ]; return () => { disposers.forEach((disposer) => disposer()); }; } async showPopover() { if (this.isShown) { return; } this.isShown = true; this._reportPopoverShowChangedBi(true); const { selectedTagIds, indeterminateTagIds } = this._initSelectionState(); this.initialSelectedIds = selectedTagIds; this.assignedTagIds = selectedTagIds; this.initialIndeterminateTagIds = indeterminateTagIds; this.indeterminateTagIds = indeterminateTagIds; } closePopover() { this._reportPopoverShowChangedBi(false); this._reportSearchEventsBi('blur'); this.isShown = false; this.assignedTagIds = []; this.initialSelectedIds = []; this.initialIndeterminateTagIds = []; this.indeterminateTagIds = []; requestAnimationFrame(() => { const el = this.triggerButtonRef?.current; if ( el && document.contains(el) && !el.disabled && el.getAttribute('aria-disabled') !== 'true' ) { el.focus(); } }); } async createAndAssignTag(name: string) { this._reportSearchEventsBi('add'); const tagName = name.slice(0, TAG_MAX_LENGTH); const id = uuid(); this.assignTagOptimistic(tagName, id); this.tagsManagementState.createTag({ name, id, filteredListSize: 0, origin, isFromSearch: true, tagsBIReporter: this.tagsBIReporter, onSuccess: (newTag) => this.assignTag({ tag: newTag }), optimisticActionParams: { successToast: { biName: 'cairo-save-new-tag-success', message: this.container.translate( 'cairo.tags.popover.tagCreated.toast.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.collectionPage.toast.create.error', ), action: resolvedError?.action, }; }, }, }); } async applyChanges() { const { searchValue, newAssignedTagIds, newUnassignedTagIds } = this; const params = { searchValue, newAssignedTagIds, newUnassignedTagIds, }; if (this.bulkAssignSyncState.canUseBulkSync) { this.bulkAssignSyncState.applyChanges(params); } else { await this.bulkAssignAsyncState.applyChanges(params); } this.closePopover(); } handleManageTagsCtaClick() { this.closePopover(); this.tagsModalState.openManageTagsModal({ origin, tagSubOrigin: 'bulkAssignPopover', }); } handleItemSelect(itemId: string) { const currentTag = this.tagsManagementState.items.find( (tag) => tag.id === itemId, ); if (!currentTag) { return; } this._reportSearchEventsBi('select'); if ( this.initialIndeterminateTagIds.includes(itemId) && this.indeterminateTagIds.includes(itemId) ) { this.indeterminateTagIds = this.indeterminateTagIds.filter( (indeterminateTagId) => indeterminateTagId !== itemId, ); } if (this.assignedWithOptimisticTagIds.includes(itemId)) { this.unassignTag({ tag: currentTag }); } else { this.assignTag({ tag: currentTag }); } } handleSearchChange(searchValue: string) { this._reportSearchChangedBi(searchValue); this.searchValue = searchValue; } retry() { this.tagsManagementState.retry(); } get isInEmptyState() { return ( this.tagsManagementState.isSuccessful && this.tagsManagementState.items.length === 0 ); } get tagOptions() { return this.tagsManagementState.items.sort((tag1, tag2) => { const isTag1InitialUnassigned = this._isInitialUnassignedTag(tag1); const isTag2InitialUnassigned = this._isInitialUnassignedTag(tag2); // Showing first assigned/indeterminate tags, and unassigned tags below it if (!isTag1InitialUnassigned && isTag2InitialUnassigned) { return -1; } if (isTag1InitialUnassigned && !isTag2InitialUnassigned) { return 1; } // Sub order by showing first newer tags return ( new Date(tag2.createdDate!).valueOf() - new Date(tag1.createdDate!).valueOf() ); }); } get assignedTags() { const savedAssignedTags = this.assignedTagIds .map((tagId) => { return this.tagsManagementState.items.find((tag) => tag.id === tagId); }) .filter((element) => Boolean(element)) as Tag[]; return this.optimisticNewTag ? [...savedAssignedTags, this.optimisticNewTag] : savedAssignedTags; } get assignedWithOptimisticTagIds() { return this.assignedTags.map((tag) => tag.id!); } get newAssignedTagIds() { return this.assignedTagIds.filter( (tagId) => !this.initialSelectedIds.includes(tagId), ); } get newUnassignedTagIds() { return [ ...this.initialSelectedIds.filter( (tagId) => !this.assignedTagIds.includes(tagId), ), ...this.initialIndeterminateTagIds.filter( (tagId) => ![...this.assignedTagIds, ...this.indeterminateTagIds].includes( tagId, ), ), ]; } get canUseBulkUpdate() { // Cannot invoke bulk assign while applying async changes if (this.bulkAssignAsyncState.isDuringApplyChanges) { return false; } // can use sync if (this.bulkAssignSyncState.canUseBulkSync) { return true; } // can use async return this.bulkAssignAsyncState.canBulkAssignAsync; } get isError() { return this.tagsManagementState.isError; } get error() { return this.tagsManagementState.error; } get isLoading() { return this.tagsManagementState.isLoading; } get isDirty() { return ( this.newAssignedTagIds.length > 0 || this.newUnassignedTagIds.length > 0 ); } assignTag = async ({ tag }: { tag: Tag }) => { this.optimisticNewTag = null; if (this.assignedTagIds.includes(tag.id!)) { return; } this.assignedTagIds.push(tag.id!); }; assignTagOptimistic = (tagName: string, tagId: string = 'fake tag id') => { this.optimisticNewTag = { id: tagId, name: tagName, createdDate: new Date(), }; }; unassignTag = async ({ tag }: { tag: Tag }) => { const updatedAssignedTagIds = this.assignedTagIds.filter( (assignedTagId) => assignedTagId !== tag.id, ); this.assignedTagIds = updatedAssignedTagIds; }; private _calcTagsCount = (selectedKeyedItems: KeyedItem[]) => { const tagsCount = this.tagsManagementState.items.reduce( (result: Record, tag) => { result[tag.id!] = 0; return result; }, {}, ); selectedKeyedItems.forEach((selectedKeyedItem) => { const tags: Tags = (selectedKeyedItem.item as any).tags ?? this.getTags?.(selectedKeyedItem.item); const entityTagIds = (tags?.privateTags?.tagIds as string[]) || []; [...new Set(entityTagIds)].forEach((entityTagId) => { if (entityTagId in tagsCount) { tagsCount[entityTagId]++; } }); }); return tagsCount; }; private _getSelectedTagsIdsStatus = ( tagsCount: Record, selectedKeyedItems: KeyedItem[], ) => { const selectedTagIds: string[] = []; const indeterminateTagIds: string[] = []; for (const [tagId, tagCount] of Object.entries(tagsCount)) { if (tagCount === selectedKeyedItems.length) { selectedTagIds.push(tagId); } else if (tagCount > 0) { indeterminateTagIds.push(tagId); } } return { selectedTagIds, indeterminateTagIds }; }; private _initSelectionState() { const { selectedKeyedItems } = this.collectionState.bulkSelect; const tagsCount = this._calcTagsCount(selectedKeyedItems); return this._getSelectedTagsIdsStatus(tagsCount, selectedKeyedItems); } private _isInitialUnassignedTag = (tag: Tag) => !this.initialIndeterminateTagIds.includes(tag.id!) && !this.initialSelectedIds.includes(tag.id!); private _reportPopoverShowChangedBi(isOpened: boolean) { this.tagsBIReporter.reportBi( cairoFiltersPanelUsed({ isOpened, feature: 'Tags', origin: 'Assign Tags', }), ); } private _reportSearchChangedBi(searchValue: string) { if (searchValue !== '') { this.tagsBIReporter.onSearch(searchValue); } else { this.tagsBIReporter._clearAllEvents(); } } private _reportSearchEventsBi(event: 'add' | 'select' | 'blur') { return this.tagsBIReporter.events.emit(event); } }