import {useShallow} from "zustand/react/shallow"; import { ComponentDataType, findDataForNodeWithoutChildren, NodeData, NodesStore, TestableCompositeData, TestableCompositeDataWithChildren, TestableData, TestablePartial, useNodesStore } from "./store"; import {__} from "../globals"; import {generate} from "shortid"; import {cloneDeep, isUndefined, merge, omit, pickBy} from "lodash"; import {ComponentRuntimeDataWithParentType} from "./components"; import {NestedTestableNodeData} from "./InMemoryTestableNode"; import mitt from "mitt"; import {useLazyTestables} from "./hooks"; import {ComponentType, getComponentMeta, getComponentMetaData} from "./ComponentsMeta"; import {exporterImporters} from "./export/CustomImportersAndExporters"; import { isNonContextTestableGroupType, normalizeTestableGroupMode, TestableGroupTypeId } from "./testableGroupTypes"; export const useChildrenIds: (parentId: TestableCompositeData['id'] | TestableCompositeDataWithChildren) => (TestableCompositeData['id'] | TestableCompositeData)[] = parentID => { return useNodesStore( useShallow(store => { if (typeof parentID === 'object') { return parentID.children || [] } return store.testableRelations.find(node => node.id === parentID)?.children }) ) || [] } export const useTestableTypeFromContext: (contextID: TestableCompositeData['id'] | TestableCompositeDataWithChildren) => Exclude = (contextID) => { // get the first child and return its component type const children = useChildrenIds(contextID) const testables = useLazyTestables() const firstChild = children[0] if (!firstChild) { return 'filter' } let firstChildData: TestableData if (typeof firstChild === 'object') { firstChildData = (firstChild as unknown as TestableData) } else { firstChildData = testables.getNode(firstChild as string)! } return firstChildData?.testableType || 'filter' } export const useParentId: (id: TestableCompositeData['id']) => TestableCompositeData['id'] | undefined = ID => { return useNodesStore( useShallow( store => store.testableRelations.find(node => node.children.includes(ID)) ) )?.id } export const useNodeData: (id: TestableCompositeData['id'] | undefined) => NodeData | undefined = id => useNodesStore(useShallow(findDataForNodeWithoutChildren(id))) export const testableTypeLabel: (type: TestableCompositeData['type']) => string = type => { switch (normalizeTestableGroupMode(type)) { case TestableGroupTypeId.AND: return '+' case TestableGroupTypeId.OR: return __('AND/OR') case TestableGroupTypeId.OR_EXCLUSIVE: return __('OR') } } export function getIdWithoutLastSuffix(id: string) { const suffixPattern = /->\w+$/; if (suffixPattern.test(id)) { if (/->children->\d+$/.test(id)) { return id.replace(/->children->\d+$/, ''); } return id.replace(suffixPattern, ''); } return id; } export function getIdWithoutAnySuffix(id: string) { const suffixPattern = /(->\w+)*$/; if (suffixPattern.test(id)) { if (/->children->\d+$/.test(id)) { return id.replace(/->children->\d+$/, ''); } return id.replace(suffixPattern, ''); } return id; } export function getIdSuffixed(id: string, suffixToHave: 'branch' | 'testable') { if (id.endsWith(`->${suffixToHave}`)) { return id; } const suffixPattern = /->\w+$/; if (suffixPattern.test(id)) { return id.replace(suffixPattern, `->${suffixToHave}`); } return `${id}->${suffixToHave}`; } type TestableCompositeWithChildren = { testableComposite: TestableCompositeData, children: { testableComposite: TestableCompositeData, children: (TestableData | TestableCompositeWithChildren)[] }[] }; export enum TestableFormat { /** * IMPORTANT: DO NOT USE NUMBERS HERE BECAUSE THE OTHER OPTION MAY EXPECT A NUMBER (ID) */ Flat = '__FLAT__', Nested = '__NESTED__', } interface PerformAddTestablesData { // id is optional testables: ((Partial> & Omit)[]) | NestedTestableNodeData[], contextOrTestableGroupIDOrFormat: string | undefined | TestableFormat, wrapInGroup: boolean, options: { keepOriginalModes: boolean } } export type TestablesState = Pick; export enum TestableEvent { RemovedRoot = 'testable.removedRoot', } type TestableEventTypes = { [TestableEvent.RemovedRoot]: { id: string } } export class Testables { private emitter = mitt(); protected afterPerformActionsQueue: Function[] = [] private isInsideATransaction: boolean = false constructor( private store: TestablesState ) { } get testables() { return this.store.testables } get testableRelations() { return this.store.testableRelations } get partials() { return this.store.testablePartials } getNode(id: string): TypeOfNode | undefined { if (this.isPartial(id)) { return this.store.testablePartials.find(node => node.id === id) as TypeOfNode; } return this.store.testables.find(node => node.id === id) as TypeOfNode; } /** * Returns copies of the nodes */ getNodeWithChildren(id: string) { const node = this.getNode(id) const nodeRelations = this.store.testableRelations.find(node => node.id === id); if (!node) { return; } if (!nodeRelations) { const isCompositeNode = typeof (node as TestableCompositeData).mode === 'string' && ( isNonContextTestableGroupType((node as TestableCompositeData).type) || (node as TestableCompositeData).type === TestableGroupTypeId.CONTEXT ) if (isCompositeNode) { return cloneDeep({ ...node, children: [] } as TestableCompositeDataWithChildren) } return cloneDeep(node) } if (nodeRelations) { // @ts-ignore return cloneDeep({ ...node, children: nodeRelations.children.map(this.getNodeWithChildren.bind(this)).filter(Boolean) as TestableCompositeDataWithChildren['children'] }) } } getChildren(id: string): NodeData[] { const children = this.store.testableRelations.find(node => node.id === id)?.children || []; return children.map(this.getNode.bind(this)).filter(Boolean) as NodeData[]; } getParent(id: string): NodeData | undefined { const parentId = this.store.testableRelations.find(node => node.children.includes(id))?.id!; if (parentId) { return this.getNode(parentId); } } hasParent(id: string): boolean { return !!this.getParent(id) } getWithParentType(testable: TestableData, parentType?: ComponentRuntimeDataWithParentType['parentType']): ComponentRuntimeDataWithParentType { return { ...testable, parentType: `${testable.testableType || parentType}s`, } } /** * Default groups are nodes that: * - are testable groups * - have 1 child * @param id */ isDefaultGroup(id: string): any { return this.isTestableCompositeGroup(id) && this.getChildren(id).length === 1; } isTestableCompositeGroup(id: string): boolean { const node = this.getNode(id) // @ts-ignore return typeof node?.mode == 'string' && isNonContextTestableGroupType(node?.type); } isContext(id: string): boolean { const node = this.getNode(id) // @ts-ignore return typeof node?.mode == 'string' && node?.type === TestableGroupTypeId.CONTEXT; } isTestable(id: string): boolean { const node = this.getNode(id) // @ts-ignore return typeof node?.mode == 'undefined' && node?.options } /** * If id is a context, they will be added to that context * if it's a testable group, a new context will be created and added to that group * * if no id is passed, a new group will be created in the root, as a totally new tree! * @param testables * @param contextOrTestableGroupID */ addTestables(testables: PerformAddTestablesData['testables'], contextOrTestableGroupIDOrFormat?: PerformAddTestablesData['contextOrTestableGroupIDOrFormat'], wrapInGroup: PerformAddTestablesData['wrapInGroup'] = true, options: PerformAddTestablesData['options'] = {keepOriginalModes: false}) { return this.perform( 'addTestables', {data: {testables, contextOrTestableGroupIDOrFormat, wrapInGroup, options}} ) } addTestablePartials(testablePartials: (TestablePartial | Omit)[], testableId: string): TestablePartial['id'][] { // dont if the parent is not a testable const testable = this.getNode(testableId) if (!testable || !this.isTestable(testableId)) { return [] } const testablePartialsWithId: TestablePartial[] = testablePartials.map(testablePartial => ({ id: `testable-partial-${generate()}`, ...testablePartial, })).filter(testablePartial => { // only add the testable partial if it doesn't exist in the store return !this.getNode(testablePartial.id) }) this.store.testablePartials.push(...testablePartialsWithId) // now let's add the relations this.addChildrenRelations(testablePartialsWithId.map(testablePartial => testablePartial.id), testableId) return testablePartialsWithId.map(testablePartial => testablePartial.id); } /** * If you're adding new testables, a context id is needed * By default, if you pass a context id, you have to pass all testables otherwise it will remove the testables that are not in the new array * you can set removeInexistentTestables to false to prevent that */ updateTestables(testables: TestableData[], contextID?: string, removeInexistentTestables: boolean = true) { // because we might be removing all testables and adding new ones to the given context, we have to do this inside a transaction this.transaction(() => { const canRemove = this.isContext(contextID!) && removeInexistentTestables; if (canRemove) { const updatedTestableIds = testables.map(testable => testable.id) // first remove the testables that are not in the new array const removedTestables = this.getChildren(contextID!).filter(testable => !updatedTestableIds.includes(testable.id)) removedTestables.forEach(testable => { this.removeIDS([testable.id]) }) } testables.forEach(testable => { const testableIndex = this.store.testables.findIndex(node => node.id === testable.id) if (testableIndex === -1) { // it's a new testable // @ts-ignore if (this.isContext(contextID)) { this.addTestables([testable], contextID) } } else { this.updateTestable(testable) } }) }) } updatePartial(testablePartial: TestablePartial) { // because we might be removing all testables and adding new ones to the given context, we have to do this inside a transaction this.transaction(() => { const testableIndex = this.store.testablePartials.findIndex(node => node.id === testablePartial.id) if (testableIndex === -1) { // it's a new testable partial // @ts-ignore this.store.testablePartials.push(testablePartial) return } this.store.testablePartials[testableIndex] = testablePartial }) } updateTestable(testable: TestableData) { // because we might be removing all testables and adding new ones to the given context, we have to do this inside a transaction this.transaction(() => { const testableIndex = this.store.testables.findIndex(node => node.id === testable.id) if (testableIndex === -1) { // it's a new testable partial // @ts-ignore this.store.testables.push(testable) } else { this.store.testables[testableIndex] = testable } }) } wrapAllContexts(testableGroupID: string) { const testableGroupWithChildrenIndex = this.store.testableRelations.findIndex(testable => testable.id === testableGroupID)! const testableGroupWithChildren = this.store.testableRelations[testableGroupWithChildrenIndex] testableGroupWithChildren.children = testableGroupWithChildren.children.map(contextID => { const parentGroupForThisContext = { id: `group-${generate()}`, type: TestableGroupTypeId.AND, mode: 'filter' } as TestableCompositeData const contextIndex = this.store.testables.findIndex(testable => testable.id === contextID)! this.store.testables.splice(contextIndex, 0, parentGroupForThisContext) this.store.testableRelations.splice(testableGroupWithChildrenIndex, 0, { id: parentGroupForThisContext.id, children: [contextID] }) return parentGroupForThisContext.id; }) } /** * Can be a testable composite (group or context) or a testable * * if it's a testable, it will return the parent context */ getRelationsForNode(id: string): NodesStore['testableRelations'][number] | undefined { const testableCompositeRelations = this.store.testableRelations.find(node => node.id === id) if (testableCompositeRelations) { // the id is for a testable composite (group or context) return testableCompositeRelations } // the id is for a testable so let's find its parent context return this.store.testableRelations.find(node => node.children.includes(id)) } createContext(testables: TestableData[], testableGroupID: string, mode: TestableCompositeData['mode'] = 'filter') { // lets add the context to the testables array const context = { id: `context-${generate()}`, type: TestableGroupTypeId.CONTEXT, mode } as TestableCompositeData // lets add the context to the testable group relations this.store.testables.push(context) // lets add the context to the relations array this.addChildrenRelations([], context.id) this.addChildrenRelations([context.id], testableGroupID) this.addTestables(testables, context.id) } createGroup( parentGroupID?: string, type: TestableCompositeData['type'] = TestableGroupTypeId.AND, mode: TestableCompositeData['mode'] = 'filter', children: TestableCompositeData['id'][] = [] ) { const group = { id: `group-${generate()}`, type, mode } as TestableCompositeData this.store.testables.push(group) this.addChildrenRelations(children, group.id) if (parentGroupID) { this.addChildrenRelations([group.id], parentGroupID) } return group } wrapChildrenInNewGroup(testableGroupID: string, type?: TestableCompositeData['type']) { const targetGroup = this.getNode(testableGroupID) as TestableCompositeData | undefined const targetGroupRelations = this.getRelationsForNode(testableGroupID) if (!targetGroup || !targetGroupRelations) { throw new Error(`Could not wrap children for testable group "${testableGroupID}".`) } const wrappedGroup = this.createGroup(undefined, type || targetGroup.type, targetGroup.mode, [ ...targetGroupRelations.children, ]) targetGroupRelations.children = [wrappedGroup.id] return wrappedGroup } getNodeIds = (testables: NodeData[]) => { return testables.map(testable => testable.id); } addChildrenRelations(children: TestableCompositeData['id'][], parentID: TestableCompositeData['id']) { const parentRelations = this.getRelationsForNode(parentID) if (typeof parentRelations !== 'undefined') { parentRelations.children.push(...children) } else { // it doesn't exist, so we need to create it this.store.testableRelations.push({ id: parentID, children }) } } changeTestableGroupOptions(testableCompositeGroupId: string, options: Partial>) { const testableCompositeNode = this.getNode(testableCompositeGroupId) as TestableCompositeData for (let option in omit(options, 'id')) { // @ts-ignore testableCompositeNode[option] = options[option] } } /** * Triggers: * - TestableEvent.RemovedRoot */ remove(testableCompositeOrContextID: string) { this.perform('remove', {data: testableCompositeOrContextID}) } perform(action: 'remove' | 'addTestables', {data}: { data: Data }): Return { const result = this[`perform${action.charAt(0).toUpperCase() + action.slice(1)}`](data) this.maybeCallActions(); return result } /** * 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 callable() this.isInsideATransaction = false this.maybeCallActions() } private maybeCallActions = () => { if (this.isInsideATransaction) { return } for (const [index, action] of this.afterPerformActionsQueue.entries()) { action() } this.afterPerformActionsQueue = [] } /** * Returns the ids of the added testables, useful if you passed testables to add without ids */ performAddTestables({ testables, contextOrTestableGroupIDOrFormat, wrapInGroup, options }: PerformAddTestablesData): TestableData['id'][] { const {keepOriginalModes} = options if (!testables || testables.length < 1) { return [] } //let's add ids if they don't have em testables.forEach(testable => { if (typeof testable.id === 'undefined') { testable.id = `testable-${generate()}` } }) // because typescript wont stop yelling smh const testablesWithIDs: TestableData[] = testables as TestableData[] const testableType: TestableData['testableType'] = testablesWithIDs[0].testableType || 'filter' const firstTestableId = testablesWithIDs[0].id if (contextOrTestableGroupIDOrFormat === TestableFormat.Nested) { wrapInGroup = false } testablesWithIDs.forEach(testable => { const testableIndex = this.store.testables.findIndex(node => node.id === testable.id) const testableWithoutChildren = pickBy({ ...testable, children: undefined } as TestableData, v => !isUndefined(v)) const customImportedData = exporterImporters[(testableWithoutChildren as TestableData).type]?.importData?.( cloneDeep(testableWithoutChildren as TestableData), {testables: this} ) const testableToStore = this.mergeWithDefaultCardData( (customImportedData || testableWithoutChildren) as TestableData ) if (testableIndex === -1) { // @ts-ignore this.store.testables.push(testableToStore) } else { this.store.testables[testableIndex] = testableToStore } }) if (!contextOrTestableGroupIDOrFormat && wrapInGroup) { const mode = this.getModeFromTestableType(testableType) // no context or testable group id passed, so let's create a new group const newGroup = { id: `group-${generate()}`, type: TestableGroupTypeId.AND, mode } as TestableCompositeData // we should also create a context, i bet this hasn't been tested has it // so lets create a new context const newContext = { id: `context-${generate()}`, type: TestableGroupTypeId.CONTEXT, mode } this.store.testables.push(newGroup, newContext) // lets add the context and group to the testable group relations this.store.testableRelations.push({ id: newGroup.id, children: [newContext.id] }) this.store.testableRelations.push({ id: newContext.id, children: this.getNodeIds(testablesWithIDs) }) } else if (this.isContext(contextOrTestableGroupIDOrFormat || '') && wrapInGroup) { this.addChildrenRelations(this.getNodeIds(testablesWithIDs), contextOrTestableGroupIDOrFormat!) } else if (wrapInGroup) { // this is a testable group // so let's create a new context this.createContext(testablesWithIDs, contextOrTestableGroupIDOrFormat!) } if (contextOrTestableGroupIDOrFormat === TestableFormat.Nested) { // let's check if children and if so, add them to the state and the relations testables.forEach(testable => { if ('mode' in testable) { this.addChildrenRelations([], testable.id) } if (testable.children) { // use performAddTestables to add the children // if the children are another group, then add them using the nested format // but if the chidlrwen are testables, then add them using the normal format and pass the parent id testable.children.forEach(child => { if ('children' in child) { // This is another nestable group (context or composite), use nested format const childIds = this.performAddTestables({ testables: [child], contextOrTestableGroupIDOrFormat: TestableFormat.Nested, wrapInGroup: false, options }); // Now establish the parent-child relationship if (childIds.length > 0) { this.addChildrenRelations([childIds[0]], testable.id); } } else { // This is a plain testable, add it with the parent id this.performAddTestables({ testables: [child], contextOrTestableGroupIDOrFormat: testable.id, wrapInGroup: true, options }); } }); } }) } this.afterPerformActionsQueue.push(() => { if (!keepOriginalModes) { this.ensureCorrectModeForTestableAncestors(firstTestableId) } }) return testablesWithIDs.map(testable => testable.id); } performRemove(testableCompositeOrContextID: string) { if (this.isTestable(testableCompositeOrContextID)) { this.performRemoveTestable(testableCompositeOrContextID); } else if (this.isContext(testableCompositeOrContextID) || this.isTestableCompositeGroup(testableCompositeOrContextID)) { this.performRemoveTestableComposite(testableCompositeOrContextID); } // first lets remove the child testables //const children = this.getChildren(testableCompositeOrContextID) return () => { // also remove the testable composite group if it has no children }; } private performRemoveTestable(testableID: string) { const parentContext = this.getParent(testableID) as TestableCompositeData this.removeIDS([testableID]) // maybe the parent has already been removed if (!parentContext) { return } // cleanup function this.afterPerformActionsQueue.push(() => { // removing if no children this.removeIfNoChildren(parentContext.id) }) } /** * Could be a testable group or a context */ private performRemoveTestableComposite(testableCompositeID: string) { const parentGroupId = this.getParent(testableCompositeID)?.id const isRoot = !parentGroupId const children = this.getChildren(testableCompositeID) this.performRemoveTestable(testableCompositeID) // for the children, remove them using the api in case they are testable composites so that it can be recursive children.forEach(child => { this.remove(child.id) }) this.afterPerformActionsQueue.push(() => { if (isRoot) { this.emitter.emit(TestableEvent.RemovedRoot, { id: testableCompositeID }) } }) } private removeIDFromTestables = (contextID: string) => { let storage: (NodeData[] | TestablePartial[]) if (this.isPartial(contextID)) { storage = this.store.testablePartials } else { storage = this.store.testables } const index = storage.findIndex(node => node.id === contextID) if (index !== -1) { storage.splice(index, 1) } } private removeIDfromRelations = (id: string) => { // here remove it from the relations array if it exists const index = this.store.testableRelations.findIndex(node => node.id === id) if (index !== -1) { this.store.testableRelations.splice(index, 1) } // then loop through chidlren and remove it from children too if it exists this.store.testableRelations.forEach(node => { const childIndex = node.children.indexOf(id) if (childIndex !== -1) { node.children.splice(childIndex, 1) } }) } private removeIfNoChildren = (id: string): Function | undefined => { // maybe it has already been removed if (!this.getNode(id)) { return; } if (this.isTestable(id)) { return } if (this.getChildren(id).length < 1) { this.remove(id) return } if (this.isTestableCompositeGroup(id) && !this.isContext(id)) { this.collapseRedundantSingleChildWrappers(id) } } private collapseRedundantSingleChildWrappers(groupId: string) { if (!this.getNode(groupId) || !this.isTestableCompositeGroup(groupId) || this.isContext(groupId)) { return } let group = this.getNode(groupId) as TestableCompositeData | undefined let groupRelations = this.getRelationsForNode(groupId) if (!group || !groupRelations) { return } let onlyChild = this.getChildren(groupId)[0] while (groupRelations.children.length === 1 && onlyChild && this.isTestableCompositeGroup(onlyChild.id) && !this.isContext(onlyChild.id)) { const childGroup = this.getNode(onlyChild.id) as TestableCompositeData | undefined const childGroupRelations = this.getRelationsForNode(onlyChild.id) if (!childGroup || !childGroupRelations) { break } group.type = childGroup.type group.mode = childGroup.mode groupRelations.children = [...childGroupRelations.children] this.removeIDS([onlyChild.id]) group = this.getNode(groupId) as TestableCompositeData | undefined groupRelations = this.getRelationsForNode(groupId) if (!group || !groupRelations) { return } onlyChild = this.getChildren(groupId)[0] } onlyChild = this.getChildren(groupId)[0] if (group && onlyChild && this.isContext(onlyChild.id)) { group.type = TestableGroupTypeId.AND group.mode = (onlyChild as TestableCompositeData).mode } const parentGroup = this.getParent(groupId) if (parentGroup && this.isTestableCompositeGroup(parentGroup.id) && !this.isContext(parentGroup.id)) { this.collapseRedundantSingleChildWrappers(parentGroup.id) } } private removeIDS(nodeIds: string[]) { nodeIds.forEach(id => { // first remove it from the testables array this.removeIDFromTestables(id) // then remove it from the relations array this.removeIDfromRelations(id) }) } private ensureCorrectModeForTestableAncestors(testableId: string) { // know what? i rather do the ancestry // check the whole ancestry and if js a single condition, then change mode for all groups to test // there's probably a more performant way to do this, but this is easier to understand so this will have to do for now if (this.thereIsAConditionInTheTree(testableId)) { this.changeModeOfAllTestableCompositeDescendants(this.getRootNode(testableId).id, 'test') } else { // there's not a single condition in the tree, so we can change all modes to filter UNLESS this.changeModeOfAllTestableCompositeDescendants(this.getRootNode(testableId).id, 'filter') } } /** * This is the farthest ancestor and it 100% is a TestableComposite because testables cannot have children */ getRootNode(id: string): TestableCompositeData | undefined { const node = this.getNode(id) as TestableCompositeData if (!node) { return } const parent = this.getParent(node.id) if (parent) { return this.getRootNode(parent.id) } return node } private thereIsAConditionInTheTree(id: string): boolean { // get the root const root = this.getRootNode(id) return this.theresAConditionInDescendants(root.id) } private theresAConditionInDescendants(id): boolean { if (this.isTestable(id)) { return (this.getNode(id) as TestableData).testableType === 'condition' } return !!this.getChildren(id).find(child => { return this.theresAConditionInDescendants(child.id) }) } private changeModeOfAllTestableCompositeDescendants(id: string, mode: TestableCompositeData['mode']) { if (this.isTestable(id)) { return } if (!this.modeIs(id, mode)) { this.changeTestableGroupOptions(id, {mode}) } // then loop through children and change their mode too this.getChildren(id).forEach(child => { this.changeModeOfAllTestableCompositeDescendants(child.id, mode) }) } private modeIs(id: string, mode: TestableCompositeData['mode']): boolean { const node = this.getNode(id) as TestableCompositeData return node?.mode === mode } private getModeFromTestableType(testableType: TestableData['testableType']): TestableCompositeData['mode'] { if (testableType === 'condition') { return 'test' } return 'filter' } public isPartial(id: string) { // we're going very simple, with the id prefix, this could be brittle so ots something to keep in mind return id?.startsWith?.('testable-partial-') } addSubscriber(event: TestableEvent, subscriber: (data: TestableEventTypes[typeof event]) => void) { this.emitter.on(event, subscriber) } getPartialRelations() { return this.testableRelations.filter(relation => relation.children.some(childId => this.isPartial(childId))) } mergeWithDefaultCardData(testableWithoutChildren: object) { if ('parentType' in testableWithoutChildren) { const runtimeData = testableWithoutChildren as { parentType: string, type: string } const componentMeta = getComponentMeta(runtimeData.parentType as ComponentType, runtimeData.type) if (!componentMeta) { return testableWithoutChildren } // its a filter or condition so lets get the default data const cardData = getComponentMetaData(componentMeta) testableWithoutChildren = merge({options: cloneDeep(cardData.defaultOptions)}, testableWithoutChildren) } return testableWithoutChildren } }