Tag Archives: antd

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;
};

Communicating server-side input validation failures with GraphQL and ant-design form

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.

When I have chosen a completely different programming language (TypeScript) and server-side API approach (GraphQL) and different React UI library (ant-design) for the development of my application, I did not know how it will slow me down. Every new feature I wanted to implement (including the most basic ones) resulted in spend some time researching using Google, StackOverflow and GitHub pages. This time it was no different – server-side validation and communicating the input validation failure to the user.

The form

The catalog object has two properties – validFrom and validTo – to make it valid only in this date range. None of these properties is required so that the catalog 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 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 {
  @Field({ nullable: true })
  id?: number;
  @Field()
  name: string;
  @MaxLength(255)
  @Field({ nullable: true })
  description?: string;
  @Validate(DateFromToValidator)
  @Field({ nullable: true })
  validFrom?: Date;
  @Validate(DateFromToValidator)
  @Field({ nullable: true })
  validTo?: Date;
  @Field()
  isPublic: boolean;
  @Field()
  isEnabled: boolean;
}

The library is using class-validator and I can create a custom validator constraint implementing ValidatorConstraintInterface interface. The interface has two methods

  • validate that should return true when everything is ok and
  • defaultMessage to return the error message.

The code of the validation constraint (not reusable) to validate validFrom and validTo date range is as follows:

@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 can argue that I could have implemented the validation on the client. Still, the golden rule of API design is never to trust the values provided by the user and always validate the data on the server-side. While this may improve the UX, it also means to have two implementations of the validator.

You may also say that is not a problem since the code can work on both browser and server-side and you may be right. But for now, let’s leave like it is 🙂 I will change it in the future.

When the validation fails on server-side, GraphQL API returns a response with errors array indicating a problem. The response has a 200 HTTP status code, unlike REST. The error response is well structured and contains some additional information like stack trace. The Apollo server introduced standardized errors where additional details are provided in the extensions map 12.

{
  "errors": [
    {
      "message": "Argument Validation Error!",
      "locations": [
        {
          "line": 2,
          "column": 3
        }
      ],
      "path": [
        "updateCatalog"
      ],
      "extensions": {
        "code": "BAD_USER_INPUT",
        "exception": {
          "validationErrors": [
            {
              "target": {
                "id": 2,
                "name": "Name",
                "description": "Description",
                "validFrom": "2019-12-19T11:42:31.972Z",
                "validTo": null,
                "isPublic": false,
                "isEnabled": true
              },
              "value": "2019-12-19T11:42:31.972Z",
              "property": "dateFromToValidator",
              "children": [],
              "constraints": {
                "catalogValidity": "Text is too short or too long!"
              }
            }
          ],
          "stacktrace": [
            "UserInputError: Argument Validation Error!",
            "    at Object.exports.translateError (C:\\Work\\playground\\app\\server\\src\\modules\\shared\\ErrorInterceptor.ts:69:11)",
            "    at C:\\Work\\playground\\app\\server\\src\\modules\\shared\\ErrorInterceptor.ts:29:13",
            "    at Generator.throw (<anonymous>)",
            "    at rejected (C:\\Work\\playground\\app\\server\\src\\modules\\shared\\ErrorInterceptor.ts:6:65)",
            "    at process._tickCallback (internal/process/next_tick.js:68:7)"
          ]
        }
      }
    }
  ],
  "data": null
}

To display the validation errors in the ant-design form, I have to convert the GraphQL error response to an object that can be passed to setFields method of the form. The signature of the method is setFields(obj: Object): void; which is not very helpful. The search on ant-design GitHub pages showed that the object passed must have the same properties as the edited object. Each member is another object with a property value with the edited object’ value and optional property errors containing the error(s) to be displayed.

const formFields: { [property: string]: { value: any, errors?: Error[] } } = {};

The failed attempt to mutate the data throws an exception – the response is rejected in Apollo. The error handler is a switch with cases for possible error codes. Only user input errors are handled here (BAD_USER_INPUT extension code).

try {
  await this.props.client.mutate<CatalogData>({
    mutation: UPDATE_CATALOG,
    variables: {
      catalog
    }
  });
} catch (e) {
  const apolloError = e as ApolloError;
  if (apolloError) {
    apolloError.graphQLErrors.forEach(apolloError => {
      const code = apolloError.extensions.code;
      switch (code) {
        case "BAD_USER_INPUT":
          const validationErrors = apolloError.extensions.exception.validationErrors;
          const formFields: { [property: string]: any } = {};
          validationErrors.forEach(
            (validationError: ApolloValidationError) => {
              const {
                target,
                property,
                constraints,
                value
              } = validationError;

              const errors = [];
              for (const key in constraints) {
                const value = constraints[key];
                errors.push(new Error(value));
              }

              formFields[property] = {
                value,
                errors
              };
            }
          );
          setTimeout(() => {
            form.setFields(formFields);
          }, 500);
          break;
        default:
          this.handleError(e);
          break;
      }
    });
  }
}

And this object will passed into setFields method:

{
  "validFrom": {
    "value": "2019-12-18T12:31:42.487Z",
    "errors": [
      Error("Text is too short or too long!")
    ]
  },
}

This code does not work – the DatePicker control expects a value of moment type, and it gets string instead. This attempt ends in warnings being written to a console and an exception thrown:

Warning: Failed prop type: Invalid prop `value` of type `string` supplied to `CalendarMixinWrapper`, expected `object`.
Warning: Failed prop type: Invalid prop `value` supplied to `Picker`.
TypeError: value.format is not a function

When these input fields are rendered the value provided is explicitly converted from Date to moment instance:

const momentFunc = (value?: Date) => moment(value).isValid() ? moment(value) : null;

<Form.Item label="Platnost do">
  <Row>
    <Col>
      {getFieldDecorator("validTo", { initialValue: momentFunc(catalog.validTo) })
        (<DatePicker locale={locale} />)
      }
    </Col>
  </Row>
</Form.Item>

The form is re-rendered, but the initialiValue is not recalculated.

The quick and ugly hack is to convert the string values representing dates into moment instances. It is ugly because I have to list the names of properties holding date values. Also, I can’t use this as a general solution:

const momentFunc = (value?: string) => moment(value).isValid() ? moment(value) : null;

let v = value;
if (key === "validTo" || key === "validFrom") { 
  v = momentFunc(value);
}

const errors = [];
for (const key in constraints) {
  const value = constraints[key];
  errors.push(new Error(value));
}

formFields[property] = { value: v, errors };

This works now with a minor problem – the validFrom input field is cleared on form submit and comes back with the validation failure message. Oops, I have been accidentally calling form.resetFields method in the submit handler.

Types exist only at compile time

Even though TypeScript brings optional static type-checking, it is only performed at compile time. The type information (the metadata) does not become a part of the compiled JavaScript code (unlike in C# or Java). However, the code elements can be extended with decorators to provide information in runtime. 3

Good news! I have already decorators in place – I have decorated the properties of CatalogInput class. This type is used for updating (mutating) the catalog instance through the GraphQL API. Bad news – this class is located in the server project which is parallel to the client project 🙁 I will write a separate post on this once I figure out how to achieve code sharing between client and server.

For now the ugly hack is to try to convert given value to a moment instance:

const isValidDate = (dateObject: string) => new Date(dateObject).toString() !== "Invalid Date";

let v = value;
if (isValidDate(v)) {
  v = momentFunc(value);
}

const errors = [];
for (const key in constraints) {
  const value = constraints[key];
  errors.push(new Error(value));
}

formFields[property] = { value: v, errors };

A much better and robust solution would be to use the type information from the decorators on client-side or extend the GraphQL API response with type information (again, from member decorator on server-side).