Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Min/max count: use expressions #229

Merged
merged 14 commits into from
Jan 15, 2025
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@
"build": "tsc",
"lint:fix": "npm run lint -- --fix",
"lint": "eslint 'src/**/*.{ts,tsx}'",
"prepare": "yarn build && husky install",
"prepare": "yarn build && husky",
"test": "jest"
},
"files": [
Expand Down
2 changes: 2 additions & 0 deletions src/node/node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ export enum NodeMetaKeys {

export interface NodeMeta {
childApplicability?: { [uuid: string]: boolean }
childrenMaxCount?: { [uuid: string]: number }
childrenMinCount?: { [uuid: string]: number }
h?: string[]
hCode?: string[]
defaultValueApplied?: boolean
Expand Down
91 changes: 79 additions & 12 deletions src/node/nodes.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,47 @@
import { NodeDef, NodeDefCountType, NodeDefs } from '../nodeDef'
import { Dates, Objects } from '../utils'
import { Node } from './node'

const isRoot = (node: Node): boolean => !node.parentUuid

const areEqual = (nodeA: Node, nodeB: Node): boolean => nodeA.uuid === nodeB.uuid
const isChildApplicable = (node: Node, nodeDefUuid: string) => {

const isChildApplicable = (node: Node, nodeDefUuid: string): boolean => {
// if child applicability is not defined for a node definition, consider it applicable
return node.meta?.childApplicability?.[nodeDefUuid] !== false
}
const assocChildApplicability = (node: Node, nodeDefUuid: string, applicable: boolean) => {
const getChildrenCount = (params: { parentNode: Node; nodeDef: NodeDef<any>; countType: NodeDefCountType }): number => {
const { parentNode, nodeDef, countType } = params
const countIndex =
countType === NodeDefCountType.max ? parentNode.meta?.childrenMaxCount : parentNode.meta?.childrenMinCount

const count = countIndex?.[nodeDef.uuid]
if (!Objects.isEmpty(count)) return count!

// count can be a constant value, specified in the node def min/max count prop
const nodeDefCount =
countType === NodeDefCountType.max ? NodeDefs.getMaxCount(nodeDef) : NodeDefs.getMinCount(nodeDef)
return nodeDefCount ? Number(nodeDefCount) : NaN
}

const getChildrenMaxCount = (params: { parentNode: Node; nodeDef: NodeDef<any> }): number =>
getChildrenCount({ ...params, countType: NodeDefCountType.max })

const getChildrenMinCount = (params: { parentNode: Node; nodeDef: NodeDef<any> }): number =>
getChildrenCount({ ...params, countType: NodeDefCountType.min })

const getHierarchy = (node: Node): string[] => [...(node.meta?.h ?? [])]

const getHierarchyCode = (node: Node): string[] => [...(node.meta?.hCode ?? [])]

const mergeNodes = (target: Node, ...sources: Node[] | object[]): Node =>
Objects.deepMerge(target, ...sources) as unknown as Node

const isDefaultValueApplied = (node: Node): boolean => node?.meta?.defaultValueApplied ?? false

const isValueBlank = (node: Node): boolean => Objects.isEmpty(node.value)

const assocChildApplicability = (node: Node, nodeDefUuid: string, applicable: boolean): Node => {
const childApplicability = { ...(node.meta?.childApplicability ?? {}) }
if (!applicable) {
childApplicability[nodeDefUuid] = applicable
Expand All @@ -21,6 +55,7 @@ const assocChildApplicability = (node: Node, nodeDefUuid: string, applicable: bo
dateModified: Dates.nowFormattedForStorage(),
}
}

const dissocChildApplicability = (node: Node, nodeDefUuid: string) => {
const childApplicability = { ...(node.meta?.childApplicability ?? {}) }
delete childApplicability[nodeDefUuid]
Expand All @@ -29,16 +64,41 @@ const dissocChildApplicability = (node: Node, nodeDefUuid: string) => {
meta: { ...node.meta, childApplicability },
}
}
const getHierarchy = (node: Node) => [...(node.meta?.h ?? [])]

const getHierarchyCode = (node: Node) => [...(node.meta?.hCode ?? [])]

const mergeNodes = (target: Node, ...sources: Node[] | object[]): Node =>
Objects.deepMerge(target, ...sources) as unknown as Node
const assocChildrenCount = (params: {
node: Node
nodeDefUuid: string
count: number
countType: NodeDefCountType
}): Node => {
const { node, nodeDefUuid, count, countType } = params
const countIndex = {
...((countType === NodeDefCountType.max ? node.meta?.childrenMaxCount : node.meta?.childrenMinCount) ?? {}),
}
if (isNaN(count)) {
delete countIndex[nodeDefUuid]
} else {
countIndex[nodeDefUuid] = count
}
const metaUpdated = { ...node.meta }
if (countType === NodeDefCountType.max) {
metaUpdated.childrenMaxCount = countIndex
} else {
metaUpdated.childrenMinCount = countIndex
}
return {
...node,
meta: metaUpdated,
updated: true,
dateModified: Dates.nowFormattedForStorage(),
}
}

const isDefaultValueApplied = (node: Node): boolean => node?.meta?.defaultValueApplied ?? false
const assocChildrenMaxCount = (params: { node: Node; nodeDefUuid: string; count: number }): Node =>
assocChildrenCount({ ...params, countType: NodeDefCountType.max })

const isValueBlank = (node: Node): boolean => Objects.isEmpty(node.value)
const assocChildrenMinCount = (params: { node: Node; nodeDefUuid: string; count: number }): Node =>
assocChildrenCount({ ...params, countType: NodeDefCountType.min })

const removeStatusFlags = (node: Node): Node => {
delete node['created']
Expand All @@ -51,12 +111,19 @@ export const Nodes = {
isRoot,
areEqual,
isChildApplicable,
assocChildApplicability,
dissocChildApplicability,
mergeNodes,
getChildrenCount,
getChildrenMaxCount,
getChildrenMinCount,
getHierarchy,
getHierarchyCode,
isDefaultValueApplied,
isValueBlank,
// update
assocChildApplicability,
dissocChildApplicability,
assocChildrenCount,
assocChildrenMaxCount,
assocChildrenMinCount,
mergeNodes,
removeStatusFlags,
}
2 changes: 1 addition & 1 deletion src/nodeDef/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ export type {
NodeDefValidations,
NodeDefMap,
} from './nodeDef'
export { NodeDefType } from './nodeDef'
export { NodeDefCountType, NodeDefType } from './nodeDef'
export { NodeDefs } from './nodeDefs'

export type { NodeDefService } from './service'
Expand Down
30 changes: 19 additions & 11 deletions src/nodeDef/nodeDef.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { UUIDs } from '../utils'
import { Objects, UUIDs } from '../utils'
import { ArenaObject, Factory } from '../common'
import { Labels } from '../language'
import { ValidationSeverity } from '../validation'
Expand All @@ -20,12 +20,17 @@ export enum NodeDefType {
formHeader = 'formHeader',
}

export enum NodeDefCountType {
max = 'max',
min = 'min',
}

export interface NodeDefMeta {
h: Array<string>
h: string[]
}

export interface NodeDefProps {
cycles?: Array<string>
cycles?: string[]
descriptions?: Labels
key?: boolean
autoIncrementalKey?: boolean
Expand Down Expand Up @@ -70,29 +75,32 @@ export interface NodeDefExpressionFactoryParams {

export const NodeDefExpressionFactory: Factory<NodeDefExpression, NodeDefExpressionFactoryParams> = {
createInstance: (params: NodeDefExpressionFactoryParams): NodeDefExpression => {
const { applyIf, expression, severity } = params
return { applyIf, expression, severity, uuid: UUIDs.v4() }
const result = { uuid: UUIDs.v4() }
Object.assign(result, Objects.deleteEmptyProps({ ...params }))
return result
},
}

export type NodeDefCountExpression = string | NodeDefExpression[]

export interface NodeDefCountValidations {
max?: number
min?: number
max?: NodeDefCountExpression
min?: NodeDefCountExpression
}

export interface NodeDefValidations {
count?: NodeDefCountValidations
expressions?: Array<NodeDefExpression>
expressions?: NodeDefExpression[]
required?: boolean
unique?: boolean
}

export interface NodeDefPropsAdvanced {
applicable?: Array<NodeDefExpression>
defaultValues?: Array<NodeDefExpression>
applicable?: NodeDefExpression[]
defaultValues?: NodeDefExpression[]
defaultValueEvaluatedOneTime?: boolean
excludedInClone?: boolean
formula?: Array<NodeDefExpression>
formula?: NodeDefExpression[]
validations?: NodeDefValidations
// file attribute
fileNameExpression?: string
Expand Down
28 changes: 22 additions & 6 deletions src/nodeDef/nodeDefs.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { LanguageCode } from '../language'
import { valuePropsCoordinate } from '../node/nodeValueProps'
import { defaultCycle } from '../survey'
import { Numbers, Objects, Strings } from '../utils'
import { Objects, Strings } from '../utils'
import {
NodeDef,
NodeDefCountExpression,
NodeDefCountType,
NodeDefExpression,
NodeDefLayout,
NodeDefProps,
Expand Down Expand Up @@ -128,15 +130,27 @@ const getItemsFilter = (nodeDef: NodeDef<any>): string | undefined => nodeDef.pr
const getValidations = (nodeDef: NodeDef<NodeDefType>): NodeDefValidations | undefined =>
nodeDef.propsAdvanced?.validations

const getValidationsExpressions = (nodeDef: NodeDef<NodeDefType>): NodeDefExpression[] =>
getValidations(nodeDef)?.expressions ?? []

const isRequired = (nodeDef: NodeDef<NodeDefType>): boolean => getValidations(nodeDef)?.required ?? false

// // Min max
const getMinCount = (nodeDef: NodeDef<NodeDefType>) => Numbers.toNumber(getValidations(nodeDef)?.count?.min)
// // Min/max count

const getCount = (nodeDef: NodeDef<NodeDefType>, countType: NodeDefCountType): NodeDefCountExpression | undefined => {
const countValidations = getValidations(nodeDef)?.count
if (!countValidations) return undefined
return countType === NodeDefCountType.max ? countValidations.max : countValidations.min
}

const getMinCount = (nodeDef: NodeDef<NodeDefType>): NodeDefCountExpression | undefined =>
getCount(nodeDef, NodeDefCountType.min)

const getMaxCount = (nodeDef: NodeDef<NodeDefType>) => Numbers.toNumber(getValidations(nodeDef)?.count?.max)
const getMaxCount = (nodeDef: NodeDef<NodeDefType>): NodeDefCountExpression | undefined =>
getCount(nodeDef, NodeDefCountType.max)

const hasMinOrMaxCount = (nodeDef: NodeDef<NodeDefType>) =>
!Number.isNaN(getMinCount(nodeDef)) || !Number.isNaN(getMaxCount(nodeDef))
const hasMinOrMaxCount = (nodeDef: NodeDef<NodeDefType>): boolean =>
!Objects.isEmpty(getMinCount(nodeDef)) || !Objects.isEmpty(getMaxCount(nodeDef))

// layout
const getLayoutProps =
Expand Down Expand Up @@ -262,9 +276,11 @@ export const NodeDefs = {
isCodeShown,
// validations
getValidations,
getValidationsExpressions,
isRequired,
// // Min Max
hasMinOrMaxCount,
getCount,
getMaxCount,
getMinCount,
// Analysis
Expand Down
1 change: 0 additions & 1 deletion src/record/_records/recordGetters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -330,7 +330,6 @@ export const findEntityByKeyValues = (params: {
return siblingEntities.find((siblingEntity) => {
const siblingEntityKeyValuesByDefUuid = getEntityKeyValuesByDefUuid({
survey,
cycle,
record,
entity: siblingEntity,
keyDefs,
Expand Down
73 changes: 73 additions & 0 deletions src/record/recordNodesUpdater/recordNodeDependentsCountUpdater.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { User } from '../../auth'
import { NodeDefCountType, NodeDefs } from '../../nodeDef'
import { Record } from '../record'
import { Survey, SurveyDependencyType } from '../../survey'
import { Node, NodePointer, Nodes } from '../../node'
import { RecordUpdateResult } from './recordUpdateResult'
import { Records } from '../records'
import { RecordExpressionEvaluator } from '../recordExpressionEvaluator'
import { Objects } from '../../utils'

export const updateDependentsCount = (params: {
user: User
survey: Survey
record: Record
node: Node
countType: NodeDefCountType
sideEffect?: boolean
timezoneOffset?: number
}): RecordUpdateResult => {
const { user, survey, record, node, countType, timezoneOffset, sideEffect = false } = params

const updateResult = new RecordUpdateResult({ record })

// 1. fetch dependent nodes
const dependencyType =
countType === NodeDefCountType.max ? SurveyDependencyType.maxCount : SurveyDependencyType.minCount

const nodePointersToUpdate = Records.getDependentNodePointers({
survey,
record,
node,
dependencyType,
})

// 2. update expr to node and dependent nodes
// NOTE: don't do it in parallel, same nodeCtx metadata could be overwritten
const expressionEvaluator = new RecordExpressionEvaluator()
nodePointersToUpdate.forEach((nodePointer: NodePointer) => {
const { nodeCtx: nodeCtxNodePointer, nodeDef: nodeDefNodePointer } = nodePointer

const expressionsToEvaluate = NodeDefs.getCount(nodeDefNodePointer, countType)
if (Objects.isEmpty(expressionsToEvaluate) || !Array.isArray(expressionsToEvaluate)) return

// 3. evaluate applicable expression
const nodeCtxUuid = nodeCtxNodePointer.uuid
// nodeCtx could have been updated in a previous iteration
const nodeCtx = updateResult.getNodeByUuid(nodeCtxUuid) ?? nodeCtxNodePointer

const countResult = expressionEvaluator.evalApplicableExpression({
user,
survey,
record: updateResult.record,
nodeCtx,
expressions: expressionsToEvaluate,
timezoneOffset,
})

const count = Number(countResult?.value)

// 4. persist updated count if changed, and return updated nodes
const nodeDefUuid = nodeDefNodePointer.uuid

if (Nodes.getChildrenCount({ parentNode: nodeCtx, nodeDef: nodeDefNodePointer, countType }) !== count) {
// count changed

// 5. update node and add it to nodes updated
const nodeCtxUpdated = Nodes.assocChildrenCount({ node: nodeCtx, nodeDefUuid, count, countType })
updateResult.addNode(nodeCtxUpdated, { sideEffect })
}
})

return updateResult
}
11 changes: 6 additions & 5 deletions src/record/recordNodesUpdater/recordNodesCreator.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { CategoryItem } from '../../category'
import { SystemError } from '../../error'
import { Node, NodeFactory } from '../../node'
import { Node, NodeFactory, Nodes } from '../../node'
import { NodeValues } from '../../node/nodeValues'
import { NodeDef, NodeDefCode, NodeDefEntity, NodeDefType, NodeDefs } from '../../nodeDef'
import { Survey } from '../../survey'
Expand All @@ -25,10 +25,11 @@ export type NodeCreateParams = NodesUpdateParams & {
createMultipleEntities?: boolean
}

const getNodesToInsertCount = (nodeDef: NodeDef<any>): number => {
if (NodeDefs.isSingle(nodeDef)) return 1
const getNodesToInsertCount = (params: { parentNode: Node | undefined; nodeDef: NodeDef<any> }): number => {
const { nodeDef, parentNode } = params
if (!parentNode || NodeDefs.isSingle(nodeDef)) return 1
if (nodeDef.type === NodeDefType.code) return 0 // never create nodes for multiple code attributes
return NodeDefs.getMinCount(nodeDef) ?? 0
return Nodes.getChildrenMinCount({ parentNode, nodeDef }) ?? 0
}

const getEnumeratingCategoryItems = (params: { survey: Survey; enumerator: NodeDefCode }): CategoryItem[] => {
Expand Down Expand Up @@ -96,7 +97,7 @@ const createChildNodesBasedOnMinCount = (params: NodeCreateParams & { updateResu
}
}

const nodesToInsertCount = getNodesToInsertCount(nodeDef)
const nodesToInsertCount = getNodesToInsertCount({ parentNode, nodeDef })
if (nodesToInsertCount === 0 || (!createMultipleEntities && NodeDefs.isMultipleEntity(nodeDef))) {
return // do nothing
}
Expand Down
Loading
Loading