Skip to content

Commit

Permalink
Add date validation using minValue and maxValue extensions (#74)
Browse files Browse the repository at this point in the history
# Add date validation using minValue and maxValue extensions

## ♻️ Current situation & Problem
Currently ResearchKitOnFHIR does not support date validation, although
this is supported by [Phoenix](https://github.com/StanfordBDHG/Phoenix)
and also implemented in the [Android FHIR
SDK](https://github.com/google/android-fhir) via the
`http://hl7.org/fhir/StructureDefinition/minValue` and
`http://hl7.org/fhir/StructureDefinition/maxValue` extensions.

## ⚙️ Release Notes 
Adds support for date validation via the `valueDate` property in the
`http://hl7.org/fhir/StructureDefinition/minValue` and
`http://hl7.org/fhir/StructureDefinition/maxValue` extensions.

## 📚 Documentation
Please refer to the official HL7 FHIR extension specification for the
minValue and maxValue extensions, which this PR follows to implement
date validation:
- MinValue: http://hl7.org/fhir/StructureDefinition/minValue
- MaxValue: http://hl7.org/fhir/StructureDefinition/maxValue

## ✅ Testing
Unit tests and UI tests have been added.

### Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md):
- [X] I agree to follow the [Code of
Conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
vishnuravi authored Mar 1, 2024
1 parent 300fbc0 commit ef35067
Show file tree
Hide file tree
Showing 6 changed files with 133 additions and 7 deletions.
61 changes: 61 additions & 0 deletions Example/ExampleUITests/ExampleUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -289,6 +289,67 @@ final class ExampleUITests: XCTestCase {
// Dismiss results view
app.swipeDown(velocity: XCUIGestureVelocity.fast)
}

func testDateValidation() throws {
let app = XCUIApplication()
app.launch()

let dateTimeExampleButton = app.collectionViews.buttons["Date and Time Example"]
XCTAssert(dateTimeExampleButton.waitForExistence(timeout: 2))
dateTimeExampleButton.tap()

// We will set the picker to a date before the minimum date, and expect that it resets to the minimum date
app.pickerWheels.element(boundBy: 0).adjust(toPickerWheelValue: "December")
app.pickerWheels.element(boundBy: 1).adjust(toPickerWheelValue: "25")
app.pickerWheels.element(boundBy: 2).adjust(toPickerWheelValue: "1970")

// Wait for validation to complete and picker to reset
sleep(2)

// Extract the date from the picker
let dateFormatter = DateFormatter()
dateFormatter.dateFormat = "MMMM d yyyy"
dateFormatter.timeZone = TimeZone.current

let minTestResetMonth = try XCTUnwrap(app.pickerWheels.element(boundBy: 0).value as? String)
let minTestResetDay = try XCTUnwrap(app.pickerWheels.element(boundBy: 1).value as? String)
let minTestResetYear = try XCTUnwrap(app.pickerWheels.element(boundBy: 2).value as? String)

let minTestResetDateStr = "\(minTestResetMonth) \(minTestResetDay) \(minTestResetYear)"
let minTestResetDate = try XCTUnwrap(dateFormatter.date(from: minTestResetDateStr))

// Validate that the date has reset to the minimum date value defined in the `Date and Time Example` questionnaire's first item
let minDate = try XCTUnwrap(dateFormatter.date(from: "January 1 2001"))
XCTAssertEqual(
minTestResetDate,
minDate,
"The date picker did not reset to January 1, 2001."
)

// Now, we will set the picker to a date after the maximum date, and expect that it resets to the maximum date
app.pickerWheels.element(boundBy: 0).adjust(toPickerWheelValue: "January")
app.pickerWheels.element(boundBy: 1).adjust(toPickerWheelValue: "1")
app.pickerWheels.element(boundBy: 2).adjust(toPickerWheelValue: "2025")

// Wait for validation to complete and picker to reset
sleep(2)

// Extract the date from the picker
let maxTestResetMonth = try XCTUnwrap(app.pickerWheels.element(boundBy: 0).value as? String)
let maxTestResetDay = try XCTUnwrap(app.pickerWheels.element(boundBy: 1).value as? String)
let maxTestResetYear = try XCTUnwrap(app.pickerWheels.element(boundBy: 2).value as? String)

let maxTestResetDateStr = "\(maxTestResetMonth) \(maxTestResetDay) \(maxTestResetYear)"
let maxTestResetDate = try XCTUnwrap(dateFormatter.date(from: maxTestResetDateStr))

// Validate that the date has reset to the maximum date value defined in the `Date and Time Example` questionnaire's first item
let maxDate = try XCTUnwrap(dateFormatter.date(from: "January 1 2024"))
XCTAssertEqual(
maxTestResetDate,
maxDate,
"The date picker did not reset to January 1, 2024."
)
}

func testFormExample() throws {
let app = XCUIApplication()
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ For more information, please refer to the [API documentation](https://swiftpacka
| [attachment](https://www.hl7.org/fhir/codesystem-item-type.html#item-type-attachment) | [ORKImageCaptureStep](http://researchkit.org/docs/Classes/ORKImageCaptureStep.html) | valueAttachment
| [boolean](https://www.hl7.org/fhir/codesystem-item-type.html#item-type-boolean) | [ORKBooleanAnswerFormat](http://researchkit.org/docs/Classes/ORKBooleanAnswerFormat.html) | valueBoolean
| [choice](https://www.hl7.org/fhir/codesystem-item-type.html#item-type-choice) | [ORKTextChoice](http://researchkit.org/docs/Classes/ORKTextChoice.html) | valueCoding
| [date](https://www.hl7.org/fhir/codesystem-item-type.html#item-type-date) | [ORKDateAnswerFormat](http://researchkit.org/docs/Classes/ORKDateAnswerFormat.html)(style: [ORKDateAnswerStyle.date](http://researchkit.org/docs/Constants/ORKDateAnswerStyle.html) | valueDate
| [dateTime](https://www.hl7.org/fhir/codesystem-item-type.html#item-type-dateTime) | [ORKDateAnswerFormat](http://researchkit.org/docs/Classes/ORKDateAnswerFormat.html)(style: [ORKDateAnswerStyle.dateAndTime](http://researchkit.org/docs/Constants/ORKDateAnswerStyle.html) | valueDateTime
| [date](https://www.hl7.org/fhir/codesystem-item-type.html#item-type-date) | [ORKDateAnswerFormat](http://researchkit.org/docs/Classes/ORKDateAnswerFormat.html)(style: [ORKDateAnswerStyle.date](http://researchkit.org/docs/Constants/ORKDateAnswerStyle.html)) | valueDate
| [dateTime](https://www.hl7.org/fhir/codesystem-item-type.html#item-type-dateTime) | [ORKDateAnswerFormat](http://researchkit.org/docs/Classes/ORKDateAnswerFormat.html)(style: [ORKDateAnswerStyle.dateAndTime](http://researchkit.org/docs/Constants/ORKDateAnswerStyle.html)) | valueDateTime
| [decimal](https://www.hl7.org/fhir/codesystem-item-type.html#item-type-decimal) | [ORKNumericAnswerFormat](http://researchkit.org/docs/Classes/ORKNumericAnswerFormat.html).decimalAnswerFormat | valueDecimal
| [display](https://www.hl7.org/fhir/codesystem-item-type.html#item-type-display) | [ORKInstructionStep](http://researchkit.org/docs/Classes/ORKInstructionStep.html) | *none*
| [group](https://www.hl7.org/fhir/codesystem-item-type.html#item-type-group) | [ORKFormStep](http://researchkit.org/docs/Classes/ORKFormStep.html) | *none*
Expand Down
12 changes: 11 additions & 1 deletion Sources/FHIRQuestionnaires/Resources/DateTimeExample.json
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,17 @@
"linkId": "cc7f8427-15fc-4c52-b6a3-3ba6635b8249",
"type": "date",
"text": "Choose a date",
"required": false
"required": false,
"extension": [
{
"url": "http://hl7.org/fhir/StructureDefinition/minValue",
"valueDate": "2001-01-01"
},
{
"url": "http://hl7.org/fhir/StructureDefinition/maxValue",
"valueDate": "2024-01-01"
}
]
},
{
"linkId": "af50f8ea-1298-42ce-8d6b-f6b88ce1ca88",
Expand Down
37 changes: 36 additions & 1 deletion Sources/ResearchKitOnFHIR/FHIRExtensions/FHIRExtensions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ extension QuestionnaireItem {
static let hidden = "http://hl7.org/fhir/StructureDefinition/questionnaire-hidden"
}


/// Is the question hidden
/// - Returns: A boolean representing whether the question should be shown to the user
var hidden: Bool {
Expand Down Expand Up @@ -58,6 +57,19 @@ extension QuestionnaireItem {
return NSNumber(value: minValue)
}

/// The minimum value for a date answer.
/// - Returns: An optional `Date` containing the minimum date allowed.
var minDateValue: Date? {
guard let minValueExtension = getExtensionInQuestionnaireItem(url: SupportedExtensions.minValue),
case let .date(dateValue) = minValueExtension.value,
let minDateValue = dateValue.value?.asDateAtStartOfDayWithDefaults
else {
return nil
}

return minDateValue
}

/// The maximum value for a numerical answer.
/// - Returns: An optional `NSNumber` containing the maximum value allowed.
var maxValue: NSNumber? {
Expand All @@ -69,6 +81,19 @@ extension QuestionnaireItem {
return NSNumber(value: maxValue)
}

/// The maximum value for a date answer.
/// - Returns: An optional `Date` containing the maximum date allowed.
var maxDateValue: Date? {
guard let maxValueExtension = getExtensionInQuestionnaireItem(url: SupportedExtensions.maxValue),
case let .date(dateValue) = maxValueExtension.value,
let maxDateValue = dateValue.value?.asDateAtStartOfDayWithDefaults
else {
return nil
}

return maxDateValue
}

/// The maximum number of decimal places for a decimal answer.
/// - Returns: An optional `NSNumber` representing the maximum number of digits to the right of the decimal place.
var maximumDecimalPlaces: NSNumber? {
Expand Down Expand Up @@ -132,3 +157,13 @@ extension QuestionnaireItem {
self.`extension`?.first(where: { $0.url.value?.url.absoluteString == url })
}
}

extension FHIRDate {
/// Converts a `FHIRDate` to a `Date` with the time set to the start of day in the user's current time zone.
/// If either the month or day are not provided, we will assume they are the first.
/// - Returns: An optional `Date`
var asDateAtStartOfDayWithDefaults: Date? {
let dateComponents = DateComponents(year: year, month: Int(month ?? 1), day: Int(day ?? 1))
return Calendar.current.date(from: dateComponents).map { Calendar.current.startOfDay(for: $0) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ extension QuestionnaireItem {
/// - Parameter valueSets: An array of `ValueSet` items containing sets of answer choices
/// - Returns: An object of type `ORKAnswerFormat` representing the type of answer this question accepts.
private func toORKAnswerFormat(valueSets: [ValueSet]) throws -> ORKAnswerFormat {
// swiftlint:disable:previous cyclomatic_complexity
// swiftlint:disable:previous cyclomatic_complexity function_body_length
// We have to cover all the switch cases in the following statement driving up the overall complexity.
switch type.value {
case .boolean:
Expand All @@ -166,9 +166,15 @@ extension QuestionnaireItem {
}
return ORKTextChoiceAnswerFormat(style: choiceAnswerStyle, textChoices: answerOptions)
case .date:
return ORKDateAnswerFormat(style: ORKDateAnswerStyle.date)
return ORKDateAnswerFormat(
style: .date,
defaultDate: nil,
minimumDate: minDateValue,
maximumDate: maxDateValue,
calendar: nil
)
case .dateTime:
return ORKDateAnswerFormat(style: ORKDateAnswerStyle.dateAndTime)
return ORKDateAnswerFormat(style: .dateAndTime)
case .time:
return ORKTimeOfDayAnswerFormat()
case .decimal, .quantity:
Expand Down
14 changes: 14 additions & 0 deletions Tests/ResearchKitOnFHIRTests/FHIRToResearchKitTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ final class FHIRToResearchKitTests: XCTestCase {
XCTAssertEqual(unwrappedMaxValue, 100)
}

func testMinDateValueExtension() throws {
let minDateValue = Questionnaire.dateTimeExample.item?.first?.minDateValue
let unwrappedMinDateValue = try XCTUnwrap(minDateValue)

XCTAssertEqual(unwrappedMinDateValue, Calendar.current.date(from: DateComponents(year: 2001, month: 1, day: 1)))
}

func testMaxDateValueExtension() throws {
let maxDateValue = Questionnaire.dateTimeExample.item?.first?.maxDateValue
let unwrappedMaxDateValue = try XCTUnwrap(maxDateValue)

XCTAssertEqual(unwrappedMaxDateValue, Calendar.current.date(from: DateComponents(year: 2024, month: 1, day: 1)))
}

func testMaxDecimalExtension() throws {
let maxDecimals = Questionnaire.numberExample.item?[1].maximumDecimalPlaces
let unwrappedMaxDecimals = try XCTUnwrap(maxDecimals)
Expand Down

0 comments on commit ef35067

Please sign in to comment.