Sử dụng Yup Meta để Đồng cấu hình Trường Biểu mẫu và Cột Bảng trong React Table

Sử dụng Yup Meta để Đồng cấu hình Trường Biểu mẫu và Cột Bảng trong React Table

Bài viết này hướng dẫn cách sử dụng thuộc tính `meta()` của Yup để triển khai cấu hình thống nhất cho các trường biểu mẫu và cột bảng trong các dự án React, đáp ứng nhu cầu hiển thị và sử dụng lại cấu trúc đa vùng.

Nền tảng cần biết:

  • Sử dụng cơ bản Yup và viết test với Jest: Không đi sâu vào cách sử dụng Yup, nếu bạn chưa quen thuộc có thể tham khảo thêm
  • Sử dụng Yup 2 - Lấy giá trị mặc định, phụ thuộc vòng lặp, xác thực số lớn, bản địa hóa: Có ít liên quan với nội dung bài này, nhưng nếu muốn tìm hiểu sâu hơn về Yup có thể tham khảo
  • [React] Xây dựng Enum Store đáp ứng với Zustand? Kèm thực chiến RTKQ và kiến trúc TS: Một phần code cơ bản của bài viết này được lấy từ bài viết này, chủ yếu để tiện lấy giá trị

Sau khi hoàn thành hai bài viết về Yup, tôi đã cân nhắc sử dụng Yup để giải quyết một vấn đề kết hợp thuộc tính `meta` của Yup để quản lý logic định nghĩa biểu mẫu/bảng một cách tập trung, giúp xử lý tốt hơn các thuộc tính và triển khai trong các dự án hiện tại.

Sau một loạt thử nghiệm và điều chỉnh, tôi đã đưa ra một giải pháp khả thi và ghi lại lại đây.

Vấn đề - Định nghĩa trùng lặp

Nhiều dự án B2B và B2C tôi làm đều là các dự án nặng về biểu mẫu, vì vậy từ lâu tôi đã nghĩ cách xử lý các vấn đề về biểu mẫu và cấu trúc. Tổng quan lại, tôi đã trải qua một số tình huống sau:

  1. Viết xác thực thủ công + tách logic UI: Đây là cách tiếp cận ban đầu khi mới tiếp xúc với React, sau khi viết một số biểu mẫu cho các dự án, tôi đã tự mình mày mò ra一套. Trong các dự án lúc đó có thể sử dụng được. Vì yêu cầu dự án cần bundle nhỏ và phụ thuộc tối thiểu, nhiều thứ phải tự mình thực hiện, do đó tôi tự tạo ra một số "bánh xe".
  2. Viết xác thực thủ công + sử dụng công cụ tạo biểu mẫu: Đây là các dự án khác nhau, nhưng do gánh nặng công nghệ, tổng thể dự án ở trạng thái vừa cũ vừa mới. Cũ vì có nhiều công cụ - ví dụ như redux form, đã có từ lâu, mặc dù lúc dùng khá thoải mái, nhưng so với sự thay đổi nhanh chóng, vẫn hơi yếu. Mới vì lúc đó đang chuyển từ thành phần dựa trên class sang thành phần dựa trên hàm, và đặt nhiều nguồn lực vào việc viết lại React, đối với các công cụ và xác thực trước đó vẫn giữ nguyên trạng thái.
  3. Bỏ qua xác thực + viết cấu trúc thủ công: Đây cũng là các dự án gần đây trong công việc. Lý do bỏ qua xác thực là vì tất cả xác thực đều do backend xử lý, lúc đó không nghĩ đến việc làm xác thực ở phía frontend, cũng thuộc gánh nặng công nghệ. Lý do riêng dự án này lại được nhắc đến là phần viết cấu trúc - viết cấu trúc thực sự hơi phức tạp vì nó cần hỗ trợ đồng thời hai cấp độ là bảng và biểu mẫu.

Bên cạnh đó, dự án này cần hỗ trợ đa vùng, do đó mỗi vùng sẽ có cấu trúc tương ứng. Điều này có nghĩa là khi một trang có logic phức tạp, độ khó quản lý sẽ tăng vọt:

  • Đối với cùng một thuộc tính, logic ở cấp độ bảng/biểu mẫu có thể khác nhau: Ví dụ, ở cấp độ bảng, nó có thể là dropdown, nhưng ở cấp độ biểu mẫu nó có thể mở một modal. Hoặc ở cấp độ bảng cần hiển thị tối giản, do đó cấp độ bảng sẽ không hiển thị thuộc tính đó, nhưng ở cấp độ biểu mẫu có đủ không gian để hiển thị dữ liệu đầy đủ, do đó cần hiển thị.
  • Đối với các vùng khác nhau, thuộc tính hiển thị cũng khác nhau: Đối với tình huống này, tôi đã thực hiện một phần refactoring, chuyển logic từ dựa trên mảng sang dựa trên đối tượng, sử dụng như sau:
const regionA = { attributeA, attributeB, attributeC };
const regionB = { attributeA, attributeC };

Trong thành phần, lấy tên thuộc tính để viết lại, giải quyết một phần vấn đề - thuộc tính có thể được quản lý tập trung, sửa đổi tên hiển thị chỉ cần sửa đổi đối tượng cơ bản, không cần sao chép và dán nhiều nơi, việc viết lại trong thành phần React cũng khá thuận tiện, so với phiên bản 1 là một sự nâng cấp hiệu quả.

Tuy nhiên, về cơ bản, việc khai báo trùng lặp vẫn không thể giải quyết, đặc biệt là khi xem xét thuộc tính cụ thể trong các vùng khác nhau - có hay không, có thể chỉnh sửa - cũng rất khó khăn. Đặc biệt sau khi sử dụng Yup, sẽ thấy schema và cấu trúc có sự trùng lặp lớn, đây chính là vấn đề tôi muốn giải quyết hiện nay.

Đây là những vấn đề mà các dự án đang có gánh nặng công nghệ sẽ gặp phải. Nếu không, tôi cá nhân nên ưu tiên sử dụng các công cụ như react hook form đã hỗ trợ gốc yup/zod và các schema validators khác. Nếu thực sự có nhu cầu schema/cấu trúc đồng thời hỗ trợ biểu mẫu và bảng, hãy tiếp tục đọc.

Dữ liệu mẫu

Dưới đây là dữ liệu cần hiển thị trên trang:

const demoStudentCourseRecords = [
  {
    studentName: "Alice Johnson",
    studentId: "S1001",
    courseLevel: "beginner",
    timeSlot: "morning",
    courseTaken: ["ml"],
  },
  {
    studentName: "Bob Smith",
    studentId: "S1002",
    courseLevel: "intermediate",
    timeSlot: "afternoon",
    courseTaken: ["db", "stats"],
  },
  {
    studentName: "Charlie Lee",
    studentId: "S1003",
    courseLevel: "advanced",
    timeSlot: "evening",
    courseTaken: ["ds"],
  },
  {
    studentName: "Diana Wang",
    studentId: "S1004",
    courseLevel: "beginner",
    timeSlot: "morning",
    courseTaken: ["stats"],
  },
  {
    studentName: "Ethan Brown",
    studentId: "S1005",
    courseLevel: "intermediate",
    timeSlot: "evening",
    courseTaken: ["ml", "ds", "stats"],
  },
];

Tất cả các `courseTaken` đều có sự khác biệt rõ ràng so với các khóa học cụ thể, các khác còn lại chỉ khác về chữ hoa/thường. Đây là sự khác biệt giữa viết tắt và tên đầy đủ. Điều đó có nghĩa là, trên bảng hiển thị đầy đủ, nếu không hiển thị được tên đầy đủ, thì chứng minh rằng việc triển khai dropdown có vấn đề.

Định nghĩa Meta cơ bản

Dưới đây là một số meta dữ liệu cơ bản, phần xác thực tạm thời bỏ qua:

export function createStudentCourseRecordSchema(
  courseLevels: string[],
  timeSlots: string[],
  courseList: string[],
  courseLevelOptions: { id: string; label: string }[],
  timeSlotOptions: { id: string; label: string }[],
  courseOptions: { id: string; label: string }[]
): yup.AnySchema {
  return yup.object().shape({
    studentName: studentNameField,
    studentId: studentIdField,
    courseLevel: yup
      .string()
      .required(`${FIELD_LABELS.courseLevel} is required`)
      .oneOf(courseLevels, `Invalid ${FIELD_LABELS.courseLevel.toLowerCase()}`)
      .meta({
        label: FIELD_LABELS.courseLevel,
        table: { header: FIELD_LABELS.courseLevel, accessorKey: "courseLevel" },
        type: "enum",
        options: courseLevelOptions,
      }),
    timeSlot: yup
      .string()
      .required(`${FIELD_LABELS.timeSlot} is required`)
      .oneOf(timeSlots, `Invalid ${FIELD_LABELS.timeSlot.toLowerCase()}`)
      .meta({
        label: FIELD_LABELS.timeSlot,
        table: { header: FIELD_LABELS.timeSlot, accessorKey: "timeSlot" },
        type: "enum",
        options: timeSlotOptions,
      }),
    courseTaken: yup
      .array()
      .of(yup.string().oneOf(courseList, `Invalid course taken`))
      .min(
        1,
        `At least one ${FIELD_LABELS.courseTaken.toLowerCase()} must be selected`
      )
      .required(`${FIELD_LABELS.courseTaken} is required`)
      .meta({
        label: FIELD_LABELS.courseTaken,
        table: { header: FIELD_LABELS.courseTaken, accessorKey: "courseTaken" },
        type: "multi-enum",
        options: courseOptions,
      }),
  });
}

Lưu ý `type` ở đây: `type: "enum"` và `type: "multi-enum"` tương ứng với chọn đơn và chọn nhiều, điều này sẽ được sử dụng khi chuyển đổi sang react table.

Đây cũng là một ý tưởng ban đầu, vì vậy khi chọn tạo `createStudentCourseRecordSchema` đã áp dụng phương pháp truyền giá trị. Hiện tại, ý tưởng là sau này quay lại triển khai zustand, sau đó sử dụng phương pháp pub/sub, sau khi lấy dữ liệu tự động publish các thay đổi tương ứng, và để yup tự động cập nhật qua subscribe.

Tạo cột bảng

Phương thức như sau:

export function generateTableColumnsFromSchema(
  schema: yup.AnySchema
): ColumnDef[] {
  console.log(schema);

  if (!schema) return [];

  const description = schema.describe() as ObjectSchemaDescription;

  if (description.type !== "object" || !description.fields) {
    throw new Error("Schema must be a Yup object schema.");
  }

  const fields = description.fields;

  const columns: ColumnDef[] = [];

  for (const [_, fieldDesc] of Object.entries(fields)) {
    const meta = fieldDesc.meta as any;

    const { header, accessorKey } = meta.table;

    const type = meta?.type;
    const options = meta?.options;

    let cell;

    if (type === "enum" && options) {
      const optionMap = new Map(options.map((opt: any) => [opt.id, opt.label]));

      cell = ({ getValue }: { getValue: () => any }) => {
        const rawValue = getValue();
        return optionMap.get(rawValue) ?? rawValue;
      };
    } else if (type === "multi-enum" && options) {
      const optionMap = new Map(options.map((opt: any) => [opt.id, opt.label]));

      cell = ({ getValue }: { getValue: () => any }) => {
        const rawValues = getValue() as string[];
        if (!Array.isArray(rawValues)) return null;
        return rawValues.map((val) => optionMap.get(val) ?? val).join(", ");
      };
    } else {
      cell = ({ getValue }: { getValue: () => any }) => {
        const rawValue = getValue();
        return rawValue;
      };
    }

    columns.push({
      header,
      accessorKey,
      cell,
    });
  }

  return columns;
}

Ở đây, việc xử lý `cell` dựa trên các loại khác nhau, cụ thể là lấy giá trị thông qua key.

Hiệu quả hiển thị như sau:

Table rendering with full course names

Có thể thấy, tất cả các khóa học đều từ viết tắt đã chuyển thành tên đầy đủ, điều này cũng chứng minh việc lấy dữ liệu và hiển thị đều không có vấn đề.

Mở rộng - Hỗ trợ động

Phần mở rộng sẽ sử dụng một package: sift, sift triển khai một cú pháp giống như mongodb, trong quá trình sử dụng, tôi nhận thấy rằng, ít nhất đối với hầu hết các dự án, một cách sử dụng đơn giản của `$or` là đủ.

Mở rộng phần schema

Code như sau:

// Cập nhật định nghĩa props, nếu viết nhiều phương pháp util, có thể để AnySchema mở rộng phần meta bên dưới
// Như vậy có thể nhận được hỗ trợ tốt hơn
export type Region = "us" | "apac" | "eu";
export type Env = "dev" | "uat" | "prod";
export type FieldVisibility = "form" | "table" | "both";
export type Context = {
  env?: Env;
  region?: Region;
  fieldVisibility?: FieldVisibility;
}[];

export interface IFieldMeta {
  label?: string;
  table?: {
    header?: string;
    accessorKey?: string;
  };
  type?: string;
  options?: { id: string; label: string }[];
  isAvailable?: boolean | Context;
}

// ========================================

export function createStudentCourseRecordSchema(
  courseLevels: string[],
  timeSlots: string[],
  courseList: string[],
  courseLevelOptions: { id: string; label: string }[],
  timeSlotOptions: { id: string; label: string }[],
  courseOptions: { id: string; label: string }[]
): yup.AnySchema {
  return yup.object().shape({
    studentName: studentNameField,
    studentId: studentIdField,
    courseLevel: yup
      .string()
      .required(`${FIELD_LABELS.courseLevel} is required`)
      .oneOf(courseLevels, `Invalid ${FIELD_LABELS.courseLevel.toLowerCase()}`)
      .meta({
        label: FIELD_LABELS.courseLevel,
        table: { header: FIELD_LABELS.courseLevel, accessorKey: "courseLevel" },
        type: "enum",
        options: courseLevelOptions,
        isAvailable: [{ fieldVisibility: "form" }],
      }),
    timeSlot: yup
      .string()
      .required(`${FIELD_LABELS.timeSlot} is required`)
      .oneOf(timeSlots, `Invalid ${FIELD_LABELS.timeSlot.toLowerCase()}`)
      .meta({
        label: FIELD_LABELS.timeSlot,
        table: { header: FIELD_LABELS.timeSlot, accessorKey: "timeSlot" },
        type: "enum",
        options: timeSlotOptions,
        isAvailable: [{ fieldVisibility: "both" }],
      }),
    courseTaken: yup
      .array()
      .of(yup.string().oneOf(courseList, `Invalid course taken`))
      .min(
        1,
        `At least one ${FIELD_LABELS.courseTaken.toLowerCase()} must be selected`
      )
      .required(`${FIELD_LABELS.courseTaken} is required`)
      .meta({
        label: FIELD_LABELS.courseTaken,
        table: { header: FIELD_LABELS.courseTaken, accessorKey: "courseTaken" },
        type: "multi-enum",
        options: courseOptions,
      }),
  });
}

Ở đây, phần cập nhật chính là kiểu dữ liệu và phần `isAvailable` của `courseLevel` và `timeSlot`, phần này cần chứa tất cả các lựa chọn để sift có thể thực hiện filter → cách dùng cụ thể ở dưới.

`Context` được tách riêng ra vì ở đây không chỉ dùng trong `isAvailable`, cùng logic có thể đặt ở `isVisible`, `isEditable` và các logic có thể mở rộng khác.

Như vậy có thể thực hiện một sự kiểm soát rất trực quan và chi tiết:

  • Thuộc tính hiện tại có bao gồm trong biểu mẫu/bảng/vùng không
  • Thuộc tính hiện tại có hiển thị trong biểu mẫu/bảng/vùng không
  • Thuộc tính hiện tại có thể thay đổi trong biểu mẫu/bảng/vùng không
  • ...

Nếu không thiết lập, mặc định là `true`, đồng thời hỗ trợ ghi đè bằng cách sử dụng boolean, quyền ưu tiên này sẽ cao hơn thiết lập context.

Mở rộng phần util

Code như sau:

export function generateTableColumnsFromSchema(
  schema: yup.AnySchema
): ColumnDef[] {
  console.log(schema);

  if (!schema) return [];

  const description = schema.describe() as ObjectSchemaDescription;

  if (description.type !== "object" || !description.fields) {
    throw new Error("Schema must be a Yup object schema.");
  }

  const fields = description.fields;

  const columns: ColumnDef[] = [];

  for (const [_, fieldDesc] of Object.entries(fields)) {
    const meta = fieldDesc.meta as any;

    const { header, accessorKey } = meta.table;

    const type = meta?.type;
    const options = meta?.options;

    let cell;

    if (type === "enum") {
      const optionMap = new Map(options.map((opt: any) => [opt.id, opt.label]));

      cell = ({ getValue }: { getValue: () => any }) => {
        const rawValue = getValue();
        return optionMap.get(rawValue) ?? rawValue;
      };
    } else if (type === "multi-enum") {
      const optionMap = new Map(options.map((opt: any) => [opt.id, opt.label]));

      cell = ({ getValue }: { getValue: () => any }) => {
        const rawValues = getValue() as string[];
        if (!Array.isArray(rawValues)) return null;
        return rawValues.map((val) => optionMap.get(val) ?? val).join(", ");
      };
    } else {
      cell = ({ getValue }: { getValue: () => any }) => {
        const rawValue = getValue();
        return rawValue;
      };
    }

    let isColAvailable = true;
    // const currEnv = {
    //   region: 'us'
    // };
    const currEnv = {
      $or: [
        { env: "dev" },
        { region: "us" },
        { fieldVisibility: "table" },
        { fieldVisibility: "both" },
      ],
    };
    if (meta?.isAvailable) {
      // Thêm kiểm tra typeof để đảm bảo quyền ưu tiên của boolean
      isColAvailable = meta.isAvailable.filter(sift(currEnv)).length > 0;
    }

    if (!isColAvailable) continue;

    columns.push({
      header,
      accessorKey,
      cell,
    });
  }

  return columns;
}

Ở đây, để minh họa, tôi đã làm đơn giản hóa một chút, cách làm ban đầu là viết ba phương pháp:

  1. Phương pháp thứ nhất là `generateTableColumnsFromSchema`
  2. Phương pháp thứ hai là `generateFormFieldsFromSchema`

Sau đó cả hai đều lọc `isColAvailable` bằng sift, lấy một cấu trúc `IFieldMeta[]` của columns, truyền đến phương thức util xử lý logic tập trung cuối cùng.

Logic sửa đổi cốt lõi ở đây là:

let isColAvailable = true;
// const currEnv = {
//   region: 'us'
// };
const currEnv = {
  $or: [
    { env: "dev" },
    { region: "us" },
    { fieldVisibility: "table" },
    { fieldVisibility: "both" },
  ],
};
if (meta?.isAvailable) {
  // Thêm kiểm tra typeof để đảm bảo quyền ưu tiên của boolean
  isColAvailable = meta.isAvailable.filter(sift(currEnv)).length > 0;
}

if (!isColAvailable) continue;

Đoạn code này có thể thực hiện kiểm soát thuộc tính hiện tại, và tôi đề xuất chia context này thành context toàn cầu và context cục bộ. Context toàn cầu, như region, env, trong môi trường cụ thể sẽ không thay đổi, điều này hoàn toàn có thể tách ra để đặt ở các constant hoặc util khác, đến khi cần sử dụng thì mới extend.

Hiệu quả hiển thị cuối cùng như sau:

Table with Course Level hidden in table view

`Course Level` đã trở thành một tùy chọn chỉ có ở biểu mẫu, do đó bị bỏ qua trong chế độ xem bảng, trong khi `Time Slot` vì xuất hiện ở cả biểu mẫu và bảng nên được giữ lại, có thể hiển thị bình thường trên trang.

Thẻ: yup react-table form-validation schema-design meta-property

Đăng vào ngày 2 tháng 6 lúc 02:20