import { reaction } from 'mobx'; import type { ComponentType } from 'react'; import { addEventListener, TaskState } from '@wix/bex-core'; import type { FieldsSource, CustomFieldOrigin, Field, Schema, WixPatternsErrorCode, } from '@wix/bex-core'; import type { Column } from '../model'; import type { ToolbarCollectionState } from './ToolbarCollectionState'; import { buildFieldColumns, ColumnOverridesContext } from './buildFieldColumns'; import { handleFieldsSourceLoadError } from './handleFieldsSourceLoadError'; import { buildFieldFilters, registerFieldFilters, type FieldFilterConfig, } from './buildFieldFilters'; import type { FilterElement, CollectionToolbarFiltersProps, } from '../components/CollectionToolbarFilters'; import type { MoreActionItemFactory } from '../components/MoreActions/MoreActionsBase'; import { CustomFieldsPanelState, type FieldActionsMenuBuilder, } from './CustomFields/CustomFieldsPanelState'; import type { FieldActionHandlersConfig } from '../components/FieldActions'; import type { CustomColumnsBaseProps } from '../components/CustomColumns/CustomColumns'; /** Builds the raw field-management for one field (handlers + registry metadata). */ type FieldActionHandlersBuilder = (args: { fieldId: string; origin: CustomFieldOrigin; location: string; }) => FieldActionHandlersConfig | null; /** * Components the source attaches at construction time so the table chunk can * render them via `source.components.X` instead of statically importing them. * Each source's rendering file (dataExtensionRendering / schemaRendering) is * the only place these are imported — those files are reachable only from the * source's feature module, so the components ship in that chunk, not the * table chunk. */ export interface FieldsSourceComponents { FieldManagementModals: ComponentType<{ source: CollectionFieldsSourceState }>; CustomColumns: ComponentType; CollectionToolbarFilters: ComponentType; } /** * The source-specific behavior a collection supplies on top of its FieldsSource * (data) — column tweaks, filter behavior, the manage-fields panel, page actions, * field actions, and the field-management modal states. A source builds one of * these (dataExtensionRendering / schemaRendering); it is spread onto * CollectionFieldsSourceState, which owns the pieces directly. The collection * renders these with shared, source-neutral components. */ export interface FieldsSourceRendering { /** * Lets a source replace any column property the shared builder produces, * including the render function for media types. Called per field. */ columnOverrides?(field: Field, ctx: ColumnOverridesContext): Partial; /** Per-source filter behavior (text operators, decimal, type icon). */ filterConfig: FieldFilterConfig; /** * Builds the source's per-field actions menu for the panel it belongs to. The * spine builds the panel and passes it in; the resulting menu drives both the * manage-fields panel and the customize-columns header. */ createFieldActionsMenu: ( panel: CustomFieldsPanelState, ) => FieldActionsMenuBuilder; /** * Builds the raw field-management for one field (handlers + registry metadata) * for the panel it belongs to, before grouping. The header merges these with * its table-intrinsic actions (sort / hide / filter). Omitted by sources with * no field actions. */ createFieldActionHandlers?: ( panel: CustomFieldsPanelState, ) => FieldActionHandlersBuilder; featuredPageActions: MoreActionItemFactory[]; props?: { disableDefaultPageAction?: boolean }; /** BI error code reported when the source's load fails. Default: 'InitCustomFieldsDataExtensionFailed'. */ errorCode?: WixPatternsErrorCode; /** The source provides these so the table chunk renders them via DI rather * than static import. See FieldsSourceComponents. */ components: FieldsSourceComponents; } /** * A collection's field source: the FieldsSource (data) plus the source's UI, * held as flat members. The toolbar holds one of these rather than a specific * source. It owns the load status (initTask); field and filter reads pass * through to the FieldsSource's own observables. */ export class CollectionFieldsSourceState { readonly fieldsSource: FieldsSource; /** The field-management state (modals + per-field menu). Built here from the * source's config so every source gets the same panel. */ readonly customFieldsPanel: CustomFieldsPanelState; readonly featuredPageActions: MoreActionItemFactory[]; readonly props?: { disableDefaultPageAction?: boolean }; readonly filterConfig: FieldFilterConfig; readonly openFieldModal: (opts?: { fieldId?: string; origin?: CustomFieldOrigin; customOrigin?: string; }) => void; readonly fieldActionsMenu: FieldActionsMenuBuilder; readonly fieldActionHandlers?: FieldActionHandlersBuilder; readonly components: FieldsSourceComponents; private readonly columnOverrides?: FieldsSourceRendering['columnOverrides']; private readonly toolbar: ToolbarCollectionState; private readonly errorCode: WixPatternsErrorCode; private hasBuiltColumns = false; constructor( params: { fieldsSource: FieldsSource; toolbar: ToolbarCollectionState; } & FieldsSourceRendering, ) { this.fieldsSource = params.fieldsSource; this.toolbar = params.toolbar; this.featuredPageActions = params.featuredPageActions; this.props = params.props; this.filterConfig = params.filterConfig; this.columnOverrides = params.columnOverrides; this.components = params.components; this.errorCode = params.errorCode ?? 'InitCustomFieldsDataExtensionFailed'; // The panel is generic — the source supplies only its config (the menu // factory); the table's container/reportBi wire it up. The panel builds all // three field-management modals (add/edit, archive, delete) itself. this.customFieldsPanel = new CustomFieldsPanelState({ fieldsSource: params.fieldsSource, container: this.toolbar.container, reportBi: this.toolbar.reportBi, closeSidePanel: this.toolbar.closeSidePanel, createFieldActionsMenu: params.createFieldActionsMenu, }); // Opening the add/edit modal and reading the per-field menu are the same for // every source, so derive them from the panel rather than per-source. this.openFieldModal = (opts) => this.customFieldsPanel.openCustomFieldModal({ fieldId: opts?.fieldId, origin: opts?.origin ?? 'external', customOrigin: opts?.customOrigin, }); this.fieldActionsMenu = this.customFieldsPanel.fieldActionsMenu; this.fieldActionHandlers = params.createFieldActionHandlers?.( this.customFieldsPanel, ); } /** * Attaches a source to a table's toolbar. Builds the wrapper once; calling it * again (e.g. on re-render) is a no-op. */ static setOnTable( toolbar: ToolbarCollectionState, source: { fieldsSource: FieldsSource } & FieldsSourceRendering, ) { toolbar.collectionFieldsSource ??= new CollectionFieldsSourceState({ ...source, toolbar, }); } readonly initTask = new TaskState(); buildFilters(): FilterElement[] { return buildFieldFilters( this.toolbar, this.fieldsSource.filterableFields, this.filterConfig, ); } /** Registers a filter state per filterable field on the collection query. */ registerFilters() { registerFieldFilters( this.toolbar, this.fieldsSource.filterableFields, this.filterConfig, ); } /** * Builds the columns from the fields, or null while the source is still * loading so existing columns are kept. */ buildColumns(firstBuild: boolean): Column[] | null { if (!this.initTask.status.isSuccess) { return null; } return buildFieldColumns(this.fieldsSource, this.columnOverrides, { firstBuild, }); } syncColumns() { const columns = this.buildColumns(!this.hasBuiltColumns); if (columns) { this.toolbar.setExtendedColumns(columns); this.hasBuiltColumns = true; } } /** Merges a returned fields map into the source (no refetch). No-ops for sources * that don't support in-place merging. The fields reaction rebuilds columns. */ mergeFields(fields: Schema['fields']) { this.fieldsSource.mergeFields?.(fields); } init() { this.initTask.runOnce(() => this.fieldsSource.load()); this.initTask.status.promise?.catch(() => this.handleLoadError()); const disposers = [ // Push the columns once the source loads, and again whenever the field // list changes (add/edit/delete field). The first push starts every // column hidden; later pushes keep the user's selection. reaction( () => [this.fieldsSource.fields, this.initTask.status.isSuccess] as const, () => this.syncColumns(), ), // Register a filter state per filterable field. filterableFields populates // inside the source's load (before initTask resolves), so the collection's // initial fetch still waits for these — matching the prior per-source timing. reaction( () => this.fieldsSource.filterableFields, () => this.registerFilters(), { fireImmediately: true }, ), // Make custom-columns storage reconciliation wait until this source's // columns are built, so a stored hide/show selection for source fields // survives a reload. Covers any source. ...(this.toolbar.customColumnsState?.events ? [ addEventListener( this.toolbar.customColumnsState.events, 'beforeInitialFetch', () => this.initTask.status.promise?.catch(() => {}), ), ] : []), ]; return () => { disposers.forEach((d) => d?.()); }; } private handleLoadError() { handleFieldsSourceLoadError({ container: this.toolbar.container, reportBi: this.toolbar.reportBi, error: this.initTask.status.error, errorCode: this.errorCode, reload: () => this.retryLoad(), }); } /** Re-runs the source load after a failure (the retry toast). */ private retryLoad() { this.initTask.reset(); this.initTask.runOnce(() => this.fieldsSource.load()); return this.initTask.status.promise; } }