AbstractProblemFactory
AbstractProblemFactory

Reputation: 9811

How to test for uniqueness of value in Yup.array?

I have form with dynamic amount of inputs (admin email) however checking for uniqueness fails:

      validationSchema={Yup.object().shape({
        adminEmails: Yup.array()
          .of(
            Yup.string()
              .notOneOf(Yup.ref('adminEmails'), 'E-mail is already used')

What is best approach here? FYI, as a form helper I use Formik.

Upvotes: 18

Views: 33529

Answers (8)

peacetrue
peacetrue

Reputation: 319

import _ from "lodash";
import {addMethod, array, defaultLocale, Flags, Message, TestContext} from 'yup';
// https://github.com/jquense/yup?tab=readme-ov-file#extending-built-in-schema-with-new-methods
// https://github.com/jquense/yup/issues/345
declare module 'yup' {
  interface ArraySchema<TIn extends any[] | null | undefined, TContext, TDefault = undefined, TFlags extends Flags = ''> {
    unique(props?: UniqueProps): this;
  }

  interface ArrayLocale {
    unique?: Message<UniqueExtra>;
  }
}

export type UniqueProps = {
  /** if the item of array is object, fieldName used to associate a unique field */
  fieldName?: string,
  /** get the duplicated values */
  verbose?: boolean,
  /** custom message */
  message?: Message<UniqueExtra>,
};

export type UniqueExtra = {
  /** if {@link UniqueProps#fieldName} assigned, this will be fieldLabel */
  itemLabel: string;
  /** value join by ',' */
  rawValue?: string;
};

function rawValue(key: string, record: Record<string, number[]>) {
  return `${key}[${record[key].join(",")}]`;
}

addMethod(array, 'unique', function (props: UniqueProps = {}) {
  return this.test({
    name: "unique",
    // @ts-ignore
    message: props.message || defaultLocale.array.unique || "${path} must be unique, ${itemLabel}${rawValue} is duplicated!",
    params: {itemLabel: "value", rawValue: ""} as UniqueExtra,
    test(value: any[] | undefined, context: TestContext) {
      if (!value || value.length <= 1) return true;
      // arraySchema, itemSchema, fieldSchema
      let itemSchema = context.schema.innerType;
      const fieldName = props.fieldName;
      if (fieldName) {
        // itemSchema <- fieldSchema
        itemSchema = itemSchema.fields[fieldName];
        value = value.map((item: any) => item[fieldName]);
      }
      const itemLabel = itemSchema.spec.label;
      if (props.verbose !== true) {
        return value.length === new Set(value).size
          || context.createError({
            params: {itemLabel,},
          });
      }

      const grouped = _(value)
          .map((item, index) => ({value: item, index}))
          .groupBy('value')
          .pickBy((value, _key) => value.length > 1)
          .mapValues((value, _key) => value.map(item => item.index))
          .value()
      ;
      const keys = Object.keys(grouped);
      if (keys.length > 0) {
        return context.createError({
          params: {itemLabel, rawValue: keys.map(key => rawValue(key, grouped)).join(','),},
        });
      }
      return true;
    }
  });
});

Upvotes: 1

Wendy Surya Wijaya
Wendy Surya Wijaya

Reputation: 11

Improving on this answer, this works for me

import { array, ArraySchema, Flags, number, object } from 'yup';

export const yupTestUnique = <
    TIn extends any[] | null | undefined,
    TContext,
    TDefault,
    TFlags extends Flags
>(params: {
    arraySchema: ArraySchema<TIn, TContext, TDefault, TFlags>;
    iteratee?: (
        value: TIn extends readonly (infer ElementType)[] ? ElementType : never
    ) => any;
    message?: string;
}) => {
    return params.arraySchema.test(
        "unique",
        params.message ? params.message : 'must be unique',
        (values, context) => {
            return values?.length
                ? new Set(
                    values.map((value) =>
                        typeof params.iteratee === "function"
                            ? params.iteratee(value)
                            : value
                    )
                ).size === values.length
                : true;
        }
    );
};

// Example
const createProductUnitValidationSchema = object({
    unitId: number().required(),
    price: number().required().min(0),
});

const createProductValidationSchema = object({
    productUnits: yupTestUnique({
        arraySchema: array()
            .required()
            .of(createProductUnitValidationSchema)
            .min(1),
        iteratee: (value) => value.unitId,
    }),
});

Upvotes: 1

marton
marton

Reputation: 1350

This is a simple inline solution to validate that an array of strings only contains unique elements:

Yup.array().of(Yup.string())
.test(
  'unique',
  'Only unique values allowed.',
  (value) => value ? value.length === new Set(value)?.size : true
)

Upvotes: 8

shlgug
shlgug

Reputation: 1356

To improve the performance of Alex's answer, use something like this to pre-calculate the lookup (using lodash).

yup.addMethod(yup.mixed, 'uniqueIn', function (array = [], message) {
    return this.test('uniqueIn', message, function (value) {
        const cacheKey = 'groups';
        if (!this.options.context[cacheKey]) {
            this.options.context[cacheKey] = _.groupBy(array, x => x);
        }
        const groups = this.options.context[cacheKey];
        return _.size(groups[value]) < 2;
    });
});

then call validate with an object for context so we can store our calculated group for the duration of the validate call

schema.validate(data, {context: {}}).then(...);

Upvotes: 2

Alex
Alex

Reputation: 61

If you want to have the errors in each field and not in the array

Yup.addMethod(Yup.mixed, 'uniqueIn', function (array = [], message) {
    return this.test('uniqueIn', message, function (value) {
        return array.filter(item => item === value).length < 2;
    });
});

Upvotes: 6

Krishna Jangid
Krishna Jangid

Reputation: 5410

Simply Do This It works for me

First Define this function in your react component

    Yup.addMethod(Yup.array, "unique", function (message, mapper = (a) => a) {
    return this.test("unique", message, function (list) {
      return list.length === new Set(list.map(mapper)).size
    })
  })

Just Put this schema inside your Formik tag

<Formik
    initialValues={{
      hotelName: "",
      hotelEmail: [""],
    }}
    validationSchema={Yup.object().shape({
      hotelName: Yup.string().required("Please enter hotel name"),
      hotelEmail: Yup.array()
        .of(
          Yup.object().shape({
            email: Yup.string()
              .email("Invalid email")
              .required("Please enter email"),
          }),
        )
        .unique("duplicate email", (a) => a.email),
    })}
    onSubmit={(values, { validate }) => {
      getFormPostData(values)
    }}
    render={({ values, errors, touched }) => (
      <Form>
            <FieldArray
                  name="hotelEmail"
                  render={(arrayHelpers) => (
                    <>
                      {values.hotelEmail.map((hotel, index) => (
                        <div class="row" key={index}>
                          <div className="col-md-8 mt-3">
                            <div className="user-title-info user-details">
                              <div className="form-group d-flex align-items-center mb-md-4 mb-3">
                                <label className="mb-0" htmlFor="hotelName">
                                  {lang("Hotelmanagement.hotelsystemadmin")}
                                  <sup className="text-danger">*</sup>
                                </label>
                                <div className="w-100">
                                  <Field
                                    name={`hotelEmail.${index}.email`}
                                    className="form-control"
                                    id="hotelEmail"
                                    placeholder={lang(
                                      "Hotelmanagement.hotelsystemadmin",
                                    )}
                                  />
                                  <span className="text-danger d-block">
                                    {errors &&
                                      errors.hotelEmail &&
                                      errors.hotelEmail[index] &&
                                      errors.hotelEmail[index].email && (
                                        <span className="text-danger d-block">
                                          {errors.hotelEmail[index].email}
                                        </span>
                                      )}
                                      {errors &&
                                      errors.hotelEmail &&(
                                        <span className="text-danger d-block">
                                          {errors.hotelEmail}
                                        </span>
                                      )}
                                  </span>
                                </div>
                              </div>
                            </div>
                          </div>
                          <div className="col-md-2 mt-3">
                            {index > 0 && (
                              <i
                                className="bx bx-minus addnewBtn "
                                onClick={() => arrayHelpers.remove(index)}
                              />
                            )}
                            {index === values.hotelEmail.length - 1 && (
                              <i
                                className="bx bx-plus addnewBtn ml-5"
                                onClick={() => arrayHelpers.push("")}
                              />
                            )}
                          </div>
                        </div>
                      ))}
                    </>
                  )}
                />



 

not to show error do this following

 {errors &&
   errors.hotelEmail &&(
      <span className="text-danger d-block">
            {errors.hotelEmail}
      </span>
 )}
)} />

Upvotes: 3

MiooiM
MiooiM

Reputation: 31

Probably too late to respond, but anyways, you should use this.createError({path, message});

See example:

yup.addMethod(yup.array, 'growing', function(message) {
    return this.test('growing', message, function(values) {
        const len = values.length;
        for (let i = 0; i < len; i++) {
            if (i === 0) continue;
            if (values[i - 1].intervalTime > values[i].intervalTime) return this.createError({
                path: `intervals[${i}].intervalTime`,
                message: 'Should be greater than previous interval',
            });
        }
        return true;
    });
});

Upvotes: 2

olefrank
olefrank

Reputation: 6820

Try this:

Yup.addMethod(Yup.array, 'unique', function(message, mapper = a => a) {
    return this.test('unique', message, function(list) {
        return list.length  === new Set(list.map(mapper)).size;
    });
});

Then use it like this:

const headersSchema = Yup.object().shape({
    adminEmails: Yup.array().of(
        Yup.string()
    )
    .unique('email must be unique')
})

Upvotes: 24

Related Questions