import { action, computed, makeObservable, observable, reaction, runInAction, } from 'mobx'; import type { ModalState, QueryPendingState, SortOrder, WixPatternsContainer, } from '@wix/bex-core'; import { CollectionBIReporter, CollectionState, FedopsReporter, Filter, FiltersMap, idArrayFilter, MultiCollectionSupport, ReportBI, TaskState, ValueBox, } from '@wix/bex-core'; import { Column, FilterProps, PrivateView, TableColumn, ViewType, } from '../model'; export { ViewType }; import { CustomColumnsProps } from '../components/CustomColumns'; import type { LiveRegionRefType, LiveRegionState, TableProps, } from '@wix/design-system'; import type { AppliedFiltersTagListState } from '../components/AppliedFiltersTagList/AppliedFiltersTagListState'; import { ToolbarFeatureInitializer } from './toolbarFeatureInitializer'; import { simplifyColumn, simplifyColumns } from './simplifyColumns'; import { indexOfCompareFn } from './indexOfCompareFn'; import type { CollectionViewsState } from './CollectionViewsState'; import { ReactElement } from 'react'; import { ToolbarBIReporter } from './ToolbarBIReporter'; import { DragAndDropState } from '../components/DragAndDrop'; import { CollectionViewsDropdownProps } from '../components/CollectionViewsDropdown'; import { ActionCellProps } from '../components/ActionCell'; import { ActionCellAPI } from './ItemActionsState'; import { CustomColumnsState } from './CustomColumnsState'; import { SidePanelsState } from './SidePanelsState'; import { CollectionToolbarFiltersState } from '../components/CollectionToolbarFilters/CollectionToolbarFiltersState'; import { MultiLevelSortingState } from './MultiLevelSortingState'; import { RectState } from './RectState'; import { BulkActionToolbarState } from './BulkActionToolbarState'; import { addEventListener } from '@wix/bex-core/util'; import type { AddApplyFiltersButton } from '../components/FiltersPanel/AddApplyFiltersButton'; import { TabsFilterState } from '../components/TabsFilter/TabsFilterState'; import { CollectionFieldsSourceState } from './CollectionFieldsSourceState'; import { TagsCollectionState } from './TagsCollectionState'; import { AdditionalFeaturesAvailabilityBIParams, FeaturesAvailabilityBIParams, } from './FeaturesAvailabilityBIParams'; import { ExtensionMenuItemsState } from './ExtensionMenuItemsState'; import { ToolbarState } from '../components/CollectionToolbars/ToolbarState'; import { MoreActionItemFactory } from '../components/MoreActions/MoreActionsBase'; import { ColumnSizesState } from './ColumnSizesState'; export type Component = () => C; export interface ToolbarCollectionStateParamsInitParams { featuresInitializers: (ToolbarFeatureInitializer | undefined)[]; hasMultiLevelSorting?: boolean; skipFilterListeners?: boolean; } export interface LayoutState { viewType: ViewType; } export interface ToolbarCollectionStateParams { readonly container: WixPatternsContainer; readonly collection: CollectionState; readonly componentType: string; readonly reportBi?: ReportBI; } export class ToolbarCollectionState { readonly collection: CollectionState; readonly selectedColumns = idArrayFilter<{ id: string; isSelected: boolean; }>(); readonly initTask = new TaskState(); readonly fedopsReporter; readonly dataCapsule; readonly toolbar; readonly container: WixPatternsContainer; readonly namespace; readonly reportBi: ReportBI; readonly featuredComponents = { moreFiltersButton: new ValueBox | null>(null), appliedFiltersTagList: new ValueBox<{ component: Component<{}>; state: AppliedFiltersTagListState; } | null>(null), exportButton: new ValueBox(null), exportModal: new ValueBox | null>(null), importButton: new ValueBox(null), importModal: new ValueBox | null>(null), }; readonly toolbarRect; readonly itemsContentRect; readonly panels; readonly bulk; readonly multi; readonly componentType; _syncedProps?: { useResponsiveLayout?: boolean; }; _userColumns: Column[] = []; // columns propagated from user _featureColumns: Column[] = observable.array(); // columns propagated from installed features _extendedColumns: Column[] = []; // columns propagated from data-extension (can be merged with above?) internalScroll = undefined as boolean | undefined; customColumnsState: CustomColumnsState | null = null; customColumnsStateInitialized = false; columnSizesState: ColumnSizesState | null = null; multiLevelSorting: MultiLevelSortingState | null = null; liveRegionRef: { current: null | LiveRegionRefType } = { current: null, }; collectionFieldsSource: CollectionFieldsSourceState | null = null; menuItemExtensions = new Map(); tagsCollectionState: TagsCollectionState | null = null; toolbarFiltersState: CollectionToolbarFiltersState | null = null; toolbarFiltersStateInitialized = false; _dnd: DragAndDropState | null = null; toolbarBIReporter: ToolbarBIReporter; filtersProps: FilterProps[] | null = null; viewsProps: CollectionViewsDropdownProps | null | undefined = null; dragAndDropCategories: (keyof F)[][] | null | undefined = null; customColumnsProps: CustomColumnsProps | null | undefined = null; selectionProps = new ValueBox<{ showSelection?: boolean }>({}); featuresAvailability: FeaturesAvailabilityBIParams = {}; additionalFeaturesAvailability: AdditionalFeaturesAvailabilityBIParams = {}; actionCell = new ValueBox< | undefined | ActionCellProps | ((item: T, index: number, api: ActionCellAPI) => ActionCellProps) >(undefined); contentPadding = 24; contentPaddingPx = `${this.contentPadding}px`; scrollElementRect = observable<{ height?: number | null; top?: number | null; }>({ height: null, top: null, }); scrollTopBeforeUnmount: number | null = null; minimizedHeaderHeight = 0; tabsFilterProps?: { filter: Filter; } = undefined; viewsState?: CollectionViewsState; exportState?: ModalState; importState?: ModalState; pendingQuery?: QueryPendingState; AddApplyFiltersButton?: typeof AddApplyFiltersButton; tabs?: TabsFilterState; layoutState?: LayoutState; // Per-layout in-memory scratchpad of uncommitted column edits. _layoutColumnsCache: Partial< Record > = {}; // Set by the coordinator on cache-hit so per-flow reactions skip the same transition. _cacheRestoredLayout?: ViewType; // Set by `_onSelectViewAndRefresh` when a view-select changes layout, so the coordinator // skips flush/restore — the view-select already loaded the correct columns. _skipNextLayoutCoordinator = false; _searchExpanded = false; forceRenderIndexes: number[] | undefined = undefined; constructor(params: ToolbarCollectionStateParams) { this.collection = params.collection; this.container = params.container; this.componentType = params.componentType; this.toolbar = new ToolbarState(this); this.fedopsReporter = new FedopsReporter({ componentName: this.componentType, collection: this.collection, internalMonitor: this.container.internalMonitor, }); this.multi = new MultiCollectionSupport({ collections: [this.collection as CollectionState], }); this.itemsContentRect = new RectState(params); this.toolbarRect = new RectState(params); this.panels = new SidePanelsState({ container: this.container, panelGroupName: this.collection.queryName, }); this.toolbarBIReporter = new ToolbarBIReporter({ toolbar: this, collection: this.collection, reportBi: (ev) => { this.reportBi({ ...ev, params: { ...this._commonDynamicBiParams(), ...ev.params, }, }); }, }); this.bulk = new BulkActionToolbarState({ toolbarBIReporter: this.toolbarBIReporter, panels: this.panels, multi: this.multi, translate: this.container.translate, scrollToTop: () => { this.collection.scrollableContent?.scrollTo({ top: 0, left: 0, }); }, }); this.namespace = [ this.container.environment.componentId, this.collection.queryName, ].join('.'); const { dataCapsule } = this.container.createEssentials({ dataCapsule: { namespace: this.namespace, }, }); this.dataCapsule = dataCapsule; const reportBiBase = params.reportBi ?? this.container.reportBi; this.reportBi = (ev) => { reportBiBase({ ...ev, params: { ...this._commonDynamicBiParams(), ...ev.params, }, }); }; this.columnSizesState = new ColumnSizesState({ container: this.container, dataCapsule: this.dataCapsule, fqdn: this.namespace, }); makeObservable(this, { forceRenderIndexes: observable.ref, _userColumns: observable.ref, _extendedColumns: observable.ref, columns: computed, customColumnsStateInitialized: observable.ref, viewsProps: observable.ref, dragAndDropCategories: observable.ref, customColumnsProps: observable.ref, filtersProps: observable.ref, shouldDisplayFiltersTags: computed, totalStickyContentHeight: computed, showInitialLoader: computed, showEmptyState: computed, showErrorState: computed, hasSubToolbarComponents: computed, containerRect: computed, setFilterTagsPropsIfChanged: action, _initDefaultSortableColumn: action, minimizedHeaderHeight: observable.ref, tabsFilterProps: observable.ref, toolbarFiltersStateInitialized: observable.ref, hasToolbarTabs: computed, itemsContentHeight: computed, setNewColumns: action, addFeatureColumn: action, setExtendedColumns: action, _commonDynamicBiParams: action, _adjustSortWithNewColumns: action, setToolbarFiltersStateInitialized: action, activeFiltersEntries: computed, _searchExpanded: observable.ref, showFieldTypeIcons: observable.ref, }); } get containerRect() { const { itemsContentRect, toolbarRect, internalScroll } = this; if (internalScroll && itemsContentRect.rect.height) { return { ...itemsContentRect.rect, }; } if (itemsContentRect.rect.left && toolbarRect.rect.left) { const top = toolbarRect.rect.bottom; const bottom = top + itemsContentRect.rect.height; const left = toolbarRect.rect.left; const right = left + toolbarRect.rect.width; return { left, right, top, bottom, height: bottom - top, width: right - left, }; } return undefined; } get containerTop() { return this.containerRect?.top; } /** * How much height left for the content inside the viewport */ get itemsContentHeight() { const { toolbarRect, scrollElementRect, contentPadding, internalScroll, itemsContentRect, } = this; if (internalScroll && itemsContentRect.rect.height) { return itemsContentRect.rect.height; } if ( toolbarRect.rect.height == null || toolbarRect.rect.top == null || scrollElementRect.height == null || scrollElementRect.top == null ) { return 0; } if (process.env.NODE_ENV === 'test') { return 796; } const toolbarOffsetTop = toolbarRect.rect.top - scrollElementRect.top; return ( scrollElementRect.height - toolbarOffsetTop - toolbarRect.rect.height - contentPadding ); } get totalStickyContentHeight() { const { toolbarRect, minimizedHeaderHeight } = this; if (toolbarRect.rect.height == null) { return null; } return toolbarRect.rect.height + minimizedHeaderHeight; } get sidePanel() { return this.panels.sidePanel; } get sidePanelComponents() { return this.panels.sidePanelComponents; } get showInitialLoader() { const { multi, initTask } = this; return ( multi.showInitialLoader || initTask.status.isLoading || initTask.status.isIdle ); } get showEmptyState() { const { showInitialLoader, multi: { showRefreshLoader, showEmptyState }, } = this; return showEmptyState && !showInitialLoader && !showRefreshLoader; } get errorStatus() { const { initTask, multi } = this; return initTask.errorStatus ?? multi.errorStatus; } get showErrorState() { const { showInitialLoader, multi: { showRefreshLoader, showErrorState }, initTask, } = this; return ( (showErrorState || initTask.status.isError) && !showInitialLoader && !showRefreshLoader ); } get hasSubToolbarComponents() { const { appliedFiltersTagList } = this.featuredComponents; return appliedFiltersTagList.value != null; } get reorderModeState() { return this._dnd?.reorderModeState; } get shouldDisplayFiltersTags() { const { collectionFieldsSource, featuredComponents } = this; if (collectionFieldsSource?.initTask.status.isLoading) { return false; } return !!featuredComponents.appliedFiltersTagList.value?.state.tags.length; } get hasToolbarTabs() { return this.tabsFilterProps != null; } get isReorderModeToolbarVisible() { return this.reorderModeState?.isReorderModeToolbarVisible; } get isReorderModePending() { return this.reorderModeState?.isReorderModePending; } get columns(): Column[] { const { _userColumns, _featureColumns, _extendedColumns } = this; // One column per id. When a layout syncs a column a source also provides // (e.g. the editable table's cell defs for source fields), the layout's def // wins and inherits the source's column metadata; source-only columns fill // in the rest. const extendedById = new Map(_extendedColumns.map((c) => [c.id, c])); const merged = [..._userColumns, ..._featureColumns].map((column) => { const extended = extendedById.get(column.id); return extended ? { ...extended, ...column } : column; }); const mergedIds = new Set(merged.map((c) => c.id)); return [...merged, ..._extendedColumns.filter((c) => !mergedIds.has(c.id))]; } /** * Whether to show field-type prefix icons across the collection UI. * Controlled by the `showFieldTypeIcons` prop on the collection component (Table, Grid, etc.). * @default false */ showFieldTypeIcons = false; get selectedOrderedColumnsOrAll() { const { columns, customColumnsState, columnSizesState } = this; const baseColumns = customColumnsState ? customColumnsState.selectedOrderedColumns : columns; return columnSizesState?.mergeSizes(baseColumns) ?? baseColumns; } _commonDynamicBiParams(query = this.collection.result.originQuery) { const { collection, viewsState } = this; return { componentType: this.componentType, ...collection._commonDynamicBiParams(query), currentTab: this.getCurrentTabName(), ...(viewsState && { currentView: viewsState.views.currentView?.id }), ...(viewsState?.views?.currentView && { isPredefined: viewsState.views.currentView.isPreset, }), ...(viewsState?.views?.currentView?.filters && { numFiltersInView: this._numberFiltersInView( viewsState.views.currentView, ), }), visibleColumnsOrder: this.selectedOrderedColumnsOrAll .map((col) => col.id ?? col.title ?? col.name) .join(','), numColumns: this.selectedOrderedColumnsOrAll.length, numFiltersActive: collection.query.activeFiltersCount, }; } _numberFiltersInView(view: PrivateView) { let numFiltersInView = 0; if (view.filters) { for (const filterName of Object.keys(view.filters)) { const filter = view.filters[filterName]; if (filter && filter.value) { if (Array.isArray(filter.value)) { numFiltersInView += filter.value.length; } else if (filter.value) { numFiltersInView += 1; } } } } return numFiltersInView; } getCurrentTabName() { const { tabsFilterProps, collection: { result: { originQuery: query }, }, } = this; const tabs = tabsFilterProps && Object.entries(query.rawFilters) .filter(([key]) => key === tabsFilterProps.filter.name) .flatMap(([, value]) => Array.isArray(value) ? value.map(tabsFilterProps.filter.itemKey) : value, ); return tabs?.[0] as string; } getCollectionSnapshot = (): { [key: string]: string | number | undefined; } => { const { collection: { result: { originQuery }, }, } = this; return { viewShown: this.viewsState?.views.currentView?.id, tabShown: this.getCurrentTabName(), sortOrderApplied: originQuery.sort?.length ? JSON.stringify(originQuery.sort) : undefined, filtersApplied: JSON.stringify(originQuery.filtersKey), filteredListSizeShown: this.collection.result.total, }; }; getPendingFilter(filter: A) { return this.pendingQuery?.getApplicableFilter(filter) ?? filter; } init({ featuresInitializers, hasMultiLevelSorting, skipFilterListeners, }: ToolbarCollectionStateParamsInitParams) { const { collection, multi, initTask, panels, sidePanel, tagsCollectionState, bulk, customColumnsState, viewsState, columnSizesState, } = this; const biReporters = multi.collections.map( (collection) => new CollectionBIReporter({ collection, reportBi: (ev) => { this.reportBi({ ...ev, params: { ...collection._commonDynamicBiParams(), ...ev.params, }, }); }, }), ); // must call `initOptionalFeatures` before collection init because we want to allow features to customize the collection before collection.init const tableFeatures = featuresInitializers .filter((t): t is ToolbarFeatureInitializer => t != null) .map((tableFeatInit) => tableFeatInit(this)); // should be initialized before collection.init() so the sort query will be updated before the time of the first request. this._initDefaultSortableColumn(hasMultiLevelSorting); const disposers = [ // Must be registered before the per-flow layout reactions so the // `_cacheRestoredLayout` gate they read is set first (mobx fires // reactions in creation order). reaction( () => this.layoutState?.viewType, (next, prev) => { if (!next || !prev || next === prev) { return; } if (this._skipNextLayoutCoordinator) { this._skipNextLayoutCoordinator = false; this._cacheRestoredLayout = next; return; } this._flushColumnsToCache(prev); this._cacheRestoredLayout = this._tryRestoreColumnsFromCache(next) ? next : undefined; }, ), this.collectionFieldsSource?.init(), // the fields source should be initialized and its beforeInitialFetch promise listener registered before customColumnsState?._tableInit() because custom columns might use data extensions customColumnsState?._tableInit(), viewsState?.init(), columnSizesState?.init(), // register table feature first as they might register to the collection `beforeInitialFetch` ...tableFeatures.map((t) => t.init()), reaction( () => this.selectedOrderedColumnsOrAll, (columns) => { collection.query._columns.replace(columns.map((c) => c.id)); }, { fireImmediately: true, }, ), multi.init({ skipFilterListeners }), ...biReporters.map((b) => b.init()), panels.init(), sidePanel.init(), bulk.init(), tagsCollectionState?.init(), addEventListener( collection.bulkSelect.events, 'openModal', this.bulk.modals.actionModal.modal.open, ), addEventListener( collection.bulkSelect.events, 'closeModal', this.bulk.modals.actionModal.modal.close, ), addEventListener(this.collection.emitter, 'resultUpdated', () => { this.broadcast({ message: this.container.translate( 'cairo.a11y.liveRegion.resultsFound', { num: this.collection.result.total }, ), }); }), ]; const startTime = performance.now(); initTask.runOnce(async () => { this.toolbarBIReporter.loadStart(); multi.initTask.runOnce(); customColumnsState?.initTask.runOnce(); tableFeatures.forEach(({ initTask }) => { if (initTask) { initTask.runOnce(); } }); try { await Promise.all([ multi.initTask.status.promise, tagsCollectionState?.initTask.status.promise?.catch(() => null), // resilience to failed request this.collectionFieldsSource?.initTask.status.promise?.catch( () => null, ), // resilience to failed request customColumnsState?.initTask.status.promise, columnSizesState?.initTask.status.promise, ...tableFeatures.map((t) => t.initTask?.status.promise), ]); this.toolbarBIReporter.loadEnd(startTime); } catch (e) { throw e; } }); return () => { disposers.forEach((d) => d?.()); }; } setFilterTagsPropsIfChanged(filtersProps: FilterProps[] | null) { if (this.filtersProps !== filtersProps) { this.filtersProps = filtersProps; } } _toStoredColumn = (column: Column) => { const { id, defaultHidden, hideable, hiddenFromCustomColumnsSelection } = column; return { id, isSelected: !hideable || hiddenFromCustomColumnsSelection || !defaultHidden, }; }; setNewColumns(tableColumns: TableColumn[]) { this._userColumns = simplifyColumns(tableColumns); } addFeatureColumn(featureColumn: TableColumn) { this._featureColumns.push(simplifyColumn(featureColumn)); this._adjustSelectedColumns(); } setExtendedColumns(columns: Column[]) { this._extendedColumns = columns; this._adjustSelectedColumns(); } broadcast(params: LiveRegionState) { this.liveRegionRef.current?.broadcast(params); } setToolbarFiltersStateInitialized(value: boolean) { this.toolbarFiltersStateInitialized = value; } updateColumnWidth(columnId: string, width: number) { this.columnSizesState?.updateColumnWidth(columnId, width); } _initDefaultSortableColumn(hasMultiLevelSorting = false) { const { columns, collection: { query }, } = this; const defaultSortableColumns = columns.filter( (col): col is Column & { defaultSortOrder: SortOrder } => col.sortable === true && col.id != null && col.defaultSortOrder != null, ); if (defaultSortableColumns.length && !query.sort.value.length) { const sortQuery = defaultSortableColumns.map((column) => ({ field: column.id, direction: column.defaultSortOrder, })); query.sort.setSortQuery( hasMultiLevelSorting ? sortQuery : sortQuery.slice(0, 1), ); } } _adjustSelectedColumns = () => { const { selectedColumns, customColumnsState, customColumnsProps, customColumnsStateInitialized, } = this; // as long as consumer rendered `` at least once, use `mergeNewColumns` strategy when re-rendering new columns prop if (customColumnsProps != null || customColumnsStateInitialized) { // if `customColumns` is not set yet - do nothing if (customColumnsState) { const { initTask } = customColumnsState; const mergedColumns = customColumnsState.mergeNewColumns(); runInAction(() => { selectedColumns.defaultValue = this.columns.map((col) => { const { id, isSelected } = this._toStoredColumn(col); return { id, isSelected }; }); }); if (initTask.status.isSuccess && customColumnsState._touched) { selectedColumns.refresh(mergedColumns); } else { selectedColumns.setValue(mergedColumns); } } } else { const allColumns = this.columns.map(({ id }) => ({ id, isSelected: true, })); selectedColumns.defaultValue = allColumns; selectedColumns.setValue(allColumns); } }; _flushColumnsToCache(layout: ViewType) { this._layoutColumnsCache[layout] = this.selectedColumns.value.map( ({ id, isSelected }) => ({ id, isSelected }), ); } _tryRestoreColumnsFromCache(layout: ViewType): boolean { const cached = this._layoutColumnsCache[layout]; if (!cached) { return false; } // Merge against current columns — mirrors `_fetchFromStorage`. const merged = this.columns.map((column) => { const cachedEntry = cached.find((c) => c.id === column.id); if (cachedEntry == null) { return this._toStoredColumn(column); } const { id, hideable, hiddenFromCustomColumnsSelection } = column; return { id, isSelected: cachedEntry.isSelected || !hideable || hiddenFromCustomColumnsSelection === true, }; }); this.selectedColumns.setValue( this.customColumnsState?.dragAndDrop ? merged.sort(indexOfCompareFn(cached, (e) => e?.id)) : merged, ); return true; } _clearColumnsCache() { this._layoutColumnsCache = {}; } _adjustSortWithNewColumns(tableColumns: TableColumn[]) { const { collection: { query }, } = this; const tableColumnsIds = new Set(tableColumns.map((column) => column.id)); query.sort.value .filter(({ field }) => !tableColumnsIds.has(field)) .forEach((s) => { query.sort.value.remove(s); }); query.sort.events.emit('change'); this.collection.scheduleSort({ clearResult: true }); } closeSidePanel = () => { this.sidePanel.close(); }; openFiltersPanel() { this.panels.openSidePanel('filters'); } retryErrorState = action(() => { const { initTask, multi } = this; if (initTask.status.isError) { initTask.runOnce(); } else { multi.retryErrorState(); } }); onSelectionStarted = () => { this.bulk._selectionStartTime = performance.now(); }; _onSelectAllCheckboxClick() {} get activeFiltersEntries() { return Array.from( this.multi.collections .flatMap(({ query }) => query.activeFiltersEntries.filter( ([_, filter]) => filter !== query.search, ), ) .reduce( (map, entry) => map.set(entry[0], entry), new Map]>(), ) .values(), ); } onSelectionChanged: Required['onSelectionChanged'] = action( (_selectedIds, change) => { if (change == null) { return; } const { panels, bulk } = this; panels.closeSidePanel(); switch (change.type) { case 'ALL': bulk.toggleSelectAll({ location: 'Checkbox' }); break; case 'NONE': bulk.deselectAll({ location: 'Checkbox' }); break; case 'SINGLE_TOGGLE': if (change.value) { bulk.selectOne(change.id); } else { bulk.deselectOne(change.id); } break; } }, ); openCustomFieldModal = ({ customOrigin }: { customOrigin?: string } = {}) => { return this.collectionFieldsSource?.openFieldModal({ customOrigin }); }; openManageTagsModal = () => { return this.tagsCollectionState?.openManageTagsModal(); }; onAddItemActionComplete = async (items: T[]) => { await this._resetQueryAndAddItems(items); this.toolbarBIReporter.newItemCreationEnd(items); }; _resetQueryAndAddItems = async (items: T[]) => { const { collection } = this; const { query } = collection; query.resetFilters(); query.resetSorting(); await collection.clearResultAndMoveToStart({ force: true }); collection._optimisticActions?.createMany(items, { submit: async () => items, }); collection.scrollableContent?.scrollTo?.({ top: 0, behavior: 'smooth', }); }; getPageFeaturedActions(): MoreActionItemFactory[] { const { collectionFieldsSource, tagsCollectionState } = this; return [collectionFieldsSource, tagsCollectionState] .filter( (e): e is CollectionFieldsSourceState | TagsCollectionState => e != null, ) .filter((e) => !e.props?.disableDefaultPageAction) .flatMap((e) => e.featuredPageActions); } }