import { CollectionState, CollectionStateParams, DataResultRaw, FiltersMap, OptionalFiltersMap, WixPatternsContainer, stringFilter, CollectionOptimisticActions, ComputedQuery, parseFqdn, } from '@wix/bex-core'; import { ICollectionComponentState } from '../ICollectionComponentState'; import { ToolbarCollectionState } from '../ToolbarCollectionState'; import { KanbanDragEndItemEvent } from './KanbanDragEndEvent'; import { KanbanStageState } from './KanbanStageState'; import { observable, makeObservable, runInAction, action, computed, reaction, comparer, } from 'mobx'; import { KanbanDragAndDropState } from '../../components/KanbanDragAndDropDndKit/KanbanCardDragAndDrop'; import { v4 as uuid } from 'uuid'; import { EventEmitter } from 'events'; import { TypedEmitter } from '@wix/bex-core/events'; import { KanbanStateBIReporter } from './KanbanStateBIReporter'; export const KanbanComponentType = 'TableKanbanSwitch'; export type stageType = 'DONE' | 'NONE'; export interface KanbanStateBaseParams< T, S, F extends FiltersMap = OptionalFiltersMap, > { fqdn?: string; kanbanId: string; stages: { fetchData: CollectionStateParams['fetchData']; stageKey: CollectionStateParams['itemKey']; stageName: CollectionStateParams['itemName']; stageType?(stage: S): stageType; }; items: { fetchData: CollectionStateParams['fetchData']; itemKey: CollectionStateParams['itemKey']; itemName: CollectionStateParams['itemName']; stageField: string; filters?: F; fetchAllByStageIds?: (params: { ids: string[]; filters: ComputedQuery['filters']; search?: ComputedQuery['search']; }) => Promise>>; dragAndDrop?: { onSubmit?: (event: KanbanDragEndItemEvent) => Promise; }; create?: { navigateToEntityPage: ({ stage }: { stage: S }) => void; title: string; }; }; } export interface KanbanStateParams< T, S, F extends FiltersMap = OptionalFiltersMap, > extends KanbanStateBaseParams { readonly container: WixPatternsContainer; } export interface KanbanStateParamsAPI< T, F extends FiltersMap = OptionalFiltersMap, > { readonly toolbar: ToolbarCollectionState; } export class KanbanState implements ICollectionComponentState, KanbanStateParamsAPI { readonly container: WixPatternsContainer; kanbanStageStates: Record< string, KanbanStageState }> > = {}; private stageStateDisposers: Record void> = {}; readonly toolbar: ToolbarCollectionState; readonly stagesCollectionState: CollectionState; readonly toolbarQueryCollectionState: CollectionState; readonly dragAndDropState: KanbanDragAndDropState< T, S, F & { stageId: ReturnType } >; readonly debouncedStagesRefresh: () => unknown; readonly stageType: KanbanStateBaseParams['stages']['stageType']; items: KanbanStateBaseParams['items']; isRefetching = false; isChangingFiltersValue = false; private stagesOptimisticActions: CollectionOptimisticActions; readonly events = new EventEmitter() as TypedEmitter<{ scrollToStage: (params: { stageKey: string }) => void; scrollToCardInStage: (params: { stageKey: string; cardKey: string; }) => void; }>; readonly kanbanStateBIReporter: KanbanStateBIReporter; readonly kanbanId: string; private _searchResultsBiEventCallback: (() => void) | null = null; constructor(params: KanbanStateParams) { this.container = params.container; this.kanbanId = params.kanbanId; this.items = params.items; this.toolbarQueryCollectionState = this.createToolbarQueryCollectionState(params); this.stageType = params.stages.stageType; this.stagesCollectionState = this.createStagesCollectionState(params); this.stagesOptimisticActions = new CollectionOptimisticActions({ collection: this.stagesCollectionState, container: this.container, }); this.toolbar = new ToolbarCollectionState({ collection: this.toolbarQueryCollectionState, container: params.container, componentType: KanbanComponentType, reportBi: (ev) => { // Filter out BI events to avoid duplicates - KanbanStateBIReporter handles them const isSearchEvent = ev.src === 144 && (ev.evid === 116 || ev.evid === 117); // cairoFilterToggled or cairoSearchResults or cairoLoadEnd const isLoadEndEvent = ev.src === 144 && ev.evid === 111; // cairoLoadEnd if (!isSearchEvent && !isLoadEndEvent) { this.container.reportBi(ev); } }, }); this.dragAndDropState = new KanbanDragAndDropState({ kanbanState: this as any, }); this.kanbanStateBIReporter = new KanbanStateBIReporter(this); makeObservable(this, { kanbanStageStates: observable, updateStage: action, stages: computed, isRefetching: observable, refetchKanban: action, hasStagesError: computed, isLoading: computed, isLoadingStages: computed, }); this.debouncedStagesRefresh = this.container.lodash.debounce(async () => { this.isChangingFiltersValue = false; const currentSearchTerm = this.queryComputed.search || ''; if (currentSearchTerm) { this._searchResultsBiEventCallback = this.kanbanStateBIReporter.reportSearchTriggered(currentSearchTerm); } else { this._searchResultsBiEventCallback = null; } await this.initializeStageStates(); if (this._searchResultsBiEventCallback) { this._searchResultsBiEventCallback(); this._searchResultsBiEventCallback = null; } }, 300); } get query() { return this.toolbarQueryCollectionState.query; } get filters() { return this.toolbarQueryCollectionState.filters; } get queryComputed() { return this.query.asComputed; } triggerDebouncedStagesRefresh() { this.isChangingFiltersValue = true; this.debouncedStagesRefresh(); } async init() { const disposers = [ this.stagesCollectionState.init(), this.stagesOptimisticActions.init(), this.kanbanStateBIReporter.initAdditionalFeaturesAvailability(), this.toolbar.init({ featuresInitializers: [] }), this.dragAndDropState.init(), reaction( () => ({ filters: this.queryComputed.filters, search: this.queryComputed.search, }), () => { this.triggerDebouncedStagesRefresh(); }, { fireImmediately: false, equals: comparer.structural, }, ), ]; this.kanbanStateBIReporter.init(); await this.loadStages(); await this.initializeStageStates(); this.kanbanStateBIReporter.appLoaded?.(); return () => { Object.keys(this.stageStateDisposers).forEach((stageId) => { this.disposeStageState(stageId); }); disposers.forEach((d) => d?.()); }; } async loadStages() { await this.stagesCollectionState?.initTask?.status?.promise?.catch( (error) => { this.showErrorToast(error); }, ); } async initializeStageStates() { if (!this.items.fetchAllByStageIds) { return; } try { const stageData = await this.fetchStagesData(); this.processAllStages(stageData); } catch (error) { this.showErrorToast(error); } } get stages() { return this.stagesCollectionState.result.keyedItems.map( (item: any) => item.item, ); } getStageState(stage: S) { return this.kanbanStageStates[this.stagesCollectionState.itemKey(stage)]; } getStageStateByKey(stageKey: string) { return this.kanbanStageStates[stageKey]; } get isLoading() { return ( this.isLoadingStages || Object.values(this.kanbanStageStates).some((stage) => stage.isLoading) ); } get isLoadingStages() { return ( this.stagesCollectionState.initTask.status.isLoading || this.isRefetching || Object.keys(this.kanbanStageStates).length !== this.stagesCollectionState.result.keyedItems.length ); } isInSearchMode() { const search = this.queryComputed.search; return search && search.length > 0; } isInFilterMode() { return this.queryComputed.hasActiveFilters; } getIsChangingFiltersValue() { return this.isChangingFiltersValue; } stageKey(stage: S) { return this.stagesCollectionState.itemKey(stage); } get hasStagesError() { return this.stagesCollectionState.result.status?.isError; } // Public methods public async refetchKanban() { const startTime = performance.now(); this.toolbar.toolbarBIReporter.loadStart(); runInAction(() => { this.isRefetching = true; }); try { await this.stagesCollectionState.clearResultBeforeRefresh(); await this.stagesCollectionState.refreshAllPages(); await this.initializeStageStates(); } catch (error) { this.showErrorToast(error); } finally { // Report refetch completion with timing this.kanbanStateBIReporter.reportOnLoadEnd( Math.round(performance.now() - startTime), ); runInAction(() => { this.isRefetching = false; }); } } // Private methods showErrorToast(error: unknown) { const resolvedError = this.container.errorHandler?.getResolvedError?.(error); resolvedError && this.container.errorHandler?.showError?.(resolvedError); } private createToolbarQueryCollectionState( params: KanbanStateParams, ) { return new CollectionState({ errorMonitor: this.container.errorMonitor, history: this.container.history, lodash: this.container.lodash, onlineState: this.container.onlineState, queryCache: this.container.queryCache, translate: this.container.translate, fqdn: params.fqdn ? parseFqdn(params.fqdn) : undefined, queryName: `kanban-toolbar-query-items-${this.kanbanId}`, fetchData: () => Promise.resolve({ items: [], total: 0, cursor: null, }), itemKey: () => '', itemName: () => '', filters: params.items.filters ?? ({} as F), errorHandler: this.container.errorHandler, paginationMode: 'cursor', }); } private createStagesCollectionState(params: KanbanStateParams) { return new CollectionState({ errorMonitor: this.container.errorMonitor, history: this.container.history, lodash: this.container.lodash, onlineState: this.container.onlineState, queryCache: this.container.queryCache, translate: this.container.translate, queryName: `kanban-stages-${params.kanbanId}`, fetchData: params.stages.fetchData, itemKey: params.stages.stageKey, itemName: params.stages.stageName, filters: {} as F, errorHandler: this.container.errorHandler, paginationMode: 'cursor', }); } private async fetchStagesData() { const stageIds = this.getStageIds(); return this.items.fetchAllByStageIds!({ ids: stageIds, filters: this.queryComputed.filters, search: this.queryComputed.search, }); } private getStageIds(): string[] { return this.stagesCollectionState.result.keyedItems.map((item) => this.stagesCollectionState.itemKey(item.item), ); } private processAllStages(stageData: Record>) { this.cleanupUnusedStageStates(); this.processCurrentStages(stageData); } private cleanupUnusedStageStates() { // Get current stage IDs to identify which ones are no longer needed const currentStageIds = new Set( this.stagesCollectionState.result.keyedItems.map((item) => this.stagesCollectionState.itemKey(item.item), ), ); // Clean up stage states that no longer exist Object.keys(this.kanbanStageStates).forEach((stageId) => { if (!currentStageIds.has(stageId)) { this.disposeStageState(stageId); } }); } private disposeStageState(stageId: string) { // Dispose of the stage state using stored disposer const disposer = this.stageStateDisposers[stageId]; if (disposer) { disposer(); Reflect.deleteProperty(this.stageStateDisposers, stageId); } runInAction(() => { Reflect.deleteProperty(this.kanbanStageStates, stageId); }); } private processCurrentStages(stageData: Record>) { this.stagesCollectionState.result.keyedItems.forEach((keyedStage) => { const stage = keyedStage.item; runInAction(() => { const stageId = this.stagesCollectionState.itemKey(stage); const hasDataForStage = !!stageData[stageId]; if (!this.kanbanStageStates[stageId]) { this.createNewStageState( stage, stageId, stageData[stageId], hasDataForStage, ); } else { this.updateExistingStageState( stage, stageId, stageData[stageId], hasDataForStage, ); } }); }); } private createNewStageState( stage: S, stageId: string, stageData: DataResultRaw, hasDataForStage: boolean, ) { this.kanbanStageStates[stageId] = new KanbanStageState< T, S, F & { stageId: ReturnType } >({ container: this.container, fetchData: this.items.fetchData, itemKey: this.items.itemKey, itemName: this.items.itemName, initialPage: hasDataForStage ? this.getInitialPageData(stageData) : { items: [], total: 0, cursor: null, }, name: this.stagesCollectionState.itemName(stage), key: this.stagesCollectionState.itemKey(stage), kanbanId: this.kanbanId, stage, query: this.query as any, }); const disposer = this.kanbanStageStates[stageId].init(); this.stageStateDisposers[stageId] = disposer; } private updateExistingStageState( stage: S, stageId: string, stageData: DataResultRaw, hasDataForStage: boolean, ) { const stageState = this.kanbanStageStates[stageId]; stageState.name = this.stagesCollectionState.itemName(stage); runInAction(() => { const initialPageData = this.getInitialPageData( hasDataForStage ? stageData : { items: [], total: 0, cursor: null, }, ); stageState.itemsCollectionState.setInitialPage(initialPageData); // Directly update QueryResultState to trigger MobX reactivity for keyedItems const items = initialPageData.items || []; const total = initialPageData.total ?? items.length; const cursor = initialPageData.cursor; stageState.itemsCollectionState.result.setPages({ processed: [items], unprocessed: [items], }); stageState.itemsCollectionState.result.setTotals({ processed: total, unprocessed: total, }); stageState.setTotal(stageData?.total ?? 0); if (cursor != null) { stageState.itemsCollectionState.paginationMode.setCursor(cursor); } }); } private getInitialPageData(stageData: DataResultRaw) { return { items: stageData?.items, total: stageData?.total, cursor: stageData?.cursor, }; } private getStageIdFromItem(item: T): string { return (item as Record)[this.items.stageField]; } // optimistic actions updateStage(originalStage: S, updatedStage: S) { const stageId = this.stagesCollectionState.itemKey(originalStage); const stageState = this.getStageStateByKey(stageId); this.stagesCollectionState.optimisticActions.updateOne(updatedStage, { submit: async (items) => { // just return the item that was passed in return Promise.resolve(items[0]); }, keepPosition: true, }); runInAction(() => { if ( stageState.name !== this.stagesCollectionState.itemName(updatedStage) ) { stageState.name = this.stagesCollectionState.itemName(updatedStage); } }); } setStages(stages: S[]) { runInAction(() => { const getKey = (stage: S) => this.stagesCollectionState.itemKey(stage); const currentStageIds = new Set(Object.keys(this.kanbanStageStates)); const newStageIds = new Set(stages.map(getKey)); this.stagesCollectionState.result.setPages({ processed: [stages], unprocessed: [stages], }); this.stagesCollectionState.result.setTotals({ processed: stages.length, unprocessed: stages.length, }); currentStageIds.forEach((stageId) => { if (!newStageIds.has(stageId)) { this.disposeStageState(stageId); } }); stages.forEach((stage) => { const stageId = getKey(stage); if (!this.kanbanStageStates[stageId]) { this.createNewStageState( stage, stageId, { items: [], total: 0, cursor: null }, false, ); } else { this.kanbanStageStates[stageId].name = this.stagesCollectionState.itemName(stage); } }); }); } createStage(stage: S) { this.stagesCollectionState.optimisticActions.createOne(stage, { submit: (_items) => Promise.resolve(), }); runInAction(() => { const stageId = this.stagesCollectionState.itemKey(stage); this.createNewStageState( stage, stageId, { items: [], total: 0, cursor: null, }, false, ); }); } scrollToStage({ stageKey }: { stageKey: string }) { this.events.emit('scrollToStage', { stageKey }); } scrollToCardInStage({ stageKey, cardKey, }: { stageKey: string; cardKey: string; }) { this.events.emit('scrollToCardInStage', { stageKey, cardKey }); } deleteStage(stage: S) { const stageId = this.stagesCollectionState.itemKey(stage); this.stagesCollectionState.optimisticActions.deleteOne(stage, { submit: (_items) => Promise.resolve(), }); this.disposeStageState(stageId); } reorderStages(fromIndex: number, toIndex: number) { const keyedItems = this.stagesCollectionState.result.keyedItems; if (fromIndex === toIndex) { return; } const from = keyedItems[fromIndex]; const over = keyedItems[toIndex]; let after; if (toIndex === 0) { after = null; } else if (fromIndex < toIndex) { after = over; } else { after = keyedItems[toIndex - 1]; } const actionMove = { moveId: uuid(), from, over, after, isFromHandle: false, filtersKeyHash: this.stagesCollectionState.query.filtersKeyHash, query: this.stagesCollectionState.originQuery, }; this.stagesCollectionState.optimisticActions._orderByMode = 'moves'; this.stagesCollectionState.optimisticActions.move(actionMove, { collectionSnapshot: {}, }); } moveItemBetweenStages( originalItem: T, updatedItem: T, fromStageId: string, toStageId: string, ) { this.getStageStateByKey(fromStageId).deleteItem(originalItem); this.getStageStateByKey(toStageId).createItem(updatedItem); } // optimistic actions card deleteCard(card: T) { const stageId = this.getStageIdFromItem(card); this.getStageStateByKey(stageId).deleteItem(card); } createCard(card: T) { const stageId = this.getStageIdFromItem(card); this.getStageStateByKey(stageId).createItem(card); } updateCard(card: T) { const stageId = this.getStageIdFromItem(card); this.getStageStateByKey(stageId).updateItem(card); } // Helper functions for BI reporting getStageIndexByKey(stageKey: string): number { return this.stages.findIndex( (stage) => this.stagesCollectionState.itemKey(stage) === stageKey, ); } getStageNameByKey(stageKey: string): string { const stage = this.stages.find( (s) => this.stagesCollectionState.itemKey(s) === stageKey, ); return stage ? this.stagesCollectionState.itemName(stage) : ''; } getStageById(stageKey: string): S | undefined { return this.stages.find( (stage) => this.stagesCollectionState.itemKey(stage) === stageKey, ); } }