import {Testable} from "./Testable"; import {TestableNode} from "./TestableNode"; import { LastRenderedNodeData, MiddleRenderedNodeData, RendererNode, RendererNodeData, RendererNodeType, TreeNodeComponent } from "./RendererNodeData"; import {AfterRemovingFromGroveHook, getGroveForReading, TreeNodeData} from "./Grove"; import {AddedToTreeNodeHook, RemovedFromTreeNodeHook, TreeNode} from "./TreeNode"; import {Offers} from "./Offers"; import {WithTestableNode} from "./WithTestableNode"; import {ExportableToState} from "./ExportableToState"; import {FlowDataCreator, FlowDataWithoutPosition} from "./FlowDataCreator"; import {OfferData, TestableData} from "./store"; import mitt from "mitt"; import {NestedTestableNodeData} from "./InMemoryTestableNode"; import {cloneDeep, omitBy} from "lodash"; import {TierType} from "./tiers"; import {NestedBranchData} from "./MemoryBranchNode"; import {Testables} from "./testables"; import {DiscountOptions} from "./cards/Discount"; import {TestableCompositeNode} from "./TestableCompositeNode"; import {AddTierButton} from "./AddTierButton"; import {offers} from "../conditionsOrFilters/CardCreator"; import {removeIds} from "./NodeHelpers"; import {getComponentMeta} from "./ComponentsMeta"; import {DataExporter, ExportImportTarget} from "./export/Exporters"; import {exporterImporters} from "./export/CustomImportersAndExporters"; import {GroveExporter} from "./export/GroveExporter"; import {currentPreloadedDynamicData} from "./export/ComponentExporterImporter"; export type BranchData = { id: string, type: 'select-action' | 'offers' | 'tiered', data?: ExtraData, children: ChildrenType[] } export type TieredExtraData = { baseTestable: TestableFormat, tierType: TierType } export interface BranchNode extends RendererNodeData, LastRenderedNodeData, TreeNodeComponent, WithTestableNode, Partial, Partial, Partial { getChildrenIds(): string[] getChildren(factory?: (child: In, branch: BranchNode) => Out | undefined): ChildrenType[] // replaces the children with the new ones //setChildren(children: ChildrenType[]): void getType(): BranchData['type'] isOffers(): boolean isSelectAction(): boolean isTiered(): boolean isSimple(): boolean isInsideAnotherBranch(): boolean addChildren(childrenData: ChildrenType[]): void } export type TieredData = Pick & { baseTestableId: string, } export enum BranchNodeEvent { ChildrenAdded = 'children.added', ChildrenRemoved = 'children.removed', } export type BranchNodeEventTypes = { [BranchNodeEvent.ChildrenAdded]: { // the id of the branch branch: BranchNode, children: ChildrenType[], }, [BranchNodeEvent.ChildrenRemoved]: { // the id of the branch branch: BranchNode, } } export type CenteredId = { id: string, native: boolean }; export class StateBranchNode implements BranchNode, MiddleRenderedNodeData, RendererNode, ExportableToState, FlowDataCreator, AfterRemovingFromGroveHook, RemovedFromTreeNodeHook, AddedToTreeNodeHook, DataExporter { private emitter = mitt>() // @ts-ignore public testableNode: TestableNodeType // @ts-ignore public testableBaseNode: TestableNodeType; // @ts-ignore private treeNode: TreeNode; constructor( private readonly offers: Offers, public readonly data: BranchData, public readonly testables: Testables ) { } setTreeNode(treeNode: TreeNode): void { this.treeNode = treeNode; } getTreeNode(): TreeNode { return this.treeNode; } setTestable(testable: TestableNodeType) { this.testableNode = testable; } setTestableBase(testable: TestableNodeType) { this.testableBaseNode = testable; this.testableBaseNode.setTreeNode(this.treeNode); // @ts-ignore (this.testableBaseNode as unknown as TestableCompositeNode).branch = this // dont use setBranch() because that will set the testableNode and we don't want that } getId(): string { return this.data.id; } getCenteredId(): CenteredId | undefined { if (this.isOffers()) { // we want to get the id of the first offer card, not the whole offer because the offer has content outside of the cart and we only want to use the cared for centering return { id: this.getChildrenIds()[0], native: true // otherwise use a data-node-id attribute from react-flow (which is the default) } } } getSourceRenderNode(): RendererNodeType | undefined { return this.testableNode } getSourceRenderNodeId(): string { return this.getSourceRenderNode()?.getId() || '' } getTargetRenderNode(): RendererNodeType | undefined { if (this.isTiered()) { return this.testableBaseNode } } getTargetRenderNodeId(): string { return this.getTargetRenderNode()?.getId() || '' } /** * I know, i know, this should be in a different class because not all branches have a base testable */ getBaseTestable(): TestableData | undefined { return this.testables.getNode(this.getExtraData().baseTestableId); } getChildrenIds(): string[] { if (this.isTiered()) { return (this.data.children as BranchData<{}, TreeNodeData>['children']).map(({id}) => id) } // @ts-ignore return this.data.children; } getFirstChildId(): string { return this.getChildrenIds()[0] } getLastChildId(): string { const childrenIds = this.getChildrenIds(); return childrenIds[childrenIds.length - 1]; } isFirstChild(child: T): boolean { if (this.isTiered()) { return (child as TreeNode).id === this.getFirstChildId(); } return child === this.getFirstChildId(); } getChildren(factory?: (child: In, branch) => Out | undefined): Out[] { // for offers, use: Offers.get(child as string) // for tiered, use: Grove.get(child as TreeNodeData.id) return this.data.children.map((child) => { return factory!(child as In, this) as unknown as Out; }).filter(v => v !== undefined) } getChildIndex(childId: string): number { return this.getChildrenIds().indexOf(childId) } getChildAtIndex(index: number, factory?: (child: ChildrenType) => ReturnedChildren): ReturnedChildren | undefined { const rawChild = this.data.children[index] if (!rawChild) { return undefined } if (factory) { return factory(rawChild as ChildrenType) as ReturnedChildren } return rawChild as ReturnedChildren } getAddTierButton(): AddTierButton | undefined { if (this.isTiered()) { return new AddTierButton( this.getAddTierButtonId(), this.treeNode, this.treeNode.grove ) } } /** * Returns the *original reference* to the data */ isOffers(): boolean { return this.data.type === 'offers'; } isSelectAction(): boolean { return this.data.type === 'select-action'; } isTiered(): boolean { return this.data.type === 'tiered'; } isSimple(): boolean { return this.isOffers() || this.isSelectAction(); } getType() { return this.data.type; } isInsideAnotherBranch() { return this.getTreeNode().hasParent(); } exportToState() { // we can use the global grove here because it's for read operations only const grove = getGroveForReading(); return omitBy( { id: this.getId(), type: this.getType(), data: this.getExtraData(), children: this.isTiered() ? this.getChildren((child) => { return child; }) : this.getChildrenIds() }, v => v === undefined ) } createFlowData(): FlowDataWithoutPosition { const tieredFlowData = this.getTieredFlowNodes(); return { renderedNodes: [ // the actual branch node this.treeNode.createRenderedNodeWithData({ id: this.getId(), type: this.isSelectAction() ? 'branch.select-action-split' : (this.isOffers() ? 'branch.offers' : 'branch.tiered'), }), // the tiered nodes (base testable and children) ...(tieredFlowData.renderedNodes) ], edges: tieredFlowData.edges }; } private getTieredFlowNodes(): FlowDataWithoutPosition { if (!this.isTiered() || this.data.children.length === 0) { return {renderedNodes: [], edges: []} } const childrenTreeNodes = this.getChildren(({id}) => { return this.treeNode.grove.get(id) }) as unknown as TreeNode[] return { renderedNodes: [ // the base testable this.treeNode.createRenderedNodeWithData({ id: (this.getExtraData() as TieredData).baseTestableId, type: 'branch.tiered.testable.base', }), // the children ...childrenTreeNodes.map( treeNode => treeNode.createFlowData().renderedNodes ).flat(), // the add tier button this.treeNode.createRenderedNodeWithData({ id: this.getAddTierButtonId(), type: 'branch.tiered.add-tier-button' }) ], edges: [ // the branch to the base testable { id: this.getId(), sourceHandle: 'right', targetHandle: 'left', source: this.getId(), target: this.getExtraData().baseTestableId, type: 'connecting-line', }, // the base testable to the first tier's step anchor { id: this.getExtraData().baseTestableId, sourceHandle: 'right', targetHandle: 'left', source: this.getExtraData().baseTestableId, // the first tree node id, which represents the step anchor target: this.getChildAtIndex(0)!.id, type: 'connecting-line', }, // the step anchor to the tier testable is done in the TreeNode // the add tier button { id: this.getAddTierButtonId(), targetHandle: 'top', sourceHandle: 'bottom', source: this.getLastChildId(), target: this.getAddTierButtonId(), type: 'connecting-line', } ] } } private getAddTierButtonId() { return `${this.getId()}.add-tier-button`; } public getExtraData(): Data { return this.data.data as Data; } /** * * CURRENTLY ONLY WORKS FOR SHALLOW BRANCHES (OFFERS, SELECT-ACTION) * * * Difference between this.data and exportNestedData is that this.data is the *state* data (eg: offer children are strings) whereas * nested data is the *actual* data (eg: offer children are objects) */ public exportData(target: ExportImportTarget = 'preset', groveExporter?: GroveExporter): NestedBranchData { // make sure we return a copy of the data let branchData = cloneDeep({ ...this.data, children: this.getChildren( (child) => { if (this.isTiered()) { if (target === 'clone-tier') { // logic is handled elsewhere return child } /** * Children are TreeNodes so let's export those recursively */ const childTreeNode = this.treeNode.grove.get(child.id) if (childTreeNode && groveExporter) { return groveExporter.exportTreeNode( childTreeNode, target ) } // else it will return undefined which the parent function will filter out } let offer = cloneDeep(this.offers.get(child as string)); const offerMeta = getComponentMeta('offers', offer!.type!) const customExportedOffer = exporterImporters[offer!.type]?.exportData?.(offer, target) /*const customExportedOffer = exporters[offer!.type]?.(offer, target)*/ if (customExportedOffer) { offer = customExportedOffer as OfferData } if (target === 'server') { const preloadedValues = exporterImporters[offer.type]?.tagDynamicValues?.(offer, 'server') if (preloadedValues) { currentPreloadedDynamicData.addPreloadedDynamicData(preloadedValues) } } if (target === 'preset') { // gotta remove the ids // @ts-ignore offer = removeIds(offer) as CardData // we also gotta remove the ids from the tetsables of the get y offer } return offer as unknown as ChildrenType } ) } ) if (this.isTiered()) { // we need to convert the extra data testable from id to the actual object const tierExtraData = branchData.data as TieredData const exportedTierExtraData = tierExtraData as unknown as TieredExtraData const testable = new Testable(this.testables, tierExtraData.baseTestableId) exportedTierExtraData.baseTestable = testable.export(target) // @ts-ignore delete tierExtraData.baseTestableId; } if (target === 'preset') { // @ts-ignore branchData = removeIds(branchData) } return branchData } leftSpacing(): number { if (this.isTiered()) { if (this.isInsideAnotherBranch()) { return 32 } else { return 20 } } else if (this.isInsideAnotherBranch()) { return 40 } return 0 } topSpacing(): number { if (this.isTiered()) { if (this.isInsideAnotherBranch()) { return 32 } else { return 24 * 4 } } else if (this.treeNode.isRoot()) { if (this.isSelectAction()) { return 32 } return 32 * 2 } return 0 } afterRemovingFromGrove() { this.beforeRemovingFromTreeNode() } beforeRemovingFromTreeNode() { // remove the children if (this.isOffers()) { this.getChildrenIds().forEach((id) => { const offerToRemove = this.offers.get(id); this.offers.remove(id); if (offerToRemove?.type === 'Discount') { const options = offerToRemove.options as DiscountOptions; if (typeof options.customFilteredItemsSourceMap === 'string') { this.testables.remove(options.customFilteredItemsSourceMap); } } }); } } afterAddingToTreeNode() { // add the offers to the state // this is where we'll add the offers to the state // and deal with tiered state // this should probably be done in a different class for each type of branch // but for now, let's do it here if (this.isOffers()) { this.offers.add(this.getChildren(this.offers.get.bind(this.offers)) as OfferData[]) } } addSubscriber(event: BranchNodeEvent, callback: (data: BranchNodeEventTypes[typeof event]) => void) { this.emitter.on(event, callback); } /** * If a tiered branch, we expect a full TreeNode object with all its data already in state * @param childrenData */ addChildren(childrenData: (TreeNode | (OfferData | Omit))[], position?: number) { if (childrenData.length === 0) { return } if (this.isOffers()) { this.offers.add(childrenData as unknown as OfferData[]) this.data.children.push(...childrenData.map((child) => child.id)) } else if (this.isTiered()) { (childrenData as unknown as TreeNode[]).forEach((child) => { // add at the position (1-based) if (typeof position === 'number') { this.data.children.splice(position - 1, 0, child.exportToState()) } else { this.data.children.push(child.exportToState()) } }) } this.emitter.emit(BranchNodeEvent.ChildrenAdded, { branch: this, // @ts-ignore children: childrenData }) } // for offers removeChildren(ids: string[]): void // for tiered branches removeChildren(children: TreeNode[]): void removeChildren(children: (string | TreeNode)[]): void { if (children.length === 0) { return } if (this.isOffers()) { this.data.children = this.data.children.filter((id) => { return !children.includes(id as string) }) children.forEach(id => this.offers.remove(id as string)) } else if (this.isTiered()) { children.forEach((child) => { const id = (child as TreeNode).id; (child as TreeNode).beforeRemovingFromGrove() this.data.children = this.data.children.filter((stateChild) => { return (stateChild as TreeNodeData).id !== id }) }) } this.emitter.emit(BranchNodeEvent.ChildrenRemoved, { branch: this, }) } }