import {TestableNode, TestableNodeWithBranchAndFlow} from "./TestableNode"; import {BranchNode, StateBranchNode} from "./StateBranchNode"; import { AfterRemovingFromGroveHook, BeforeAddingToGroveHook, BeforeRemovingFromGroveHook, Grove, TreeNodeData, } from "./Grove"; import {FlowDataCreator, FlowDataWithoutPosition, Identifiable, RenderedNodeData} from "./FlowDataCreator"; import {generateIdForType} from "./NodeHelpers"; import {isUndefined, omitBy} from "lodash"; import mitt from "mitt"; import {ExportableToState} from "./ExportableToState"; import {MiddleRenderedNodeData, RendererNode, RendererNodeType} from "./RendererNodeData"; import {TreeNodeAnchor} from "./TreeNodeAnchor"; export interface BeforeAddingToTreeNodeHook { beforeAddingToTreeNode: () => BranchNode | undefined | void } export interface RemovedFromTreeNodeHook { beforeRemovingFromTreeNode: () => void } export interface AddedToTreeNodeHook { afterAddingToTreeNode: () => void } export enum TreeNodeEvent { BranchAdded = 'branch.added', } export type TreeNodeEventTypes = { [TreeNodeEvent.BranchAdded]: { // the id of the branch branch: typeof TreeNode.prototype.branchNode } } export class TreeNode implements BeforeAddingToGroveHook, BeforeRemovingFromGroveHook, AfterRemovingFromGroveHook, BeforeAddingToTreeNodeHook, FlowDataCreator, Identifiable, ExportableToState, RendererNode, MiddleRenderedNodeData, RendererNode { private emitter = mitt() // @ts-ignore branchNode: Branch & Partial & Partial & Partial & Partial & FlowDataCreator; public parentBranchNode?: StateBranchNode; public suggestedScope?: string private constructor( private _testableNode: TestableNodeWithBranchAndFlow & Partial & Partial, public readonly grove: Grove, // regular connects a normal testable composite node to a branch public readonly id: string = generateIdForType('tree.node')!, ) {} get testableNode() { return this._testableNode; } get sourceAnchor(): RendererNode | undefined { if (!this.isRoot()) { return } // @ts-ignore return new TreeNodeAnchor(this) } /** * Designed for creating a tree node instance for adding a tree node to branches or to the grove. * **DO NOT** use this for reading because it is not context-aware. it is not aware of the apps's state. * For runtime tree nodes, use Grove.get(id) */ static createTemporaryFromComponentsWithoutAncestors(testableNode: TestableNodeWithBranchAndFlow, branchNode: BranchNode, grove: Grove, id?: string, extraData?: any): TreeNode { // ok so the const treeNode = new TreeNode( testableNode, grove, id, ); testableNode.setBranch(branchNode); treeNode.setBranchNode(branchNode) if (extraData?.suggestedScope) { treeNode.suggestedScope = extraData.suggestedScope; } return treeNode; // first, build the testable node that holds the temp testable data in memory // this object is a nested representation of the testable data // ideally, this should be a 1:1 map of the server schema for the 'testableGroup' => [php] // this class (TreeNode) also implements the hooks (beforeMovingToGrove and afterMovingToGrove) so that we can call them on the testableNode and branchNode // ok this was extremely easy and ok // now, the testablenode and branchnode may not exist yet, they may only exits in memory but ot in thestate // so we going to have a hook api, its going to be optional, sometink like: // testableNode.beforeMovingToGrove() // for the branch, maybe: // branchNode.afterMovingToGrove() // so were goung to create a temprraryTestableNode that implements the TestableNode interface and that only has the testable data in memory // then it will implement beforeMovingToGrove() // when that is called, we'll add the testable data from memory t the state // then on the get testableNode() getter, we'll return the real testablenode from he state using the Testables clsss (Testables.getNode()) // essentially removing the temporary testableNode from memory // it's brilliant ig // also afterMovingToGrove() we'll add the rendered node for the testable to the state // and the edge that connects to its branch // now for the branch node, its going to be a similar approach. // for example, if an offers branch, we'll add the offers to the state when beforeMovingToGrove() is called // and when afterMovingToGrove is called, we'll add the rendered node to the state } setBranchNode(branchNode: BranchNode & Partial) { if (this.branchNode) { this.branchNode.beforeRemovingFromTreeNode?.() } // @ts-ignore this.branchNode = branchNode.beforeAddingToTreeNode?.() || branchNode; this.branchNode.setTreeNode(this); this.testableNode.setTreeNode(this); this.testableNode.setBranch(this.branchNode); this.branchNode.afterAddingToTreeNode?.() this.emitter.emit(TreeNodeEvent.BranchAdded, { branch: this.branchNode }) } setTestableNode(testableNode: TestableNodeWithBranchAndFlow) { this._testableNode = testableNode; this.testableNode.setTreeNode(this); this.testableNode.setBranch(this.branchNode); this.branchNode.setTestable(this.testableNode); } /** * Optional only for the nested branches (eg we need to set the parent branch if this is a tier) */ setParentBranchNode(parentBranchNode: BranchNode) { if (!this.parentBranchNode) { this.parentBranchNode = parentBranchNode; } } get type(): 'tiered' | 'regular' { return this.branchNode.isTiered() ? 'tiered' : 'regular'; } getId(): string { return this.id; } // will return the parent branch (a BranchNode not a TreeNode!) if nested, // undefined if it is a root node getParentBranch() { return this.parentBranchNode; } isRoot(): boolean { return !this.parentBranchNode; } hasParent() { return !!this.parentBranchNode; } beforeAddingToTreeNode() { // this is where we add the testable data to the state // and possibly the children if any // @ts-ignore this.testableNode.beforeAddingToTreeNode?.() // @ts-ignore this.branchNode.beforeAddingToTreeNode?.() } beforeAddingToGrove() { // this where the in-memory data may be added to the state this.testableNode.beforeAddingToGrove?.() this.branchNode.beforeAddingToGrove?.() } beforeRemovingFromGrove() { this.afterRemovingFromGrove() } afterRemovingFromGrove() { // remove the testable data from the state // and possibly the children if any this.testableNode.afterRemovingFromGrove?.() this.branchNode.afterRemovingFromGrove?.() } addSubscriber(event: TreeNodeEvent, callback: (data: TreeNodeEventTypes[typeof event]) => void) { this.emitter.on(event, callback); } exportToState(): TreeNodeData { return { id: this.id, testableId: this.testableNode.getId(), branch: (this.branchNode as StateBranchNode).exportToState(), extraData: { suggestedScope: this.suggestedScope, } }; } createFlowData(): FlowDataWithoutPosition { const testableFlowData = this.testableNode.createFlowData(); const branchFlowData = this.branchNode.createFlowData(); const tierFlowData: FlowDataWithoutPosition = { renderedNodes: this.isTier() ? [ /** * The step anchor if this tree node is a tier */ { id: this.id, // this is the id of the tree node that represents the step anchor type: 'branch.tiered.tier.step-anchor', data: {treeNodeId: this.id}, } as RenderedNodeData ] : [], edges: this.getTierEdges() } let rootStepAnchorFlowData = this.getRootStepAnchorFlowData(); return { renderedNodes: [ ...rootStepAnchorFlowData.renderedNodes, ...tierFlowData.renderedNodes, ...testableFlowData.renderedNodes, ...branchFlowData.renderedNodes, ].filter(v => v !== undefined), edges: [ ...rootStepAnchorFlowData.edges, this.getTestableToBranchEdge(), ...branchFlowData.edges, ...tierFlowData.edges, ...testableFlowData.edges, ...this.getEdgesForChildrenTiers() ].filter(v => v !== undefined), }; } createRenderedNodeWithData(renderedNodeData: Omit): Omit { return { ...renderedNodeData, data: {treeNodeId: this.id}, } } leftSpacing(): number { if (this.isTier() && this.isFirstTier()) { return 16 } return 0 } topSpacing(): number { if (this.isTier()) { return 12 } return 0 } /** * True if this is the child of a tiered branch. * NOT to be confused with isTiered(), 'tiered' refers to a branch, * 'tier' refers to the child of a tiered branch, */ isTier(): boolean { return !!this.parentBranchNode?.isTiered?.() } isFirstTier(): boolean { return this.parentBranchNode?.getChildIndex(this.getId()) === 0 } /** * undefined if this is no a tier */ getTierPosition(): number | undefined { const tierIndex = this.getTierIndex() if (typeof tierIndex === 'undefined') { return undefined } return tierIndex + 1 } private getTierIndex() { if (!this.isTier()) { return undefined } return this.parentBranchNode!.getChildIndex(this.id) } private getPreviousTreeNode(): TreeNode | undefined { if (!this.isTier()) { return undefined } return this.parentBranchNode?.getChildAtIndex( this.getTierIndex()! - 1, (treeNodeData) => this.grove.get((treeNodeData as TreeNodeData).id) ) } private getTestableToBranchEdge() { let handles = {} if (!this.isRoot() && this.branchNode.isTiered()) { handles = { // hide the from root testable to first tiered branch sourceHandle: 'bottom', targetHandle: 'left', } } else if (this.isTier()) { handles = { sourceHandle: 'right', targetHandle: 'left', } } else { handles = { /*sourceHandle: 'bottom', targetHandle: 'top',*/ } } return omitBy({ id: this.testableNode.getId(), ...handles, source: this.testableNode.getId(), target: this.testableNode.getTargetRenderNodeId(), type: 'connecting-line', }, isUndefined); } private getTierEdges(): FlowDataWithoutPosition['edges'] { if (!this.isTier()) { return [] } let previousNode: TreeNode | undefined return [ // connect the previous step anchor to the this step anchor if this is not the first tier this.getTierPosition()! > 1 ? { id: `${(previousNode = this.getPreviousTreeNode()!).id}->${this.id}`, targetHandle: 'top', sourceHandle: 'bottom', source: previousNode.id, target: this.id, type: 'connecting-line', } : undefined, // the step anchor to the tier testable { id: this.id, targetHandle: 'left', sourceHandle: 'right', source: this.id, target: this.testableNode.getId(), type: 'connecting-line', }, // the tier testable to branch is in method getTestableToBranchEdge() // why? because that method works for non-tiers too ].filter(v => v !== undefined) } private getEdgesForChildrenTiers(): FlowDataWithoutPosition['edges'] { if (this.branchNode.isTiered()) { return this.branchNode.getChildren(({id}) => this.grove.get(id)).map((treeNodeTier: TreeNode) => treeNodeTier.createFlowData().edges).flat() } return [] } getRoot(): TreeNode { if (this.parentBranchNode) { return this.parentBranchNode.getTreeNode().getRoot() } return this; } getSourceRenderNode(): RendererNodeType | undefined { /** * This is for when this is a TIER inside a TIERED BRANCH */ if (this.isTier()) { // so this is a step anchor, so its source is the base of the parent branch if (this.isFirstTier()) { return this.parentBranchNode!.testableBaseNode } else { // this is not the first tier, so its source is the previous step anchor return this.getPreviousTreeNode() } } else if (this.isRoot()) { // @ts-ignore return this.sourceAnchor } } getSourceRenderNodeId(): string { return this.getSourceRenderNode()?.getId() || '' } getTargetRenderNode(): RendererNodeType | undefined { /** * Again, this is for when this is a TIER inside a TIERED BRANCH */ // so this is a step anchor, so its target is the testable of this tree node which is a testable partial if (this.isTier()) { return this.testableNode } } getTargetRenderNodeId(): string { return this.getTargetRenderNode()?.getId() || '' } isFirstRoot() { return this.grove.isFirst(this.getId()) } private getRootStepAnchorFlowData(): FlowDataWithoutPosition { let renderedNodes: FlowDataWithoutPosition['renderedNodes'] = [] let edges: FlowDataWithoutPosition['edges'] = [] if (this.isRoot()) { renderedNodes = [ { id: this.id, type: 'tree.root.anchor', data: {treeNodeId: this.id}, } ] edges = [ { id: `root->${this.testableNode.getId()}`, source: this.id, target: this.testableNode.getId(), }, // if this anit the first one, we also want to connect it to the previous root !this.isFirstRoot() ? { id: `previous.root->${this.id}`, source: this.grove.getPreviousTreeNode(this.id)!.id, target: this.id, } : undefined ].filter(v => v !== undefined); } return { renderedNodes, edges } } }