import { NodesStore, OfferData, RenderedNode, TestableData, TestablePartial, useNodesStore, WithParentNodeId, WithTargetNodeId, WithXSpacing } from "./store"; import {Testables, TestablesState} from "./testables"; import {generate} from "shortid"; import {cloneDeep, merge, pickBy} from "lodash"; import {nodeTypes} from "../Tree"; import {StepAnchorData} from "../StepAnchorBranch"; import {OffersBranchData} from "../OffersBranch"; import {test} from "vitest"; import {Position} from "@xyflow/react"; import {increase, TierType} from "./tiers"; export const getTree = () => { const state = useNodesStore.getState(); return new Tree(state, new Testables(state)) }; export type TreeState = Pick; export class Tree { private isInsideATransaction: boolean = false; private afterPerformActionsQueue: (() => void)[] = []; constructor( public state: TreeState, public readonly testables: Testables ) {} /** * will remove rendered nodes and its related nodes from a testable id */ removeNodeFromTestable(testableId: string) { const renderedNode = this.state.renderedNodes.find(node => node.data?.testableID === testableId); if (renderedNode) { this.removeRenderedNode(renderedNode.id); } } public removeRenderedNode(id: string) { this.perform('removeNode', {data: {id}}) } private performRemoveNode({id}: {id: string}) { const nodeToRemove = this.getRenderedNode(id); const parentBranch = this.getParentBranch(id); //this need to be checked here before the node gets removed if (!nodeToRemove) { return; } const isStepAnchor = this.isStepAnchor(nodeToRemove.id); // before removing the node, let's remove its branch relations since the other methods depend on the object being there if (this.isTestablePartial(nodeToRemove.id) || this.isTreeNode(nodeToRemove.id)) { // le's remove the branch relations this.removeBranchRelations(nodeToRemove.id) } // remove the offers if this is an offers branch if (this.isOffersBranch(nodeToRemove.id)) { this.removeOffersBranch(nodeToRemove.id); } this.removeCouponsPlusNodeType(nodeToRemove.id); this.state.renderedNodes.splice(this.state.renderedNodes.indexOf(nodeToRemove), 1); // now if it has a target node, we need to remove it too const sourceNode = !isStepAnchor && this.getSourceNode(nodeToRemove.id); if (sourceNode) { this.removeRenderedNode(sourceNode.id); } if (this.getRenderedNode(`${nodeToRemove.id}.step-anchor`)) { this.removeRenderedNode(`${nodeToRemove.id}.step-anchor`); } if (nodeToRemove.type === 'tiered-base-node') { // its a tier branch so let's make sure we remove its add tier button const addTierButton = this.getAddTierButtonNode(nodeToRemove.id); if (addTierButton) { this.removeRenderedNode(addTierButton.id); } } const targetNode = nodeToRemove.data?.targetNodeId; if (targetNode) { this.removeRenderedNode(targetNode); } //update the add node button if (isStepAnchor) { this.updateAddTierButton(this.getParentBranchId(nodeToRemove.data?.sourceNodeId || '')); } // then we gotta remove the edges associated with that rendered node this.removeRenderedNodeEdge(nodeToRemove.id); // remove branch relations this.removeBranchChildren(parentBranch?.id, [nodeToRemove.id]); this.updateTieredBranchRelationships(parentBranch?.id) this.afterPerformActionsQueue.push(() => { this.addSelectActionBranchToSingleRootNodes() }) } private getSourceNode(id: string): RenderedNode | undefined { return this.state.renderedNodes.find(({data: {sourceNodeId}}) => sourceNodeId === id); } private removeRenderedNodeEdge(id: string) { const edgeIndex = this.state.edges.findIndex(edge => edge.source === id || edge.target === id); if (edgeIndex !== -1) { this.state.edges.splice(edgeIndex, 1); } } removeOrphanNodes() { // if a tree node with a testableid and the testableid does not exist const renderedNodesWithATestable = this.state.renderedNodes.filter(renderedNode => { return this.isRootTreeNode(renderedNode.id); //return typeof renderedNode.data?.testableID === 'string' }) const nodesWithoutValidTestables = renderedNodesWithATestable.filter(renderedNode=> { return !this.testables.getNode(renderedNode?.data?.testableID || ''); }) nodesWithoutValidTestables.forEach(({id}) => { this.removeRenderedNode(id); }) const tierBaseNodesWithoutSourceTestable = this.state.renderedNodes.filter( node => { return node.type === 'tiered-base-node' && node.data?.testableID && !this.testables.getNode(node.data.testableID) } ); tierBaseNodesWithoutSourceTestable.forEach(({id}) => { this.removeRenderedNode(id); }) //remove add node buttons const addNodeButtons = this.state.renderedNodes.filter(node => node.type === 'branch-add-node-button'); addNodeButtons.forEach(({id, data: {parentNodeId}}) => { if (!this.getRenderedNode(parentNodeId)) { this.removeRenderedNode(id); } }) } createRootTree(testableCompositeGroupID: string) { // so, let's create a simple tree const rootNode = this.testables.getNode(testableCompositeGroupID); if (!rootNode) { return; } const rootNodeId = rootNode.id; const rootNodeData = this.state.renderedNodes.find(node => node.id === rootNodeId); if (rootNodeData) { return; } // add the tree-node rendred node this.state.renderedNodes.push({ id: `${testableCompositeGroupID}->testable`, type: 'tree-node', data: { renderedNodeType: 'root', isDefaultAND: true, testableID: testableCompositeGroupID, }, position: { x: 0, y: 0 } }) // add the branch rendered node this.addSelectActionBranch(testableCompositeGroupID); // now add the edge this.state.edges.push({ id: testableCompositeGroupID, source: `${testableCompositeGroupID}->testable`, target: `${testableCompositeGroupID}->branch`, type: 'connecting-line', }) // that's it for now } private addSelectActionBranch(testableCompositeGroupIDorTestablePartialID: string) { let sourceId: string, isInsideBranch: boolean = false if (this.isTestablePartial(testableCompositeGroupIDorTestablePartialID)) { // here the source is the testable partial itself sourceId = testableCompositeGroupIDorTestablePartialID isInsideBranch = true } else { // here it's the testable composite group sourceId = this.getSuffixed(testableCompositeGroupIDorTestablePartialID, 'testable') } const branchId = this.getSuffixed(testableCompositeGroupIDorTestablePartialID, 'branch'); if (this.getRenderedNode(branchId)) { // if it already exists, we don't need to add it again return; } this.state.renderedNodes.push({ id: branchId, type: 'select-action-branch', data: { renderedNodeType: 'action', isInsideBranch, sourceNodeId: sourceId }, position: { x: 0, y: 0 }, }) this.addChildrenRelations(sourceId, [branchId]); this.setCouponsPlusNodeType(branchId, 'select-action') } private getSuffixed(id: string, suffix: 'testable' | 'branch') : string { if (suffix === 'testable') { // if it ends with ->testable, do noting if (id.endsWith('->testable')) { return id; } else if (id.endsWith('->branch')) { // if it ends with ->branch, we need to remove the branch return id.replace(/->branch$/, '->testable'); } else { // if it doesn't end with ->testable or ->branch, we need to add the testable return id + '->testable'; } } else if (suffix === 'branch') { // if it ends with ->branch, do nothing if (id.endsWith('->branch')) { return id; } else if (id.endsWith('->testable')) { // if it ends with ->testable, we need to remove the testable return id.replace(/->testable$/, '->branch'); } else { // if it doesn't end with ->testable or ->branch, we need to add the branch return id + '->branch'; } } return id; } /** * A valid target branch id is needed */ addOffers(offers: Omit[], targetBranchId: string) { offers.forEach(offer => { const branch = this.getRenderedNode(targetBranchId); if (!branch) { return; } const offerToAdd = { id: `offer-${generate()}`, ...offer, // this way we can keep an id if it had one }; this.state.offers.push(offerToAdd) // now we need to add the offer to the branch // but what if the branch already has offers? const branchRelations = this.getBranchRelations(targetBranchId); if (branchRelations) { // just add the offer to the children branchRelations.children.push(offerToAdd.id); } else { // we aint have any relations, so we need to create one this.state.branchRelations.push({ branch: targetBranchId, children: [ offerToAdd.id ], childrenType: 'offers' }) } // alright now let's change the rendered node to an offers branch this.ensureIsAnOffersBranch(branch.id); }) } public getRenderedNode(id: string): RenderedNode | undefined { return this.state.renderedNodes.find(node => node.id === id); } getBranchChildrenIds(renderedNodeId: string, withRelations: boolean = false): (string[] | [string[], (NodesStore['branchRelations'][number] | undefined)]) { if (/*this.isBaseTestable(branchId) ||*/ this.isTestablePartial(renderedNodeId) || this.isStepAnchor(renderedNodeId) || this.isAddTierButton(renderedNodeId)) { renderedNodeId = this.getParentBranchId(renderedNodeId) || renderedNodeId; } const branchRelations = this.state.branchRelations.find(branch => branch.branch === renderedNodeId); if (!branchRelations) { return withRelations? [[], undefined] : []; } return withRelations? [branchRelations.children, branchRelations] : branchRelations.children } public getBranchChildren(branchId: string): ChildrenDataType[] { const [branchChildrenIds, branchRelations] = this.getBranchChildrenIds(branchId, true) as [string[], NodesStore['branchRelations'][number]] if (!branchRelations) { return []; } if (branchRelations.childrenType === 'offers') { // look in offers return branchRelations.children.map(childId => { const offer = this.state.offers.find(offer => offer.id === childId); if (offer) { return offer as ChildrenDataType; } }).filter(Boolean) as ChildrenDataType[]; } return branchChildrenIds.map(this.getRenderedNode.bind(this)).filter(Boolean) as ChildrenDataType[]; } /** * Returns the parent id eg: the testable base if a tier */ public getParentBranchId(nodeId: string) { const node = this.getRenderedNode(nodeId); if (!node) { return; } if (this.isBaseTestable(nodeId)) { return node.id; } else if (this.isTestablePartial(nodeId)) { return this.getParentBranchId(node.data.parentNodeId); } else if (this.isStepAnchor(nodeId)) { return this.getParentBranchId( (node.data.type === 'tiered-base' ? node.data.targetNodeId : node.data.sourceNodeId) || '' ); } else if (this.isAddTierButton(nodeId)) { return this.getParentBranchId(node.data.parentNodeId); } } /** * Returns the root branch id */ public getRootBranchId(branchId: string): string | undefined { const node = this.getRenderedNode(branchId); if (!node) { return; } if (this.isBaseTestable(branchId)) { return node.data.sourceNodeId; } else if (this.isTestablePartial(branchId)) { return this.getRootBranchId(node.data.parentNodeId); } else if (this.isStepAnchor(branchId)) { return this.getRootBranchId(node.data.sourceNodeId || ''); } } /** * Branch must exist though we can't create it for you */ private ensureIsAnOffersBranch(branchId: string) { const branch = this.getRenderedNode(branchId); if (!branch) { return; } branch.type = 'branch-offers'; branch.data = { isInsideBranch: false, // here so that it may be overridden if it is inside a branch ...branch.data, renderedNodeType: 'branch-offers', // here because this needs to be set to branch-offers } } private getBranchRelations(targetBranchId: string): NodesStore['branchRelations'][number] | undefined { const node = this.getRenderedNode(targetBranchId); if (this.isBaseTestable(targetBranchId)) { // if it's a tiered base node, we need to get the root testable for this branch //targetBranchId = node.data?.sourceNodeId?.replace(/->testable$/, ''); } return this.state.branchRelations.find(branch => branch.branch === targetBranchId); } updateOffer(offer: OfferData) { const offerToUpdate = this.getOffer(offer.id) if (!offer) { return; } const offerIndex = this.state.offers.findIndex(({id}) => id === offer.id); if (offerIndex === -1) { return; } this.state.offers[offerIndex] = merge(this.state.offers[offerIndex], offer); } removeOffer(id: string) { this.perform('removeOffer', {data: {id}}) } private performRemoveOffer({id}: {id: string}) { const offer = this.state.offers.find(offer => offer.id === id); if (!offer) { return; } // remove from offers array this.state.offers.splice(this.state.offers.indexOf(offer), 1); // now we need to remove the offer from the branch const parentBranchNode = this.getParentBranch(offer.id); this.removeBranchChildren(parentBranchNode?.id, [offer.id]); } /** * ALL This is a duplicate of Testables, it needs be refactored when we have time */ perform(action: 'removeOffer' | 'removeOffersBranch' | 'removeNode' | 'removeBranchChildren' | 'convertBranch', {data}: {data: Data}) { this[`perform${action.charAt(0).toUpperCase() + action.slice(1)}`](data) this.maybeCallActions(); } /** * Will hold the cleanup actions until the transaction is finished. * When you run a mutable function, a "cleanup" action will be performed after that. * * For example, if you remove a node, an action will be scheduled to check if its parent has no children and if not, * it will be removed too because we don't want to have empty nodes. * * So if you remove a node inside a transaction, the cleanup action will not be executed until the transaction is finished. * * This allows us, for example, to remove a node and then add a new one to its parent. * * If you remove a node outside a transaction and that node was the only child of its parent's node, the parent would be removed, and you wouldn't be able to add another node to that parent * * eg: * * the parent of testable is context-8758 and it has no other children * this.remove('testable') * this.addTestables([newTestable], 'context-8758') // this would fail because context-8758 would have been removed by now * * so you'd have to perform those operations inside a transaction * * this.transaction(() => { * this.remove('testable') * // the parent isn't removed yet because we are inside a transaction * this.addTestables([newTestable], 'context-8758') * // end of transaction, cleanup actions are executed but parent is still there because we added a new testable to it * }) */ transaction(callable: Function) { this.isInsideATransaction = true const result = callable() this.isInsideATransaction = false this.maybeCallActions() return result; } private maybeCallActions = () => { if (this.isInsideATransaction) { return } for (const [index, action] of this.afterPerformActionsQueue.entries()) { action() } this.afterPerformActionsQueue = [] } public getParentBranch(id: string) : RenderedNodeType | undefined { const branchRelations = this.state.branchRelations.find(branch => branch.children.includes(id)); if (!branchRelations) { return; } return this.state.renderedNodes.find(node => node.id === branchRelations.branch) as RenderedNodeType; } private removeBranchChildren(parentBranchNodeId: string | undefined, childrenIDS: string[]) { this.perform('removeBranchChildren', {data: {parentBranchNodeId, childrenIDS}}) } private performRemoveBranchChildren({parentBranchNodeId, childrenIDS}: {parentBranchNodeId: string | undefined, childrenIDS: string[]}) { if (!parentBranchNodeId) { return; } const branchRelations = this.getBranchRelations(parentBranchNodeId); if (!branchRelations) { return; } childrenIDS.forEach(childId => { const childIndex = branchRelations.children.indexOf(childId); if (childIndex !== -1) { branchRelations.children.splice(childIndex, 1); } }) // schedule the cleanup action this.afterPerformActionsQueue.push(() => { this.convertOffersBranchWithNoChildrenToSelectActionBranch(branchRelations.branch); }) } private addSelectActionBranchToSingleRootNodes() { const singleRootNodes = this.getRootNodes().filter(node => { return !this.getRelatedNode(node.id); }) singleRootNodes.forEach(node => { this.addSelectActionBranch(node.id); }) } private convertOffersBranchWithNoChildrenToSelectActionBranch(id: any) { const branch = this.getRenderedNode(id); if (branch?.type !== 'branch-offers') { return; } const branchChildren = this.getBranchChildren(id) if (branchChildren.length > 0) { return; } this.convertBranchTo('select-action-branch', id); } private convertBranchTo(type: keyof typeof nodeTypes, id: any) { this.perform('convertBranch', {data: {type, id}}) } private performConvertBranch({type, id}: {type: keyof typeof nodeTypes, id: any}) { const branch = this.getRenderedNode(id); if (!branch) { return; } if (type === 'select-action-branch') { branch.type = type; branch.data = { ...branch.data, renderedNodeType: 'action', } } else if (type === 'tiered-base-node') { branch.type = type; branch.data = { renderedNodeType: 'branch-first-passing-node', targetNodeId: `${branch.id}->children->1->subchildren->1.step-anchor`, } } // here's where we may call the cleanup functions if needed for specific types } getOffer(id: string) : OfferData | undefined { return this.state.offers.find(offer => offer.id === id); } createTieredBranch({targetId, type, baseTestable, testablePartial, offers}: CreateTierBranchOptions): RenderedNode['id'] { const targetBranchToReplace = this.getRenderedNode(targetId); if (!targetBranchToReplace) { throw new Error('Branch not found when trying to create a tier branch'); } //let's check this before we remove the node const isNested = this.isInsideBranch(targetId); const parentNode = this.getParentBranch(isNested && false? targetBranchToReplace.data.sourceNodeId! : targetId)!; return this.transaction(() => { //before we add the tier, let's add the base testable const newTestableId = this.testables.addTestables([baseTestable], undefined, false)[0] if (!newTestableId) { throw new Error('Could not create base Testable branch'); } console.log('r the target?', targetId) // let's first remove that branch this.removeRenderedNode(targetId); // let's create the new branch const position = 1; const {baseTestableBranchId, testablePartialId, tierFinalBranchId} = this.generateTierBranchIds( position, isNested ? parentNode.id : undefined, isNested ) if (isNested) { const targetSubChild = this.getRenderedNode(targetBranchToReplace.data.sourceNodeId!)!; targetSubChild.data = { ...targetSubChild.data, renderedNodeType: 'branch-first-passing-node', targetNodeId: `${baseTestableBranchId}.step-anchor`, sourceAnchor: { id: 'bottom', position: Position.Bottom } } } // this is the main branch, this replaces the removed branch this.state.renderedNodes.push({ id: baseTestableBranchId, type: 'tiered-base-node', // @ts-ignore data: pickBy({ type, renderedNodeType: 'branch-first-passing-node', sourceNodeId: isNested? `${baseTestableBranchId}.step-anchor` : targetBranchToReplace.data.sourceNodeId, targetNodeId: `${testablePartialId}.step-anchor`, testableID: newTestableId, isInsideBranch: isNested || undefined, parentNodeId: isNested ? parentNode.id : undefined, } as WithTargetNodeId, (v) => v !== undefined), position: {x: 0, y: 0}, }) /*this.state.branchRelations.push({ branch: targetId.replace(/->branch$/, ''), children: [testablePartialId], childrenType: 'renderedNodes' })*/ this.setCouponsPlusNodeType(baseTestableBranchId, 'tiered') this.addChildrenRelations(parentNode.id, [baseTestableBranchId]) // step anchor for the base testable this.addStepAnchor(baseTestableBranchId, {isFirst: true, type: 'tiered-base'}) this.addTier(baseTestableBranchId, testablePartial, offers, position) return baseTestableBranchId; }); } addTier(targetBaseTestableBranch: string, testablePartial: CreateTierBranchOptions['testablePartial'], offers: CreateTierBranchOptions['offers'] = [], position?: number) : RenderedNode['id'] | undefined { if (!position) { // we'll add it to the end, so we need to rind out which is the last tier in this branch position = this.getBranchChildren(targetBaseTestableBranch).length + 1 } // instead of creating a new branch, we are going to add a new tier to the existing one const {testablePartialId, tierFinalBranchId} = this.generateTierBranchIds(position, targetBaseTestableBranch) const baseTestableBranchId = targetBaseTestableBranch if (!this.getRenderedNode(baseTestableBranchId)) { throw new Error('Could not create base Testable branch'); } const newTestableId = this.getRenderedNode(baseTestableBranchId)?.data?.testableID as string // now the partial const newTestablePartialId = this.testables.addTestablePartials([testablePartial], newTestableId)[0] // add the testable partial this.state.renderedNodes.push({ id: testablePartialId, type: 'tiered-subchild-node', // @ts-ignore data: { renderedNodeType: 'branch-first-passing-node', targetNodeId: tierFinalBranchId, parentNodeId: baseTestableBranchId, testablePartialId: newTestablePartialId, } as WithTargetNodeId & WithParentNodeId, position: {x: 0, y: 0}, }) // this is the relationship between the tier branch and its children (subchildren/testablePartials) this.addChildrenRelations(baseTestableBranchId, [testablePartialId]) // now the step anchor for the testable partial this.addStepAnchor(testablePartialId, {isFirst: position === 1, type: 'tier-row'}) // then add the final tier branch (offers, etc.) if (offers?.length) { this.addOffersBranch(tierFinalBranchId, testablePartialId, offers) // we have to add the relation between the subchild and its branch (eg 865656->children->1->subchildren->1 as parent and 865656->children->1->subchildren->1->branch as child) this.addChildrenRelations(testablePartialId, [tierFinalBranchId]) } else { // we have to add a select actions branch if we don't have offers this.addSelectActionBranch(testablePartialId) } this.updateAddTierButton(baseTestableBranchId) // you know what? instead of adding the edges manually, why don't we check for all rendered nodes that don't have edges? it might be a little slower, but it will be more reliable this.connectRenderedNodes() return testablePartialId; } cloneTier(targetTierId: string) : RenderedNode['id'] | undefined { const targetTier = this.getRenderedNode(targetTierId); if (!targetTier) { return; } const pastTestablePartialCopy = cloneDeep(this.testables.getNode(targetTier.data.testablePartialId) as TestablePartial); const tieredType = this.getTieredType(targetTier.id); let newTestablePartialOptions: object = {} if (tieredType === TierType.Numeric) { // here we'll change the options (eg: increase the number) but first let's clone the previous tier newTestablePartialOptions = increase(pastTestablePartialCopy.options); } return this.addTier( this.getParentBranch(targetTierId)!.id, // the options should be the same as the previous one so let's use the testable partial of the target tier { options: newTestablePartialOptions }, // ok so now for the final branch of the tier, if its a simple branch, and // a - has offers, get them // b - its a select actuon, leave this blank //BUT if it has nested tiers, we'd leave this blank and then we have to clone those tiers too, but thats for later // perhaps omit the ids? this.isOffersBranch(targetTier.data.targetNodeId!) ? cloneDeep(this.getBranchChildren(targetTier.data.targetNodeId!).map(offer => { // let's not delete the id of the original offer! offer = cloneDeep(offer) // remove offer id if (offer.id) { // @ts-ignore delete offer.id } return offer; })) : [], ) } addOffersBranch(id: string | undefined, sourceNodeId: string, offers: Omit[]) { id = id || `branch-${generate()}` this.state.renderedNodes.push({ id, type: 'branch-offers', data: { renderedNodeType: 'branch-offers', isInsideBranch: true, // this is VERY important! sourceNodeId, xSpacing: 18 * 4 // This is going to be calculated based on the width of the 'pass items' label IF the source node is a testable } as OffersBranchData & WithXSpacing, // position: {x: 240, y: 290}, position: {x: 0, y: 0}, }) this.addOffers(offers, id); this.setCouponsPlusNodeType(id, 'offers') } generateTierBranchIds(position: number, baseTestableBranchId?: string, nested: boolean = false): { baseTestableBranchId: string, testablePartialId: string, tierFinalBranchId: string } { if (!baseTestableBranchId) { baseTestableBranchId = `${this.createId('branch')}->children->${position}` } else if (baseTestableBranchId && nested) { baseTestableBranchId = `${baseTestableBranchId}->children->${position}` } const testablePartialId = `${baseTestableBranchId}->subchildren->${position}` const tierFinalBranchId = `${testablePartialId}->branch` return { baseTestableBranchId, testablePartialId, tierFinalBranchId, }; } private addStepAnchor(targetBranchId: string, options: Required> & Partial>) { let { id, sourceNodeId, targetNodeId, isFirst, type, label, direction, y } = this.getStepAnchorObjectData(targetBranchId, options); this.state.renderedNodes.push({ id, type: 'step-anchor', data: pickBy({ renderedNodeType: 'step-anchor', isFirst, sourceNodeId, targetNodeId, type, /** tier-row only (subchild) **/ label, direction, y, /*end*/ xSpacing: isFirst && type === 'tiered-base'? 8 * 4 : undefined } as StepAnchorData & WithXSpacing, (v) => v !== undefined), position: {x: 0, y: 0}, }) } private getStepAnchorObjectData(targetBranchId: string, options: Required> & Partial>) { const targetNode = this.getRenderedNode(targetBranchId); let sourceNodeId = targetNode?.data?.sourceNodeId let targetNodeId = targetBranchId let {isFirst, type} = options let label: string | undefined, direction: StepAnchorData['direction'] | undefined, y: StepAnchorData['y'] if (type === 'tier-row') { sourceNodeId = targetNode?.data.parentNodeId label = String.fromCharCode(96 + this.getBranchChildIndex(targetBranchId) + 1).toUpperCase(); direction = 'horizontal' y = 'middle' } if (!isFirst) { // the source is the anchor of the previous tier const currentTestablePartial = this.getRenderedNode(targetBranchId); const allTiers = this.getBranchChildren(currentTestablePartial?.data.parentNodeId || ''); const currentIndex = this.getBranchChildIndex(targetBranchId) const previousTier = allTiers[currentIndex - 1]; sourceNodeId = `${previousTier.id}.step-anchor` } if (this.isInsideBranch(targetBranchId)) { sourceNodeId = this.getRenderedNode(targetBranchId)?.data.parentNodeId } return { id: `${targetNodeId}.step-anchor`, sourceNodeId, targetNodeId, isFirst, type, label, direction, y }; } private createId(type: 'branch') { return `${type}-${generate()}`; } public connectRenderedNodes() { // check all the nodes that have a source/target and that don't have edges // only connect them if both source and target exist for (const renderedNode of this.state.renderedNodes) { if (renderedNode.type === 'step-anchor') { const edge = this.getEdgeById(renderedNode.id); if (edge) { continue; } // if its the base testable const isBase = renderedNode.data?.type === 'tiered-base' && renderedNode.data?.isFirst; if (isBase) { if (this.isStepAnchor(renderedNode.id) && this.isInsideBranch(renderedNode.data.targetNodeId!)) { // its nested so let's connect the step anchor of the base testable to the partial testable of the parent branch this.state.edges.push({ id: renderedNode.id, targetHandle: 'left', sourceHandle: 'bottom', source: renderedNode.data.sourceNodeId || '', target: `${renderedNode.data.targetNodeId}.step-anchor`, type: 'connecting-line', }) } else { // if it's the base testable, we need to add a left and right handle // the base needs to left and right handles and its target is the target testable this.state.edges.push({ id: renderedNode.id, targetHandle: 'left', sourceHandle: 'right', source: renderedNode.id, target: renderedNode.data.targetNodeId!, type: 'connecting-line', }) } } else { // this is a type: 'tier-row', if (renderedNode.data?.isFirst) { this.state.edges.push( { id: renderedNode.id, targetHandle: 'left', sourceHandle: 'right', source: `${renderedNode.data.sourceNodeId}`, target: `${renderedNode.data.targetNodeId}`, type: 'connecting-line', } ) } else { this.state.edges.push( { id: renderedNode.id, targetHandle: 'top', sourceHandle: 'bottom', source: renderedNode.data.sourceNodeId!, target: renderedNode.id, type: 'connecting-line', }, ) } } continue; } if (renderedNode.type === 'tiered-base-node') { if (this.getEdgeById(renderedNode.id)) { continue } if (this.isInsideBranch(renderedNode.id)) { //nested base testable, we need to connect it to its step anchor this.state.edges.push({ id: renderedNode.id, targetHandle: 'left', sourceHandle: 'right', source: `${renderedNode.id}.step-anchor`, target: renderedNode.id, type: 'connecting-line', }) } else { this.state.edges.push({ id: renderedNode.id, targetHandle: 'left', sourceHandle: 'right', source: renderedNode.id, target: `${this.getStepAnchorWithSourceId(renderedNode.id)?.data.targetNodeId}.step-anchor`, type: 'connecting-line', }) } continue } if (renderedNode.type === 'tiered-subchild-node' && !this.getEdgeById(renderedNode.id)) { const isFirstTier = this.isFirstTier(renderedNode.id) if (isFirstTier) { // the first tier dont need connection as its already redundant continue } this.state.edges.push({ id: renderedNode.id, targetHandle: 'left', sourceHandle: 'right', source: `${renderedNode.id}.step-anchor`, target: `${renderedNode.id}`, type: 'connecting-line', }) continue; } if (renderedNode.type === 'branch-add-node-button' && !this.getEdgeById(renderedNode.id)) { this.state.edges.push({ id: renderedNode.id, targetHandle: 'top', sourceHandle: 'bottom', source: renderedNode.data.lastAnchor, target: renderedNode.id, type: 'connecting-line', }) } if (renderedNode.data.isInsideBranch && !this.getEdgeById(renderedNode.id)) { // if it's inside a branch, we need to connect it to the parent branch this.state.edges.push({ id: renderedNode.id, targetHandle: 'left', sourceHandle: 'right', source: renderedNode.data.sourceNodeId!, target: renderedNode.id, type: 'connecting-line', }) continue; } const children = this.getBranchChildren(renderedNode.id); if (children.length > 0 && !this.getEdgeById(renderedNode.id)) { // if it has children, we need to connect it to the first child const firstChild = children[0]; // if the child is a tiered branch, the target shall be the step anchor this.state.edges.push({ id: renderedNode.id, source: renderedNode.id, target: firstChild.type === 'tiered-base-node'? `${firstChild.id}.step-anchor` : firstChild.id, type: 'connecting-line', }) continue; } if (renderedNode.data?.sourceNodeId || renderedNode.data?.targetNodeId) { let source = this.getRenderedNode(renderedNode.data?.sourceNodeId || ''); let target = this.getRenderedNode(renderedNode.data?.targetNodeId || ''); let sourceID: string, targetID: string; // has a source but not a target if (source && !target) { sourceID = source.id; targetID = renderedNode.id; } else if (!source && target) { // has a target but not a source sourceID = renderedNode.id; targetID = target.id; } else if (source && target) { // has both a source and a target sourceID = source?.id; targetID = target?.id; } else { // has neither a source nor a target continue; } if (sourceID && targetID) { const edge = this.state.edges.find(edge => edge.source === sourceID && edge.target === targetID); if (!edge) { this.state.edges.push({ id: sourceID, source: sourceID, target: targetID, type: 'connecting-line', }); } } } } } public isFirstTier(renderedNodeId: string) { return this.getBranchChildIndex(renderedNodeId) === 0; } private getEdgeById(renderedNodeId: string) { return this.state.edges.find(edge => edge.id === renderedNodeId); } getEdgeBySource(sourceId: string) { return this.state.edges.find(edge => edge.source === sourceId); } public getEdgesThatConnectTo(nodeId: string) { return this.state.edges.filter(edge => edge.source === nodeId || edge.target === nodeId); } private getBranchChildIndex(branchId: string): number { let node = this.getRenderedNode(branchId); if (typeof node === 'undefined') { return -1; } if (this.isTestablePartial(node.id)) { node = this.getRenderedNode(node.data.parentNodeId) } if (typeof node === 'undefined') { return -1; } const index = this.getBranchRelations(node.id)?.children.findIndex(child => { return child === branchId }); if (typeof index === 'undefined' || index === -1) { return -1; } return index; } getLastBranchChild(id: string): RenderedNode | undefined { return this.getBranchChildren(id).slice(-1)[0]; } private addChildrenRelations(parentId: string, childIDs: string[]) { if (this.isBaseTestable(parentId)) { //parentId = this.getRootBranchId(parentId) || parentId; } const branchRelations = this.getBranchRelations(parentId); if (branchRelations) { branchRelations.children.push(...childIDs); } else { this.state.branchRelations.push({ branch: parentId, children: childIDs, childrenType: 'renderedNodes' }) } } private getStepAnchorWithSourceId(id: string) { return this.state.renderedNodes.find(node => { return node.type === 'step-anchor' && node.data.sourceNodeId === id }); } isBaseTestable(branchId: string) { return this.getRenderedNode(branchId)?.type === 'tiered-base-node'; } isTestablePartial(branchOrId: string | RenderedNode) { let branch: string | RenderedNode | undefined if (typeof branchOrId === 'string') { branch = this.getRenderedNode(branchOrId) } else { branch = branchOrId } return branch?.type === 'tiered-subchild-node'; } private updateAddTierButton(baseTestableBranchId: string) { console.log('update button', this.isBaseTestable(baseTestableBranchId), baseTestableBranchId) if (!this.isBaseTestable(baseTestableBranchId)) { return; } // add the button if it doesnt exist let buttonNode = this.getAddTierButtonNode(baseTestableBranchId); if (!buttonNode) { buttonNode = { id: `${baseTestableBranchId}->subchildren->buttons.add`, type: 'branch-add-node-button', data: { renderedNodeType: 'branch-add-node-button', parentNodeId: baseTestableBranchId, lastAnchor: ''// IT'S BEING ADDED BELLOW! //'tiered-example->children->1->subchildren->4.step-anchor' }, position: {x: 0, y: 0}, } this.state.renderedNodes.push(buttonNode) } // let's make sure the last anchor is the same as the last tier const lastTier = this.getBranchChildren(baseTestableBranchId).slice(-1)[0] const lastTierAnchorId = `${lastTier?.id}.step-anchor` const prevLastAnchor = buttonNode.data.lastAnchor; if (prevLastAnchor !== lastTierAnchorId) { buttonNode.data.lastAnchor = lastTierAnchorId // update the edges to! const oldEdge = this.getEdgeBySource(prevLastAnchor); if (oldEdge) { oldEdge.source = lastTierAnchorId; } } } public getAddTierButtonNode(baseTestableBranchId: string) { return this.state.renderedNodes.find(node => { return this.isAddTierButton(node.id) && node.data?.parentNodeId === baseTestableBranchId }); } private isStepAnchor(id: string) { const node = this.getRenderedNode(id); return node?.type === 'step-anchor' } private setCouponsPlusNodeType(id: string, type: NodesStore['couponsPlusNodeTypes'][number]['branchType']) { let rootId: string if (this.isBaseTestable(id)) { rootId = this.getRootBranchId(id)!; } else { rootId = id.endsWith('->branch')? id.replace(/->branch$/, '') : id; } if (!rootId) { return; } const couponsPlusNodeType = this.state.couponsPlusNodeTypes.find(({id}) => id === rootId); if (couponsPlusNodeType) { couponsPlusNodeType.branchType = type; } else { this.state.couponsPlusNodeTypes.push({ id: rootId, branchType: type, }) } } private removeBranchRelations(id: string) { if (this.isTestablePartial(id)) { const branchRelationsIndex = this.state.branchRelations.findIndex( branch => branch.children.includes(id) ); const branchRelations = this.state.branchRelations[branchRelationsIndex] if (!branchRelations) { return; } if (branchRelations.children.length > 1) { // we have more than one subchild, so just remove the current one const childIndex = branchRelations.children.indexOf(id); if (childIndex !== -1) { branchRelations.children.splice(childIndex, 1); } } else { // only one subchild, remove the whole thing this.state.branchRelations.splice(branchRelationsIndex, 1); } } else { // its an offers brach const branchRelationsIndex = this.state.branchRelations.findIndex(branch => branch.branch === id); if (branchRelationsIndex !== -1) { this.state.branchRelations.splice(branchRelationsIndex, 1); } } } private isOffersBranch(id: string) { return this.getRenderedNode(id)?.type === 'branch-offers'; } private removeOffersBranch(id: string) { this.perform('removeOffersBranch', {data: {id}}) } private performRemoveOffersBranch({id}: {id: string}) { const branch = this.getRenderedNode(id); if (!branch) { return; } const offers = this.getBranchChildren(id); offers.forEach(offer => { this.removeOffer(offer.id); }) // remove the branch relations this.removeBranchRelations(id); // remove it from the coupons plus node types this.removeCouponsPlusNodeType(id); // maybe replace it with a select action branch IF the rendered node still exists this.afterPerformActionsQueue.push(() => { // to do (as said above) }) } public getRootNodes(): RenderedNode[] { return this.state.renderedNodes.filter(node => { return node.type === 'tree-node' && node.data?.renderedNodeType === 'root'; }) } /** * For Testable-Branch relations * * if the id is a testable, the return value would be the branch associated with it * if the if is the branch, the return value would be the testable */ public getRelatedNode(id: string) : RenderedNode | undefined { if (this.isTreeNode(id)) { // get the branch return this.getBranchChildren(id)[0]; } else { // its the branch so get the testable return this.getParentBranch(id); } } private isTreeNode(id: string) { const node = this.getRenderedNode(id); return node?.type === 'tree-node' } private removeCouponsPlusNodeType(id: string) { id = this.getCouponsPlusTypeId(id) const couponsPlusNodeTypeIndex = this.state.couponsPlusNodeTypes.findIndex(({id: couponsPlusId}) => couponsPlusId === id); if (couponsPlusNodeTypeIndex !== -1) { this.state.couponsPlusNodeTypes.splice(couponsPlusNodeTypeIndex, 1); } } private getCouponsPlusTypeId(id: string) : string { if (this.isInsideBranch(id) && this.isBranch(id)) { return id.replace(/->branch$/, ''); } if (this.isRootTreeNode(id)) { return id.replace(/->testable$/, ''); } return id; } public isInsideBranch(id: string) { return this.getRenderedNode(id)?.data?.isInsideBranch; } private isBranch(id: string) { return (['select-action-branch', 'branch-offers', 'tiered-base-node'] as (keyof typeof nodeTypes)[]).includes( this.getRenderedNode(id)?.type! ); } private isRootTreeNode(id: string) { return this.isTreeNode(id) && this.getRenderedNode(id)?.data?.renderedNodeType === 'root'; } static createFromState(state: TreeState & TestablesState) : Tree { return new Tree(state, new Testables(state)); } private isAddTierButton(renderedNodeId: string) : boolean { const node = this.getRenderedNode(renderedNodeId); if (!node) { return false; } return node.type === 'branch-add-node-button'; } public getTieredType(id: string) : TierType { return this.getParentBranch(id)?.data.type as TierType; } private updateTieredBranchRelationships(tieredBaseId: string) { if (!this.isBaseTestable(tieredBaseId)) { return } const tieredBaseNode = this.getRenderedNode(tieredBaseId); if (!tieredBaseNode) { return; } // so we have to update the tiered base data to point to the first available subchile const subChilds = this.getBranchChildren(tieredBaseId); const firstSubChild = subChilds[0]; if (!firstSubChild) { // there are no subchildren so we have to remove this branch { const stepAnchor = this.getStepAnchorForNode(subChild)! //testablePartialId, {isFirst: position === 1, type: 'tier-row'} const stepAnchorData = this.getStepAnchorObjectData( subChild.id, { isFirst: index === 0, type: 'tier-row' } ) for (const key in stepAnchorData) { if (key === 'id') { continue; } stepAnchor.data[key] = stepAnchorData[key]; } }) } private getStepAnchorForNode(subChild: RenderedNode) { return this.getRenderedNode(`${subChild.id}.step-anchor`); } } export type CreateTierBranchOptions = { // a select action branch or a testable branch for nested tier branches targetId: string, // this is the type of the value of the testables // numeric, path-fixed, path-variable, path-boolean // currently only numeric and path-fixed are supported type: TierType /*| 'path-variable' | 'path-boolean'*/, // we have to pass the entire object bc some types may have editable fields (eg: location, where to look? ip, billing address, etc, this can be configurable, only this, the actual values are configurable on each tier) baseTestable: Omit, // no base if bc we're going to use the one from the passed base testable testablePartial: Omit, // this one is optional, if not passed, a select action branch will be added offers?: Omit[], };