import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef} from 'react'; import { Background, BackgroundVariant, Controls, GeneralHelpers, MiniMap, NodeAddChange, NodeChange, NodeDimensionChange, NodePositionChange, NodeRemoveChange, NodeReplaceChange, NodeSelectionChange, ReactFlow, ReactFlowProvider, useReactFlow } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; import {OfferData, RenderedNode, TestableData, TestablePartial, useNodesStore, WithXSpacing} from './tree/store'; import {ConnectingLine} from "./ConnectingLine"; import {isEqual} from 'lodash'; import {OffersBranch} from "./OffersBranch"; import {getIdWithoutAnySuffix, getIdWithoutLastSuffix, Testables} from "./tree/testables"; import {StepAnchorBranch, StepAnchorData} from "./StepAnchorBranch"; import {BranchAddNodeButtonNodeBranch, BranchAddNodeButtonNodeData} from "./BranchAddNodeButtonNode"; import {SelectActionBranch, SelectActionNodeData} from "./SelectActionBranch"; import {SelectActionSplitBranch} from "./SelectActionSplitBranch"; import {TieredBaseNode} from "./TieredBaseNode"; import {TieredSubchildNode} from "./TieredSubchildNode"; import {BranchData, BranchNode, CenteredId, StateBranchNode} from "./tree/StateBranchNode"; import {Welcome} from "./Welcome"; import {getTree, Tree} from "./tree/Tree"; import {TestableCompositeRenderer} from "./tree/TestableCompositeRenderer"; import {getGroveForReading, NestedTreeNodeData} from "./tree/Grove"; import {TestableCompositeNode} from "./tree/TestableCompositeNode"; import { FirstRenderedNodeData, LastRenderedNodeData, MiddleRenderedNodeData, RendererNode, RendererNodeData } from "./tree/RendererNodeData"; import {wrapTestablesInDefaultGroup} from "./tree/NodeHelpers"; import {TreeNodeFactory} from "./tree/TreeNodeFactory"; import {Offers} from "./tree/Offers"; import {NestedBranchData, NestedTieredData} from "./tree/MemoryBranchNode"; import {QuantityMeta, QuantityOptions} from "./tree/cards/Quantity"; import {equals, maximum, minimum, range, TierType} from "./tree/tiers"; import {NestedTestableNodeData} from "./tree/InMemoryTestableNode"; import {BranchTieredNode} from "./BranchTieredNode"; import {FlowData, RenderedNodeData} from "./tree/FlowDataCreator"; import {TreeNode} from "./tree/TreeNode"; import {AddTierButton} from "./tree/AddTierButton"; import {GroveActions} from "./tree/GroveActions"; import {AmountValidatorOptions} from "./tree/cards/filterinterfaces"; import {UserRoleOptions} from "./tree/cards/UserRole"; import {ProductsOptions} from "./tree/cards/Products"; import {BuyXGetXMeta, BuyXGetXOptions} from "./tree/cards/BuyXGetX"; import {DiscountOptions} from "./tree/cards/Discount"; import {ParentType} from "./tree/components"; import {BundlePriceOptions} from "./tree/cards/BundlePrice"; import {ShippingDiscountOptions} from "./tree/cards/ShippingDiscount"; import {AdvancedData, BuyXGetYOptions, ProductData} from "./tree/cards/BuyXGetY"; import {FeaturedProductsOptions} from "./tree/cards/FeaturedProducts"; import {AnyProductsOptions} from "./tree/cards/AnyProducts"; import {useAtom} from "jotai/index"; import {atomsStore, mainReactFlowInstanceAtom, OverlayOpenCompositeAtom, treeEmitterAtom} from "./tree/atoms"; import mitt from 'mitt' import {useAtomValue} from "jotai"; import {RootTreeAnchor} from "./RootTreeAnchor"; import {TreeNodeAnchor} from "./tree/TreeNodeAnchor"; import {CouponsPlus} from "./globals"; export const nodeTypes = { "tree.root.anchor": RootTreeAnchor, "testable.group": TestableCompositeRenderer, "branch.select-action": SelectActionBranch, "branch.select-action-split": SelectActionSplitBranch, "branch.offers": OffersBranch, //"branch-first-passing-node": TreeNode, "branch.tiered.testable.base": TieredBaseNode, "branch.tiered.tier.testable": TieredSubchildNode, "branch.tiered": BranchTieredNode, "branch.tiered.tier.step-anchor": StepAnchorBranch, "branch.tiered.add-tier-button": BranchAddNodeButtonNodeBranch } const edgeTypes = { 'connecting-line': ConnectingLine } export type Direction = 'vertical' | 'horizontal' | 'bottom-right'; type CalculatePositionOptionsBase = { ySourceNode?: RenderedNode, } type CalculatePositionOptions = (CalculatePositionOptionsBase & CalculateVerticalPositionOptions) | undefined export type CalculateVerticalPositionOptions = CalculatePositionOptionsBase & { x: 'start' | 'middle', yDistanceFromSource?: number } export type CalculateHorizontalPositionOptions = CalculatePositionOptionsBase & { y?: 'start' | 'middle', nodesToCheckForTheirWidth?: string[] } const DomElementsCache: Record = {} export const tierVerticalSpacing = 0 export const findNodeDomElement = (id: string) => { if (DomElementsCache[id]) { return DomElementsCache[id]; } const element = document.querySelector( id.startsWith('#') ? id : `.react-flow__node[data-id="${id}"]` ) if (element) { DomElementsCache[id] = element; } return element; } let changedDimensionsMap = new Map() const emptyDimensions = {width: 0, height: 0}; export const getDimensions = (id: string): { width: number, height: number } => { if (!id) { return emptyDimensions; } const cached = changedDimensionsMap.get(id); if (cached) { return cached; } return findNodeDomElement(id)?.getBoundingClientRect() || emptyDimensions } type RenderableNode = FirstRenderedNodeData | MiddleRenderedNodeData | LastRenderedNodeData; function calculateNodePosition(targetNode: RenderableNode, sourceNode: RenderedNode | undefined, direction: Direction, getNode: GeneralHelpers['getNode'], options: CalculatePositionOptions): RenderedNode['position'] | undefined { if (direction === 'vertical') { // vertical needs to have a source node return calculateNodePositionVertically(targetNode, sourceNode!, getNode, options as CalculateVerticalPositionOptions); } else if (direction === 'horizontal') { // whereas horizontal doesn't bc it might be a root node with no parent/source return calculateNodePositionHorizontally(targetNode, sourceNode, getNode, options as CalculateHorizontalPositionOptions); } else if (direction === 'bottom-right') { return calculateNodePositionBottomRight(targetNode, sourceNode, getNode, options); } } const calculateTierHeight = (node: RenderedNode, getNode: GeneralHelpers['getNode'], options: { withY?: boolean, elementsToCheck?: ('testable' | 'branch')[], spacing?: number } = {withY: true, spacing: 0}) => { const nodeTypes = useNodesStore.getState().couponsPlusNodeTypes node = getNode(node.id)! as RenderedNode; const data = node.data as StepAnchorData; const targetNode = getNode(data.targetNodeId)! as RenderedNode; const targetBranchNode = getNode(targetNode.data.targetNodeId)! as RenderedNode; let testableNodeHeight: number = 0; const shouldCheckTestable = options.elementsToCheck === undefined || options.elementsToCheck?.includes('testable'); const shouldCheckBranch = options.elementsToCheck === undefined || options.elementsToCheck?.includes('branch'); if (shouldCheckTestable) { testableNodeHeight = getDimensions(targetNode.id).height || 0; } let branchNodeHeight: number = 0; if (shouldCheckBranch) { const branchTypeOfTheTargetNode = nodeTypes.find(({id}) => `${id}` === targetNode.id)?.branchType; if (branchTypeOfTheTargetNode === 'select-action' || branchTypeOfTheTargetNode === 'offers') { branchNodeHeight = getDimensions(targetNode.data.targetNodeId).height || 0; } else { // this is a multi-node branch, so let's get the height of all its children const allChildrenOfTheBranch = useNodesStore.getState().branchRelations.find(({branch}) => branch === targetNode.id)?.children || []; allChildrenOfTheBranch.forEach((childId, index) => { // call recursively this fubcrion again usig their step anchor let childAnchorNode = getNode(`${childId}.step-anchor`)! as RenderedNode; let baseNodeId if (branchTypeOfTheTargetNode === 'tiered' || branchTypeOfTheTargetNode === 'first-passing-node') { baseNodeId = getNode(childAnchorNode.data.targetNodeId) as RenderedNode //childAnchorNode = getNode(baseNodeId.data.targetNodeId)! as RenderedNode; } let childHeight: number try { childHeight = calculateTierHeight(childAnchorNode, getNode, {withY: false}); } catch (e) { childHeight = 0 } branchNodeHeight += childHeight + (index + 1 !== allChildrenOfTheBranch.length ? tierVerticalSpacing : 0); }) } } const {withY} = options; return Math.max( testableNodeHeight + (withY ? targetNode?.position?.y : 0) || 0, branchNodeHeight + (withY ? targetBranchNode?.position?.y : 0) || 0 ); }; const newCalculateTierHeight = (treeNode: TreeNode, getNode: GeneralHelpers['getNode']): number => { const testableHeight = getDimensions(treeNode.testableNode.getId()).height || 0 if (treeNode.branchNode.isSimple()) { // which is greatest, the testable or the branch? const branchHeight = getDimensions(treeNode.branchNode.getId()).height || 0 return Math.max(testableHeight, branchHeight) } else { // for now just get the height of the testable return testableHeight } } // Positions calculated during the current handleNodesChange pass. // Later nodes in the loop read from this so they see up-to-date positions // from nodes already processed — single-pass convergence. let positionOverrides = new Map() const calculateYOfRenderedNode = (nodeId: string): number => { const node = findNode(nodeId) const y = positionOverrides.get(nodeId)?.y ?? node?.position?.y ?? 0; return y + (getDimensions(nodeId).height || 0); } const calculateYOfPreviousTier = (previousAnchorNode: RendererNode, getNode: GeneralHelpers['getNode']): number => { const previousAnchorTargetNodeFlowData = getNode(previousAnchorNode.getTargetRenderNode()?.getId() as string) as RenderedNodeData const previousAnchorNodeIsStepAnchor = previousAnchorNode instanceof TreeNode; /** * The previous target to use is NEVER an anchor, it's always the target node of the previous anchor eg: a testable, what * the previous anchor is anchoring to (eg [anchor] -> [testable])) */ const previousTargetToUseFlowData: RenderedNodeData = (previousAnchorNodeIsStepAnchor ? previousAnchorTargetNodeFlowData : (getNode(previousAnchorNode.getId()) as RenderedNodeData)) let endYOfThePreviousTier: number = 0; if (previousAnchorNodeIsStepAnchor) { // we need to get the greatest y of the previous tier (inclduing branch) // so let's do: // if the previous target is an offers or select action branch, then we need to get the y (inclduing height) of the: // -testable // and // -branch // then we need to get the greatest y of the two const previousTreeNode = previousAnchorNode as TreeNode if (previousTreeNode.branchNode.isSimple()) { const previousTargetBranchFlowData = getNode(previousTreeNode.branchNode.getId())! as RenderedNodeData; const previousTargetBranchHeight = getDimensions(previousTargetBranchFlowData?.id).height || 0; const previousTargetTestableHeight = getDimensions(previousTreeNode.testableNode.getId()).height || 0; endYOfThePreviousTier = Math.max( previousTargetBranchHeight + (previousTargetBranchFlowData?.position?.y || 0), previousTargetTestableHeight + (previousTargetToUseFlowData?.position?.y || 0) ) } else { const previousLastAddTierButtonPosition = getNode(previousTreeNode.branchNode.getAddTierButton()?.getId() as string)?.position?.y || 0 let buttonHeight = 16 // eyeballed return previousLastAddTierButtonPosition + buttonHeight; /*console.log('previousAnchorNode.id', previousAnchorNode.id,) const lastChildOfTheBranch = tree.getLastBranchChild(previousAnchorNode.id) console.log('lastChildOfTheBranch', lastChildOfTheBranch, previousAnchorNode.id) const lastChildOfTheBranchStepAnchorID = `${lastChildOfTheBranch?.id}.step-anchor` const lastChildOfTheBranchStepAnchor = getNode(lastChildOfTheBranchStepAnchorID)! as RenderedNode if (lastChildOfTheBranchStepAnchor) { console.log('calculating nested start') const yOfTheLastChildOfTheBranch = calculateYOfPreviousTier(lastChildOfTheBranchStepAnchor, getNode); console.log('calculating nested end') //console.log('y nested', yOfTheLastChildOfTheBranch, lastChildOfTheBranchStepAnchor) endYOfThePreviousTier = yOfTheLastChildOfTheBranch }*/ } } else { // this is a testable (either a root or the testable of a nested branch) // in this case we just need the y and the height of the testable endYOfThePreviousTier = previousTargetToUseFlowData.position.y + (getDimensions(previousTargetToUseFlowData.id)?.height || 0) } return endYOfThePreviousTier; }; function calculateNodePositionVertically(targetNode: RendererNodeData & RendererNode & MiddleRenderedNodeData, sourceNode: RenderedNode, getNode: GeneralHelpers['getNode'], options?: CalculateVerticalPositionOptions): RenderedNode['position'] { /** * FOR A PERFOMANCE OPTIMIZATION: * -reduce the calls to getNode() (check if its a rendered node, if its use that one else use getNode()) */ const targetDOMElement = findNodeDomElement(targetNode.getId())!; if (!targetDOMElement) { // DOM not ready yet — keep current position to avoid jumping to (0,0) return undefined } // i figure we should get the parent of the handle element inside the target dom element instead of using the target element itself const targetHandleDOMElement = targetDOMElement.querySelector('.react-flow__handle')?.parentElement; const targetElementToUse = targetHandleDOMElement || targetDOMElement; const sourceDOMElementMeasures = () => getDimensions(targetNode.getSourceRenderNodeId()); //const YSourceDOMElementMeasures = options?.ySourceNode ? findNodeDomElement(options.ySourceNode.id)?.getBoundingClientRect() : sourceDOMElementMeasures; let pickedTheBestHeightOfThePreviousTier: number = 0; let currentTierHeight: number = 0; let spacing = options?.yDistanceFromSource ?? tierVerticalSpacing; //const sourceIsAnchorOrAddButton = sourceNode.type === 'step-anchor' || sourceNode.type === 'branch-add-node-button'; const sourceIsAnchorOrAddButton = false //we'll check if its instance of anchor or button, but now its not really needed; const targetNodeIsAddButton = false//targetNode.type === 'branch-add-node-button'; const targetIsAnchorOrAddButton = false//targetNode.type === 'step-anchor' || targetNodeIsAddButton; const targetIsAnchor = targetNode instanceof TreeNode && (targetNode.getSourceRenderNode() as TreeNode).isTier() const targetIsAddTierButton = targetNode instanceof AddTierButton const targetIsNestedBranch = targetNode instanceof StateBranchNode && targetNode.isInsideAnotherBranch() let YOfThePreviousTier: number = 0 if (targetIsAnchor || targetIsAddTierButton || targetIsNestedBranch) { YOfThePreviousTier = calculateYOfPreviousTier(targetNode.getSourceRenderNode()!, getNode); if (targetIsAnchor) { currentTierHeight = newCalculateTierHeight(targetNode as TreeNode, getNode) } } else if (targetNode instanceof StateBranchNode && !targetNode.isInsideAnotherBranch?.()) { // THIS WILL ONLY WORK FOR ROOT NODES BECAUSE REMEMBER WE'RE CALCULATING THE POSITION **VERTICLALLY** AND // SELECT ACTION BRANCHES INSIDE OTHER BRANCHES ARE HORIZONTAL! YOfThePreviousTier = calculateYOfRenderedNode(targetNode.getSourceRenderNodeId()) } //const sourceNodeYPosition = sourceNode?.position.y + (pickedTheBestHeightOfThePreviousTier || YSourceDOMElementMeasures?.height || 0); // so for this we need: // 1- the height of the testable // 2- the height of the branch // if the height of the testable is greate then the height of the branch, then just use that hieght //let extraSpacingOnTopBecauseOfCenterAlignment = 0 //currentTierHeight / 2; const heightOfTheTargetNode = getDimensions(targetNode.getId()).height || 0; //const targetIdIOfThetargetNode = targetNode.data.targetNodeId; //const targetHeightOfTheTargetNode = targetIdIOfThetargetNode? findNodeDomElement(targetIdIOfThetargetNode)?.getBoundingClientRect().height : 0; if (false && targetHeightOfTheTargetNode) { let greatestHeight: number = 0; if (Math.floor(targetHeightOfTheTargetNode) >= Math.floor(currentTierHeight)) { greatestHeight = targetHeightOfTheTargetNode } else { greatestHeight = currentTierHeight } extraSpacingOnTopBecauseOfCenterAlignment = (greatestHeight - heightOfTheTargetNode) / 2; } // if the previuos tier is a nested branch, we want to subtract the height of the testable node IF THE branch is higer then the etsable node if (false && targetIsAnchorOrAddButton && sourceIsAnchorOrAddButton) { const sourceNodeBranchType: BranchData['type'] | undefined = useNodesStore.getState().couponsPlusNodeTypes.find(({id}) => `${id}` === sourceNode.data.targetNodeId)?.branchType; if (sourceNodeBranchType === 'first-passing-node' || sourceNodeBranchType === 'tiered') { const sourceNodeTargetNode = getNode(sourceNode.data.targetNodeId)! as RenderedNode; const sourceNodeTargetHeight = findNodeDomElement(sourceNodeTargetNode.id)?.getBoundingClientRect().height || 0; const sourceNodeTargetBranchHeight = calculateTierHeight(sourceNode, getNode, {withY: false}); if (Math.floor(sourceNodeTargetBranchHeight) > Math.floor(sourceNodeTargetHeight)) { extraSpacingOnTopBecauseOfCenterAlignment = extraSpacingOnTopBecauseOfCenterAlignment - sourceNodeTargetHeight; } } } const topSpacing = targetNode.topSpacing?.() || 0 const leftSpacing = targetNode.leftSpacing?.() || 0 const y = YOfThePreviousTier + topSpacing + (targetIsAddTierButton ? currentTierHeight : Math.max(0, (((currentTierHeight - heightOfTheTargetNode) / 2)))); const sourceNodePosition = getNode(targetNode.getSourceRenderNodeId())?.position || {x: 0, y: 0} return { // x position is half the width of the source node x: sourceNodePosition?.x + ( options?.x === 'start' ? 0 : ( (((sourceDOMElementMeasures()?.width || 0) / 2) - ((targetElementToUse?.getBoundingClientRect().width || 0) / 2)) + leftSpacing ) ), y // y position 100px below the source node, considering its height /*y: targetNode?.data?.isFirst? sourceNodeYPosition + extraSpacingOnTopBecauseOfCenterAlignment + spacing : ( pickedTheBestHeightOfThePreviousTier? pickedTheBestHeightOfThePreviousTier + extraSpacingOnTopBecauseOfCenterAlignment + spacing : (sourceNodeYPosition + spacing) )*/ }; } const renderedNodeExists = (nodeIdToCheck: string) => { return findNode(nodeIdToCheck); }; const findNode = (nodeIdToCheck: string) => { return useNodesStore.getState().renderedNodes.find((node) => node.id === nodeIdToCheck); }; const findWidthAndPosition = (nodeIdToCheck: string, getNode: GeneralHelpers['getNode']): number => { // get the node's x and width const node = renderedNodeExists(nodeIdToCheck); if (!node) { return 0; } const x = positionOverrides.get(nodeIdToCheck)?.x ?? node?.position?.x ?? 0; return (getDimensions(nodeIdToCheck).width || 0) + x; }; function calculateNodePositionHorizontally(targetNode: MiddleRenderedNodeData & RendererNode, sourceNode: RenderedNode | undefined, getNode: GeneralHelpers['getNode'], options?: CalculateHorizontalPositionOptions): RenderedNode['position'] { let widthOfTheSourceIncludingX: number = 0 let widthOfTheSourceIncludingPosition: number = 0 sourceNode = getNode(targetNode.getSourceRenderNodeId()) as RenderedNode // so now for the width, we need to find the max width of the source including its branch if the source exists const sourceDOMElementMeasures = getDimensions(targetNode.getSourceRenderNodeId()); const treeNodeForCalculatingFullWidth = targetNode.getSourceRenderNode()?.treeNodeForCalculatingFullWidth?.() as TreeNode | undefined; if (treeNodeForCalculatingFullWidth) { // if the source is a tree node, then we need to loop through all its descendants and find the max x of the branches widthOfTheSourceIncludingX = findLargestEndXOfATreeNode(treeNodeForCalculatingFullWidth, getNode) } else if (sourceNode) { // here we'll take the dom elements of the source (testable) AND the dom element of the branch // and we'll the width of whichever is greater widthOfTheSourceIncludingX = sourceDOMElementMeasures?.width!; /*const branchDOMElement = findNodeDomElement(getIdSuffixed(sourceNode.id, 'branch')); const branddimesions = getDimensions(getIdSuffixed(sourceNode.id, 'branch')) console.log('branddimesions', branddimesions, branchDOMElement) if (branchDOMElement) { widthOfTheSource = Math.max(widthOfTheSource, branchDOMElement.getBoundingClientRect().width); }*/ } if (!sourceNode) { // @ts-ignore sourceNode = { position: { x: 0, y: 0 } } } let y: number = sourceNode!.position.y ||0 /*40*/ if (options?.y === 'middle') { const sourceHeight = sourceDOMElementMeasures?.height || 0; let targetId: string = targetNode.getId(); if (typeof targetNode.getCenteredId === 'function') { const possibleCenteredId = targetNode.getCenteredId() as CenteredId | undefined; if (possibleCenteredId) { targetId = possibleCenteredId.native ? `#${possibleCenteredId.id}` : possibleCenteredId.id } } const targetHeight = getDimensions(targetId).height || 0; y = sourceNode!.position.y - ((targetHeight - sourceHeight) / 2) } const spacing = targetNode.leftSpacing?.() || 0; const positionX = (targetNode as RendererNodeData).horizontalSourceX?.(getNode) ?? sourceNode!.position.x return { // x position is the x of the source node plus the width of the source node plus 100 x: (widthOfTheSourceIncludingPosition ? widthOfTheSourceIncludingPosition : positionX + (widthOfTheSourceIncludingX || 0)) + spacing, // x position is half the width of the source node // x: sourceNode!.position.x + (sourceDOMElement?.getBoundingClientRect().width || 0) / 2 - (targetDOMElement?.getBoundingClientRect().width || 0) / 2, // y position 100px below the source node, considering its height y }; } function findLargestEndXOfATreeNode(treeNode: TreeNode | undefined, getNode: GeneralHelpers['getNode']): number { if (!treeNode) { return 0; } const widthsWithX: number[] = [] // first get the width and x of the root testable widthsWithX.push( getEndX(treeNode.testableNode.getId()!, getNode) ) if (treeNode.branchNode.isSimple()) { // if the branch is simple, then we just need to get the end x of the branch widthsWithX.push( getEndX(treeNode.branchNode.getId(), getNode) ) } else { const findEndXOfTiers = (treeNode: TreeNode) => { treeNode.branchNode.getChildren(({id}) => treeNode.grove.get(id)).forEach(tier => { const branch = tier.branchNode if (branch.isSimple()) { widthsWithX.push( getEndX(branch.getId(), getNode) ) } else { findEndXOfTiers(tier) } }) } /** * It's a tiered branch, so let's get the end x of all the branches recursively */ findEndXOfTiers(treeNode) } return Math.max(...widthsWithX); } function getEndX(renderedNodeId: string, getNode: GeneralHelpers['getNode']) { const node = getNode(renderedNodeId) as RenderedNode const nodeWidth = getDimensions(renderedNodeId).width || 0; const nodeX = node?.position?.x > 0? node.position.x : 0; return nodeX + nodeWidth; } function calculateNodePositionBottomRight(targetNode: RenderableNode & MiddleRenderedNodeData & RendererNodeData, sourceNode: RenderedNode | undefined, getNode: GeneralHelpers['getNode'], options?: CalculateVerticalPositionOptions): RenderedNode['position'] { const {y} = calculateNodePositionVertically(targetNode, sourceNode!, getNode, options); // first the vertical position const xCenterFromSource = (getDimensions(targetNode.getSourceRenderNodeId())?.width / 2) || 0; let xFromSource = xCenterFromSource + (targetNode.leftSpacing?.() || 0) const leftSpacing = targetNode.leftSpacing?.() || 0 const sourceNodePosition = getNode(targetNode.getSourceRenderNodeId())?.position || {x: 0, y: 0} return { x: (sourceNodePosition?.x || 0) + xFromSource + leftSpacing, y }; } function findRootNode() { return node => node.data?.renderedNodeType === 'root'; } function isFirstNodeOfABranch(changedNode: NodeDimensionChange | NodePositionChange | NodeSelectionChange | NodeRemoveChange | NodeAddChange, string> & { style?: React.CSSProperties; className?: React.CSSProperties; resizing?: boolean; focusable?: boolean }> | NodeReplaceChange, string> & { style?: React.CSSProperties; className?: React.CSSProperties; resizing?: boolean; focusable?: boolean }>) { return changedNode.id.endsWith('->1'); } let canUpdate: boolean = true; let savedGrove = JSON.stringify(CouponsPlus.state) let source: string | undefined = savedGrove;/* undefined;*/ '{"version":1,"grove":[{"testable":{"type":"AND","mode":"filter","children":[{"type":"context","mode":"filter","children":[{"parentType":"filters","type":"NumberOfItems","options":{"type":"limit","quantity":{"type":"equals","amount":1,"range":{"minimum":1,"maxmimum":2}}},"testableType":"filter"}]}]},"branch":{"type":"offers","children":[{"parentType":"offers","type":"BuyXGetX","options":{"quantity":1,"discount":{"type":"free","amount":0}}}]}}]}' /*'{"version":1,"grove":[{"testable":{"type":"AND","mode":"filter","children":[{"type":"context","mode":"filter","children":[{"parentType":"filters","type":"NumberOfItems","options":{"type":"limit","quantity":{"type":"equals","amount":1,"range":{"minimum":1,"maxmimum":2}}},"testableType":"filter"}]}]},"branch":{"type":"offers","children":[{"parentType":"offers","type":"BuyXGetX","options":{"quantity":1,"discount":{"type":"free","amount":0}}}]}},{"testable":{"type":"OR","mode":"filter","children":[{"type":"context","mode":"filter","children":[{"parentType":"filters","type":"NumberOfItems","options":{"type":"limit","quantity":{"type":"equals","amount":1,"range":{"minimum":1,"maxmimum":2}}},"testableType":"filter"}]},{"type":"context","mode":"filter","children":[{"parentType":"filters","type":"AnyProducts","options":{},"testableType":"filter"}]}]},"branch":{"type":"offers","children":[{"parentType":"offers","type":"BuyXGetX","options":{"quantity":1,"discount":{"type":"free","amount":0}}}]}},{"testable":{"type":"AND","mode":"filter","children":[{"type":"context","mode":"filter","children":[{"parentType":"filters","type":"Products","options":{"mode":"classic","ids":[],"inclusionType":"allowed"},"testableType":"filter"},{"parentType":"filters","type":"NumberOfItems","options":{"type":"limit","quantity":{"type":"equals","amount":1,"range":{"minimum":1,"maxmimum":2}}},"testableType":"filter"}]}]},"branch":{"type":"offers","children":[{"parentType":"offers","type":"BuyXGetY","options":{"mode":"products","data":[],"whenNotInCart":{"actions":[],"notification":{"message":"You\'ve unlocked an exclusive offer!","button":{"text":"Select Reward","url":{"type":"smart","value":""}}}}}}]}}]}'*/ /*JSON.stringify([{ testable: wrapTestablesInDefaultGroup([ { type: 'AnyProducts', testableType: 'filter', options: {} as AnyProductsOptions }, ]), branch: { type: 'select-action', // children ignored bc of type = select-action children: [ { id: 'iyut', parentType: 'offers', type: 'BuyXGetX', options: { quantity: 1, discount: { amount: 10, type: 'free' } } as BuyXGetXOptions }, /!* { id: 'hjgjfyjf', parentType: 'offers', type: 'BuyXGetY', options: { mode: 'advanced', data: { source: [ ], } }, } as(OfferData> & ParentType) *!/ ] } as Omit }])*/; /* JSON.stringify([{ testable: wrapTestablesInDefaultGroup([{ type: 'Products', testableType: 'filter', options: { inclusionType: 'allowed', ids: [] } as ProductsOptions }]), branch: { type: 'offers', children: [] } as Omit }]); */ /*JSON.stringify([ { testable: wrapTestablesInDefaultGroup([{ type: 'Products', testableType: 'filter', options: {} }]), branch: { type: 'tiered', data: { baseTestable: { type: 'UserRole', testableType: 'condition', options: {} }, tierType: TierType.Fixed } as NestedTieredData, children: [ { testable: { options: { roles: ['admin'] } as UserRoleOptions } as Omit, 'id'>, branch: { type: 'select-action', children: [] } }, ] } as Omit } as Omit, { testable: wrapTestablesInDefaultGroup([ { type: 'InCategories', testableType: 'filter', options: { categories: ['jackets'] } } as Omit ]), branch: { type: 'tiered', data: { baseTestable: { type: QuantityMeta.id, testableType: 'filter', options: {} }, tierType: TierType.Numeric } as NestedTieredData, children: [ { testable: { options: { quantity: equals(2) } } as Omit, 'id'>, branch: { type: 'select-action', children: [] } }, { testable: { options: { quantity: equals(4) } } as Omit, 'id'>, branch: { type: 'offers', children: [ { type: 'Discount', options: { type: 'amount', amount: 20 } } as OfferData ] } }, { testable: { options: { quantity: equals(6) } } as Omit, 'id'>, branch: { type: 'tiered', data: { baseTestable: { type: QuantityMeta.id, testableType: 'filter', options: {} }, tierType: TierType.Numeric } as NestedTieredData, children: [ { testable: { options: { quantity: equals(8) } } as Omit, 'id'>, branch: { type: 'select-action', children: [] } }, { testable: { options: { quantity: equals(9) } } as Omit, 'id'>, branch: { type: 'offers', children: [ { type: 'Discount', options: { type: 'amount', amount: 20 } } as OfferData, { type: 'Discount', options: { type: 'amount', amount: 20 } } as OfferData, { type: 'Discount', options: { type: 'amount', amount: 20 } } as OfferData, { type: 'Discount', options: { type: 'amount', amount: 20 } } as OfferData, ] } }, { testable: { options: { quantity: equals(10) } } as Omit, 'id'>, branch: { type: 'tiered', data: { baseTestable: { type: QuantityMeta.id, testableType: 'filter', options: {} }, tierType: TierType.Numeric } as NestedTieredData, children: [ { testable: { options: { quantity: equals(12) } } as Omit, 'id'>, branch: { type: 'select-action', children: [] } }, { testable: { options: { quantity: equals(14) } } as Omit, 'id'>, branch: { type: 'offers', children: [ { type: 'Discount', options: { type: 'amount', amount: 20 } } as OfferData ] } } ] } }, { testable: { options: { quantity: range(22, 24) } } as Omit, 'id'>, branch: { type: 'select-action', children: [] } }, ] } }, ] as (Omit)[] } as Omit } as Omit, { testable: wrapTestablesInDefaultGroup([{ type: 'Products', testableType: 'filter', options: {} }]), branch: { type: 'offers', children: [ { type: 'Discount', options: { type: 'amount', amount: 20 } } as OfferData, { type: 'Discount', options: { type: 'amount', amount: 20 } } as OfferData, { type: 'Discount', options: { type: 'amount', amount: 20 } } as OfferData, { type: 'Discount', options: { type: 'amount', amount: 20 } } ] } as Omit } as Omit, { testable: wrapTestablesInDefaultGroup([{ type: 'InCategories', testableType: 'filter', options: {} }]), branch: { type: 'select-action', children: [] } as Omit } as Omit, ]) */ const ProductFiltersFlow = () => { const renderedNodes = useNodesStore((state => state.renderedNodes)) const edges = useNodesStore((state => state.edges)) const rootTreeCount = useNodesStore((state => state.grove.length)) const setRenderedNodes = useNodesStore(state => state.setRenderedNodes) const {getNode, getNodes, updateNode} = useReactFlow(); const editorContainerRef = useRef(null) const [reactFlowInstance, setReactFlowInstance] = useAtom(mainReactFlowInstanceAtom) const reactFlow = useReactFlow() const emitter = useAtomValue(treeEmitterAtom) const importFromSource = useNodesStore(store => store.importFromSource) useLayoutEffect(() => { if (!reactFlowInstance) { setReactFlowInstance(reactFlow) } setTimeout(() => { /*this may get called twice so let's make sure we only importin it when its empty*/ if (source && renderedNodes.length === 0) { importFromSource(source) source = ''; // make sure we only import once } }, 150) /* if (source && renderedNodes.length === 0) { const state = useNodesStore.getState(); const groveActions = new GroveActions(state) const treeNodeFactory = new TreeNodeFactory( new Testables(state), new Offers(state) ) const treeNodes = treeNodeFactory.createFromNestedDataJson(source) treeNodes?.forEach?.((treeNode) => { groveActions.addTreeNode(treeNode) }) } */ }, []) // THIS IS THE KEY PART useEffect(() => { // This effect runs every time 'nodes' changes. // Set a timer. If nodes change again before it fires (e.g., from the layout engine), // the cleanup function below will cancel this timer. const timer = setTimeout(() => { console.log('✅ Nodes have stabilized. Calling fitView.'); setTimeout(() => { // wait for painting to be done... emitter.emit('renderedNodes:stabilized', reactFlowInstance!); }, 0); }, 150); // A 150ms quiet period is usually enough to detect stabilization. // This cleanup function is CRITICAL. It runs before the next effect or on unmount. return () => { clearTimeout(timer); }; }, [renderedNodes]); // The effect is dependent on the nodes state. const reactFlowInstanceRef = useRef(reactFlowInstance); reactFlowInstanceRef.current = reactFlowInstance; const handleInit = useCallback((newReactFlowInstance) => { // @ts-ignore if (newReactFlowInstance === reactFlowInstanceRef.current) { return } // @ts-ignore setReactFlowInstance(newReactFlowInstance) }, [setReactFlowInstance]); const handleNodeClick = useCallback(() => { console.log('clicked node') }, []); const reactFlowStyle = useMemo(() => ({ background: 'rgba(255, 255, 255, 0)', }), []); const handleNodesChange = useCallback((changedNodes: NodeChange[]) => { // Skip layout when composite overlay is open — the inline node is frozen if (atomsStore.get(OverlayOpenCompositeAtom) !== null) { return; } const dimensionChanges = changedNodes.filter(c => c.type === 'dimensions'); const hasStructuralChanges = changedNodes.some(c => c.type === 'remove' || c.type === 'add' || c.type === 'replace') if (dimensionChanges.length === 0 && !hasStructuralChanges) { // Non-layout changes (selection, etc.) don't affect layout — skip entirely return; } // Store ONLY dimension changes for O(1) getDimensions() lookups changedDimensionsMap = new Map() for (const c of dimensionChanges) { if (c.type === 'dimensions' && c.dimensions) { changedDimensionsMap.set(c.id, c.dimensions) } } const renderedNodes = useNodesStore.getState().renderedNodes const nodes = (hasStructuralChanges || dimensionChanges.length === renderedNodes.length) ? getNodes() : renderedNodes // if this gets updated bellow the REFERENCES should be different, if the references are the same don't update the state let updatedNodes = [...nodes] let updateNodes = false const grove = getGroveForReading() // O(1) index lookups instead of O(n) findIndex in the inner loop const nodeIndexMap = new Map() for (let i = 0; i < nodes.length; i++) { nodeIndexMap.set(nodes[i].id, i) } // Feed-forward: as we calculate positions, store them so subsequent // nodes see fresh positions instead of stale React Flow state. // This achieves single-pass convergence (no multi-cycle oscillation). positionOverrides = new Map() const getNodeFeedForward: typeof getNode = (id) => { const node = getNode(id) const pos = positionOverrides.get(id as string) if (node && pos) { return { ...node, position: pos } as typeof node } return node } const nodesChangedByUs: RenderedNode[] = [] for (let i = 0; i < nodes.length; i++) { const changedNode: RenderedNodeData = nodes[i]; //const changedNodeNode = grove.get(changedNode.id) let direction: Direction = 'vertical'; let options: CalculatePositionOptions | {} = {} let targetRenderNode: FirstRenderedNodeData | LastRenderedNodeData | MiddleRenderedNodeData | undefined targetRenderNode = grove.get(changedNode.id) switch (changedNode.type as keyof typeof nodeTypes) { case 'tree.root.anchor': if (grove.isFirst((targetRenderNode as TreeNode))) { direction = 'vertical' } else { direction = 'horizontal' } targetRenderNode = (targetRenderNode as TreeNode).getSourceRenderNode() as typeof targetRenderNode break; case 'testable.group': // its always vertical in relation to its anchor direction = 'vertical' /* if (grove.isFirst((targetRenderNode as TestableCompositeNode).getTreeNode().getId())) { direction = 'vertical' } else { direction = 'horizontal' } */ break; case 'branch.tiered.testable.base': case 'branch.tiered.tier.testable': direction = 'horizontal'; options = { y: 'middle', } break; case 'branch.tiered.tier.step-anchor': const isFirst = (targetRenderNode as TreeNode).isFirstTier(); if (isFirst) { direction = 'horizontal'; options = { y: 'middle', } } else { direction = 'vertical'; } break; case 'branch.select-action': case 'branch.select-action-split': case 'branch.offers': if ((targetRenderNode as StateBranchNode).isInsideAnotherBranch()) { direction = 'horizontal' options = { y: 'middle' } } else { direction = 'vertical' } break case 'branch.tiered': if ((targetRenderNode as StateBranchNode).getTreeNode().isRoot()) { direction = 'bottom-right' } else { // this should actually b e bottom right but we're just refactoring so by the end of the refactoring hopefully this will be fixed direction = 'vertical' } break case 'branch.tiered.add-tier-button': direction = 'vertical' options = { x: 'start', } const treeNode = grove.get(changedNode.data.treeNodeId) as TreeNode targetRenderNode = treeNode.branchNode.getAddTierButton() break default: targetRenderNode = undefined } if (targetRenderNode) { const newPosition = calculateNodePosition( //changedNode as RenderedNode, targetRenderNode, /*sourceNode as RenderedNode | undefined*/undefined, direction, getNodeFeedForward, // @ts-ignore options ); if (newPosition && !isEqual(changedNode.position, newPosition)) { // if ((newPosition.x < 1 || newPosition.y < 1) === false) { //if ((newPosition.y < 1) === false) { if ((newPosition.y >= 0)) { // by creating a new array reference here we tell our code below that this has changed and needs to be updated //updatedNodes = [...updatedNodes] updateNodes = true // Feed this position forward so later nodes see it positionOverrides.set(changedNode.id, newPosition) const index = nodeIndexMap.get(changedNode.id)!; const updatedNode = { ...changedNode, position: newPosition }; updatedNodes[index] = updatedNode nodesChangedByUs.push(updatedNode); } } } } if (updateNodes/*nodes !== updatedNodes*/) { setRenderedNodes([...updatedNodes]); } }, [getNodes, getNode, setRenderedNodes]); const previousRootTreeCountRef = useRef(rootTreeCount) useEffect(() => { const previousRootTreeCount = previousRootTreeCountRef.current previousRootTreeCountRef.current = rootTreeCount if (rootTreeCount >= previousRootTreeCount) { return } const frameId = requestAnimationFrame(() => { handleNodesChange([{id: '__root-tree-removed__', type: 'remove'} as NodeRemoveChange]) }) return () => { cancelAnimationFrame(frameId) } }, [rootTreeCount, handleNodesChange]) return (
{(renderedNodes.length === 0) ? : undefined}
); }; export default ProductFiltersFlow; export const Tree = () => { return
}