Reusable date range validator

Note: This post describes my developer experience, the step I have to take to make it work. I am not describing the final result but also the problems I have experienced and how I dealt with them. In the development process, the issues are snowballing and spiralling out of control. Also, this is a follow-up post to the previous post.

The validator validates catalogue object has properties – validFrom and validTo – to make it valid only in this date range. None of these properties is required so that the catalogue can be active from the specific day forward or till some date. Or forever. But the date range has to be valid date range and end of validity has to be after the start of validity. The date range is also validated only when the catalogue is active. That information is stored in isActive property, named initially isEnabled but renamed later.

The validator is implemented on the server-side using a custom validator and added to properties of the input type as @Validate(DateFromToValidator) decorator.

@InputType()
export class CatalogInput {
  ...
  @Validate(DateFromToValidator)
  @Field({ nullable: true })
  validFrom?: Date;
  @Validate(DateFromToValidator)
  @Field({ nullable: true })
  validTo?: Date;
  @Field()
  isActive: boolean;
}

The of the validator from the previous post

@ValidatorConstraint({ name: "dateFromToValidator", async: false })
export class DateFromToValidator implements ValidatorConstraintInterface {
  validate(value: string, args: ValidationArguments) {
    const catalog = args.object as CatalogInput;
    if (!catalog.isEnabled) {
      return true;
    }

    return catalog.validFrom <= catalog.validTo;
  }

  defaultMessage(args: ValidationArguments) {
    return "Text is too short or too long!";
  }
}

You may have noticed that the DateFromToValidator contains bugs.

  1. it does not work when only one of the range values is provided
  2. the error message is non-sensical
  3. It has to split into two constraints to show a proper validation message for each property
  4. The validator is not reusable

It happened during the development while dealing with the other snowballed issues and I have completely forgotten about that.

@ValidatorConstraint({ name: "dateFromValidator", async: false })
export class DateFromValidator implements ValidatorConstraintInterface {
  validate(value: string, args: ValidationArguments) {
    const catalog = args.object as CatalogInput;
    if (!catalog.isEnabled) {
      return true;
    }

    return catalog.validFrom <= catalog.validTo;
  }

  defaultMessage(args: ValidationArguments) {
    return "Text is too short or too long!";
  }
}

Reusability

The steps to make the validator reusable was an introduction of a new interface IHasValidityRestriction and getting rid of the dependency on the value of isEnabled property.

export interface IHasValidityRestriction {
  validFrom?: Date;
  validTo?: Date;
}

Then any class implementing this interface can be validated:

@ValidatorConstraint({ name: "dateRangeValidator", async: false })
export class DateRangeValidator implements ValidatorConstraintInterface {
  constructor(private message: string) {}

  validate(value: string, args: ValidationArguments) {
    const o = args.object as IHasValidityRestriction;
    // only when validFrom and validTo values has been supplied
    if (o.validFrom && o.validTo) { 
      return o.validFrom <= o.validTo;
    }

    return true;
  }

  defaultMessage(args: ValidationArguments) { return this.message; }
}

The class-validator library has a neat conditional decorator ValidateIf that can ignore the validators on a property when the provided condition function returns false. In this case, the date range is validated when isEnabled is true.

Decorators

I have also created two decorators to validate validFrom and validTo properties; each of them has a different constraint violation message.

export function DateRangeStart(validationOptions?: ValidationOptions) {
  return function(object: Object, propertyName: string) {
    registerDecorator({
      name: "dateRangeStartValidator",
      target: object.constructor,
      propertyName: propertyName,
      constraints: [],
      options: validationOptions,
      validator: new DateRangeValidator(
        `${propertyName} must be before date range end`
      )
    });
  };
}

export function DateRangeEnd(validationOptions?: ValidationOptions) {
  return function(object: Object, propertyName: string) {
    registerDecorator({
      name: "dateRangeEndValidator",
      target: object.constructor,
      propertyName: propertyName,
      constraints: [],
      options: validationOptions,
      validator: new DateRangeValidator(
        `${propertyName} must be after date range start`
      )
    });
  };
}

Result

And this is the result when everything is put together:

@InputType()
export class CatalogInput implements IHasValidityRestriction {
  @ValidateIf(o => o.isEnabled)
  @DateRangeStart()
  @Field({ nullable: true })
  validFrom?: Date;
  
  @ValidateIf(o => o.isEnabled)
  @DateRangeEnd()
  @Field({ nullable: true })
  validTo?: Date;
  
  @Field()
  isEnabled: boolean;
}

Notes

I was trying to improve the UX and disable the form submit button when there is a field with an invalid value, but this does not work in this case. While I can change the dates for the date range to be valid, the fields remain invalid until the next server validation.

<Button
  type="primary"
  disabled={hasError}
  htmlType="submit">
  Submit
</Button>

And the hasError value is detected from the form itself.

const fieldErrors = form.getFieldsError();
const hasError = Object.keys(fieldErrors).find(p => fieldErrors[p] !== undefined) !== undefined;

The (again) ugly fix was to reset the errors for the date range on submitting explicitly.

handleSubmit = async (catalog: ICatalog, form: WrappedFormUtils<any>) => {
  ...
  form.setFields({
    validFrom: { value: form.getFieldValue("validFrom") },
    validTo: { value: form.getFieldValue("validTo") }
  });
  ...
}

Is my code going to be full of ugly hacks? I certainly hope note. Some might still argue, but a much better fix is to reset the field errors when the date range fields change. The handler has to reset input field errors for both input fields because the validator invalidates both. And the onChange handler has been added to both input fields.

<DatePicker
  onChange={() =>
    form.setFields({
      validTo: { value: form.getFieldValue("validTo") },
      validFrom: { value: form.getFieldValue("validFrom") }
    })
  } />

Final thoughts

Developers who know how similar controls work would certainly avoid half of the discusses problems and use the DatePicker disabledDate property to limit the start and end dates. Using it will improve the UX on the client-side, but data provided by the user has to be validated.

{getFieldDecorator("validTo", {
  initialValue: momentFunc(catalog.validTo) 
})(<DatePicker
  onChange={() =>
    form.setFields({
      validTo: { value: form.getFieldValue("validTo") },
      validFrom: { value: form.getFieldValue("validFrom") }
    })  
  } 
  disabledDate={this.disabledValidTo}
/>)}
disabledValidTo = (validTo?: moment.Moment): boolean => {
  const validFrom = this.props.form.getFieldValue("validFrom");
  if (validTo && validFrom) {
    return validTo.valueOf() <= validFrom.valueOf();
  }
  return false;
};

Leave a Reply

Your email address will not be published. Required fields are marked *

Blue Captcha Image
Refresh

*