Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 21 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,26 @@
# Changelog

## 2.0.0 Release Candidate
## 1.9.3

### Improvements

- Add support for validating dates before/after another date field using `isBefore` and `isAfter` validators. [#11194](https://github.com/opencrvs/opencrvs-core/issues/11194)

Usage example:

```ts
// 6570 days before another field
field('mother.dob').isBefore().days(6570).fromDate(field('child.dob'))

// 6570 days after another field
field('mother.dateOfMarriage')
.isAfter()
.days(6570)
.fromDate(field('mother.dob'))

// 45 days before now
field('child.dob').isBefore().days(45).fromNow()
```

### New features

Expand Down
26 changes: 26 additions & 0 deletions packages/commons/src/conditionals/conditionals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -308,6 +308,32 @@ describe('date comparisons', () => {
})
).toBe(false)
})

it('validates comparisons to where date is expected to be certain days before certain date reference', () => {
expect(
validate(
field('mother.dob').isBefore().days(6570).fromDate(field('child.dob')),
{
...getFieldParams({
'child.dob': '1990-01-01',
'mother.dob': '1969-12-31' // needs to be before 1972-12-31 ✅
})
}
)
).toBe(true)

expect(
validate(
field('mother.dob').isBefore().days(6570).fromDate(field('child.dob')),
{
...getFieldParams({
'child.dob': '1990-01-01',
'mother.dob': '1973-01-01' // needs to be before 1972-12-31 ❌
})
}
)
).toBe(false)
})
})

describe('age asDob comparisons', () => {
Expand Down
109 changes: 104 additions & 5 deletions packages/commons/src/conditionals/conditionals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,65 @@ function getDateRangeToFieldReference(
}
}

/**
* This function will output JSONSchema which looks for example like this:
* @example
* {
* "type": "object",
* "properties": {
* "mother.dob": {
* "type": "string",
* "format": "date",
* "daysFromDate": {
* "clause": "before",
* "days": 30,
* "referenceDate": {
* "$data": "1/child.dob"
* }
* }
* },
* "child.dob": {
* "type": "string",
* "format": "date"
* }
* },
* "required": ["mother.dob"]
* }
*/
function getDayRangeToFieldReference(
field: FieldReference,
comparedField: FieldReference,
days: number,
clause: 'before' | 'after'
) {
return {
type: 'object',
properties: {
[field.$$field]: wrapToPath(
{
type: 'string',
format: 'date',
daysFromDate: {
referenceDate: {
$data: `${field.$$subfield.length + 1}/${jsonFieldPath(
comparedField
)}`
},
clause,
days
}
},
field.$$subfield
),
[comparedField.$$field]: wrapToPath(
{ type: 'string', format: 'date' },
comparedField.$$subfield
)
},
required: [field.$$field]
}
}

function defineComparison(
field: FieldReference,
value: number | FieldReference,
Expand Down Expand Up @@ -341,17 +400,19 @@ export function createFieldConditionals(fieldId: string) {
const getDayRange = (
field: FieldReference,
days: number,
clause: 'before' | 'after'
clause: 'before' | 'after',
referenceDate?: string
) => ({
type: 'object',
properties: {
[field.$$field]: wrapToPath(
{
type: 'string',
format: 'date',
daysFromNow: {
daysFromDate: {
days,
clause
clause,
referenceDate
}
},
field.$$subfield
Expand Down Expand Up @@ -412,7 +473,22 @@ export function createFieldConditionals(fieldId: string) {
inPast: () =>
defineFormConditional(getDayRange(this, -days, 'after')),
inFuture: () =>
defineFormConditional(getDayRange(this, days, 'after'))
defineFormConditional(getDayRange(this, days, 'after')),
fromDate: (
date: `${string}-${string}-${string}` | FieldReference
) => {
if (isFieldReference(date)) {
const comparedField = date
return defineFormConditional(
getDayRangeToFieldReference(this, comparedField, days, 'after')
)
}

return defineFormConditional(getDayRange(this, days, 'after', date))
},
fromNow: () => {
return defineFormConditional(getDayRange(this, days, 'after'))
}
}),
date: (date: string | FieldReference) => {
if (isFieldReference(date)) {
Expand All @@ -438,7 +514,30 @@ export function createFieldConditionals(fieldId: string) {
inPast: () =>
defineFormConditional(getDayRange(this, -days, 'before')),
inFuture: () =>
defineFormConditional(getDayRange(this, days, 'before'))
defineFormConditional(getDayRange(this, days, 'before')),
fromDate: (
date: `${string}-${string}-${string}` | FieldReference
) => {
if (isFieldReference(date)) {
const comparedField = date

return defineFormConditional(
getDayRangeToFieldReference(
this,
comparedField,
-days,
'before'
)
)
}

return defineFormConditional(
getDayRange(this, -days, 'before', date)
)
},
fromNow: () => {
return defineFormConditional(getDayRange(this, -days, 'before'))
}
}),
date: (date: `${string}-${string}-${string}` | FieldReference) => {
if (isFieldReference(date)) {
Expand Down
85 changes: 78 additions & 7 deletions packages/commons/src/conditionals/validate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,44 @@ const DataContext = z.object({

type DataContext = z.infer<typeof DataContext>

function resolveDataPath(
rootData: Record<string, unknown>,
dataPath: string,
instancePath: string
): unknown {
const pathParts = dataPath.split('/')

// First part is the depth level (how many levels up to traverse)
const levels = parseInt(pathParts[0], 10)
const referencePath = pathParts.slice(1)

// Parse instancePath to get current location
// instancePath looks like: "/$form/mother.dob"
const instanceParts = instancePath.split('/').filter(Boolean) // Remove empty strings

// Traverse up by `levels` from current location
const traversedParts = instanceParts.slice(0, -levels)

// Start from rootData and navigate to the traversed location
let current: unknown | Record<string, unknown> = rootData
for (const part of traversedParts) {
if (current === null || current === undefined) {
return undefined
}
current = (current as Record<string, unknown>)[part]
}

// Now navigate using the reference path from that location
for (const part of referencePath) {
if (current === null || current === undefined) {
return undefined
}
current = (current as Record<string, unknown>)[part]
}

return current
}

// https://ajv.js.org/packages/ajv-formats.html
addFormats(ajv)

Expand All @@ -60,24 +98,29 @@ addFormats(ajv)
* "properties": {
* "birthDate": {
* "type": "string",
* "daysFromNow": {
* "daysFromDate": {
* "days": 30,
* "clause": "before"
* "clause": "before",
* "referenceDate": { "$data": "1/child.dob" } // optional, defaults to $now
* }
* }
* }
*/
ajv.addKeyword({
keyword: 'daysFromNow',
keyword: 'daysFromDate',
type: 'string',
schemaType: 'object',
$data: true,
errors: true,
validate(
schema: { days: number; clause: 'after' | 'before' },
schema: {
days: number
clause: 'after' | 'before'
referenceDate?: string | { $data: string }
},
data: string,
_: unknown,
dataContext?: { rootData: unknown }
dataContext?: { rootData: unknown; instancePath: string }
) {
if (
!(
Expand All @@ -101,8 +144,35 @@ ajv.addKeyword({
return false
}

const now = new Date(dataContext.rootData.$now)
const offsetDate = new Date(now.getTime() + days * 24 * 60 * 60 * 1000)
let referenceDate = schema.referenceDate

if (
referenceDate &&
typeof referenceDate === 'object' &&
'$data' in referenceDate
) {
/**
* For some reason AJV does not resolve $data references automatically inside custom keywords
* So we have to do it manually here
*/
referenceDate = resolveDataPath(
dataContext.rootData,
referenceDate.$data,
dataContext.instancePath
) as string
}

if (!referenceDate) {
referenceDate = dataContext.rootData.$now
}

const baseDate = new Date(referenceDate)

if (isNaN(baseDate.getTime())) {
return false
}

const offsetDate = new Date(baseDate.getTime() + days * 24 * 60 * 60 * 1000)

return clause === 'after'
? isAfter(date, offsetDate)
Expand Down Expand Up @@ -152,6 +222,7 @@ export function validate(schema: JSONSchema, data: ConditionalParameters) {
})
)
}

const result = validator(data) as boolean
return result
}
Expand Down
Loading