teableio/teable

View on GitHub
packages/core/src/models/field/field.schema.ts

Summary

Maintainability
B
4 hrs
Test Coverage
import type { RefinementCtx } from 'zod';
import { assertNever } from '../../asserts';
import type { IEnsureKeysMatchInterface } from '../../types';
import { IdPrefix } from '../../utils';
import { z } from '../../zod';
import { CellValueType, DbFieldType, FieldType } from './constant';
import {
  checkboxFieldOptionsSchema,
  numberFieldOptionsSchema,
  selectFieldOptionsSchema,
  singlelineTextFieldOptionsSchema,
  formulaFieldOptionsSchema,
  linkFieldOptionsSchema,
  dateFieldOptionsSchema,
  attachmentFieldOptionsSchema,
  rollupFieldOptionsSchema,
  linkFieldOptionsRoSchema,
  numberFieldOptionsRoSchema,
  selectFieldOptionsRoSchema,
  ratingFieldOptionsSchema,
  longTextFieldOptionsSchema,
  createdTimeFieldOptionsSchema,
  lastModifiedTimeFieldOptionsSchema,
  autoNumberFieldOptionsSchema,
  createdTimeFieldOptionsRoSchema,
  lastModifiedTimeFieldOptionsRoSchema,
  autoNumberFieldOptionsRoSchema,
  userFieldOptionsSchema,
  createdByFieldOptionsSchema,
  lastModifiedByFieldOptionsSchema,
} from './derivate';
import { unionFormattingSchema } from './formatting';
import { unionShowAsSchema } from './show-as';

export const lookupOptionsVoSchema = linkFieldOptionsSchema
  .pick({
    foreignTableId: true,
    lookupFieldId: true,
    relationship: true,
    fkHostTableName: true,
    selfKeyName: true,
    foreignKeyName: true,
  })
  .merge(
    z.object({
      linkFieldId: z.string().openapi({
        description: 'The id of Linked record field to use for lookup',
      }),
    })
  );

export type ILookupOptionsVo = z.infer<typeof lookupOptionsVoSchema>;

export const lookupOptionsRoSchema = lookupOptionsVoSchema.pick({
  foreignTableId: true,
  lookupFieldId: true,
  linkFieldId: true,
});

export type ILookupOptionsRo = z.infer<typeof lookupOptionsRoSchema>;

export const unionFieldOptions = z.union([
  rollupFieldOptionsSchema.strict(),
  formulaFieldOptionsSchema.strict(),
  linkFieldOptionsSchema.strict(),
  dateFieldOptionsSchema.strict(),
  checkboxFieldOptionsSchema.strict(),
  attachmentFieldOptionsSchema.strict(),
  singlelineTextFieldOptionsSchema.strict(),
  ratingFieldOptionsSchema.strict(),
  userFieldOptionsSchema.strict(),
  createdByFieldOptionsSchema.strict(),
  lastModifiedByFieldOptionsSchema.strict(),
]);

export const unionFieldOptionsVoSchema = z.union([
  unionFieldOptions,
  linkFieldOptionsSchema.strict(),
  selectFieldOptionsSchema.strict(),
  numberFieldOptionsSchema.strict(),
  autoNumberFieldOptionsSchema.strict(),
  createdTimeFieldOptionsSchema.strict(),
  lastModifiedTimeFieldOptionsSchema.strict(),
]);

export const unionFieldOptionsRoSchema = z.union([
  unionFieldOptions,
  linkFieldOptionsRoSchema.strict(),
  selectFieldOptionsRoSchema.strict(),
  numberFieldOptionsRoSchema.strict(),
  autoNumberFieldOptionsRoSchema.strict(),
  createdTimeFieldOptionsRoSchema.strict(),
  lastModifiedTimeFieldOptionsRoSchema.strict(),
]);

export const commonOptionsSchema = z.object({
  showAs: unionShowAsSchema.optional(),
  formatting: unionFormattingSchema.optional(),
});

export type IFieldOptionsRo = z.infer<typeof unionFieldOptionsRoSchema>;
export type IFieldOptionsVo = z.infer<typeof unionFieldOptionsVoSchema>;

export const fieldVoSchema = z.object({
  id: z.string().startsWith(IdPrefix.Field).openapi({
    description: 'The id of the field.',
  }),

  name: z.string().openapi({
    description: 'The name of the field. can not be duplicated in the table.',
    example: 'Tags',
  }),

  type: z.nativeEnum(FieldType).openapi({
    description: 'The field types supported by teable.',
    example: FieldType.SingleSelect,
  }),

  description: z.string().optional().openapi({
    description: 'The description of the field.',
    example: 'this is a summary',
  }),

  options: unionFieldOptionsVoSchema.openapi({
    description:
      "The configuration options of the field. The structure of the field's options depend on the field's type.",
  }),

  isLookup: z.boolean().optional().openapi({
    description:
      'Whether this field is lookup field. witch means cellValue and [fieldType] is looked up from the linked table.',
  }),

  lookupOptions: lookupOptionsVoSchema.optional().openapi({
    description: 'field lookup options.',
  }),

  notNull: z.boolean().optional().openapi({
    description: 'Whether this field is not null.',
  }),

  unique: z.boolean().optional().openapi({
    description: 'Whether this field is not unique.',
  }),

  isPrimary: z.boolean().optional().openapi({
    description: 'Whether this field is primary field.',
  }),

  isComputed: z.boolean().optional().openapi({
    description:
      'Whether this field is computed field, you can not modify cellValue in computed field.',
  }),

  isPending: z.boolean().optional().openapi({
    description: "Whether this field's calculation is pending.",
  }),

  hasError: z.boolean().optional().openapi({
    description:
      "Whether This field has a configuration error. Check the fields referenced by this field's formula or configuration.",
  }),

  cellValueType: z.nativeEnum(CellValueType).openapi({
    description: 'The cell value type of the field.',
  }),

  isMultipleCellValue: z.boolean().optional().openapi({
    description: 'Whether this field has multiple cell value.',
  }),

  dbFieldType: z.nativeEnum(DbFieldType).openapi({
    description: 'The field type of database that cellValue really store.',
  }),

  dbFieldName: z
    .string()
    .min(1, { message: 'name cannot be empty' })
    .regex(/^\w{0,63}$/, {
      message: 'Invalid name format',
    })
    .openapi({
      description:
        'Field(column) name in backend database. Limitation: 1-63 characters, can only contain letters, numbers and underscore, case sensitive, cannot be duplicated with existing db field name in the table.',
    }),
});

export type IFieldVo = z.infer<typeof fieldVoSchema>;

export type IFieldPropertyKey = keyof Omit<IFieldVo, 'id'>;

export const FIELD_RO_PROPERTIES = [
  'type',
  'name',
  'dbFieldName',
  'isLookup',
  'description',
  'lookupOptions',
  'options',
] as const;

export const FIELD_VO_PROPERTIES = [
  'type',
  'description',
  'options',
  'name',
  'isLookup',
  'lookupOptions',
  'notNull',
  'unique',
  'isPrimary',
  'isComputed',
  'isPending',
  'hasError',
  'cellValueType',
  'isMultipleCellValue',
  'dbFieldType',
  'dbFieldName',
] as const;

/**
 * make sure FIELD_VO_PROPERTIES is exactly equals IFieldVo
 * if here shows lint error, you should update FIELD_VO_PROPERTIES
 */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _validator2: IEnsureKeysMatchInterface<
  Omit<IFieldVo, 'id'>,
  typeof FIELD_VO_PROPERTIES
> = true;

export const getOptionsSchema = (type: FieldType) => {
  switch (type) {
    case FieldType.SingleLineText:
      return singlelineTextFieldOptionsSchema;
    case FieldType.LongText:
      return longTextFieldOptionsSchema;
    case FieldType.User:
      return userFieldOptionsSchema;
    case FieldType.Attachment:
      return attachmentFieldOptionsSchema;
    case FieldType.Checkbox:
      return checkboxFieldOptionsSchema;
    case FieldType.MultipleSelect:
      return selectFieldOptionsRoSchema;
    case FieldType.SingleSelect:
      return selectFieldOptionsRoSchema;
    case FieldType.Date:
      return dateFieldOptionsSchema;
    case FieldType.Number:
      return numberFieldOptionsRoSchema;
    case FieldType.Rating:
      return ratingFieldOptionsSchema;
    case FieldType.Formula:
      return formulaFieldOptionsSchema;
    case FieldType.Rollup:
      return rollupFieldOptionsSchema;
    case FieldType.Link:
      return linkFieldOptionsRoSchema;
    case FieldType.CreatedTime:
      return createdTimeFieldOptionsRoSchema;
    case FieldType.LastModifiedTime:
      return lastModifiedTimeFieldOptionsRoSchema;
    case FieldType.AutoNumber:
      return autoNumberFieldOptionsRoSchema;
    case FieldType.CreatedBy:
      return createdByFieldOptionsSchema;
    case FieldType.LastModifiedBy:
      return lastModifiedByFieldOptionsSchema;
    case FieldType.Duration:
    case FieldType.Count:
    case FieldType.Button:
      throw new Error('no implementation');
    default:
      assertNever(type);
  }
};

const refineOptions = (
  data: {
    type: FieldType;
    isLookup?: boolean;
    lookupOptions?: ILookupOptionsRo;
    options?: IFieldOptionsRo;
  },
  ctx: RefinementCtx
) => {
  const { type, isLookup, lookupOptions, options } = data;
  if (isLookup && !lookupOptions) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'lookupOptions is required when isLookup is true.',
    });
  }

  if (!isLookup && lookupOptions && type !== FieldType.Rollup) {
    ctx.addIssue({
      code: z.ZodIssueCode.custom,
      message: 'lookupOptions is not allowed when isLookup is not true.',
    });
  }

  if (!options) {
    return;
  }

  if (isLookup) {
    const result = commonOptionsSchema.safeParse(options);
    if (!result.success) {
      ctx.addIssue({
        path: ['options'],
        code: z.ZodIssueCode.custom,
        message: `RefineOptionsInLookupError: ${result.error.message}`,
      });
    }
    return;
  }

  const schema = getOptionsSchema(type);
  const result = schema && schema.safeParse(options);

  if (result && !result.success) {
    ctx.addIssue({
      path: ['options'],
      code: z.ZodIssueCode.custom,
      message: `RefineOptionsError: ${result.error.message}`,
    });
  }
};

const baseFieldRoSchema = fieldVoSchema
  .partial()
  .pick({
    type: true,
    name: true,
    unique: true,
    notNull: true,
    dbFieldName: true,
    isLookup: true,
    description: true,
  })
  .required({
    type: true,
  })
  .merge(
    z.object({
      name: fieldVoSchema.shape.name.min(1).optional(),
      description: fieldVoSchema.shape.description.nullable(),
      lookupOptions: lookupOptionsRoSchema.optional().openapi({
        description:
          'The lookup options for field, you need to configure it when isLookup attribute is true or field type is rollup.',
      }),
      options: unionFieldOptionsRoSchema.optional().openapi({
        description:
          "The options of the field. The configuration of the field's options depend on the it's specific type.",
      }),
    })
  );

export const convertFieldRoSchema = baseFieldRoSchema.superRefine(refineOptions);
export const createFieldRoSchema = baseFieldRoSchema
  .merge(
    z.object({
      id: z.string().startsWith(IdPrefix.Field).optional().openapi({
        description:
          'The id of the field that start with "fld", followed by exactly 16 alphanumeric characters `/^fld[\\da-zA-Z]{16}$/`. It is sometimes useful to specify an id at creation time',
        example: 'fldxxxxxxxxxxxxxxxx',
      }),
      order: z
        .object({
          viewId: z.string().openapi({
            description: 'You can only specify order in one view when create field',
          }),
          orderIndex: z.number(),
        })
        .optional(),
    })
  )
  .superRefine(refineOptions);

export const updateFieldRoSchema = z.object({
  name: baseFieldRoSchema.shape.name,
  description: baseFieldRoSchema.shape.description,
  dbFieldName: baseFieldRoSchema.shape.dbFieldName,
});

export type IFieldRo = z.infer<typeof createFieldRoSchema>;

export type IConvertFieldRo = z.infer<typeof convertFieldRoSchema>;

export type IUpdateFieldRo = z.infer<typeof updateFieldRoSchema>;

export const getFieldsQuerySchema = z.object({
  viewId: z.string().startsWith(IdPrefix.View).optional().openapi({
    description: 'The id of the view.',
  }),
  filterHidden: z.coerce.boolean().optional(),
  excludeFieldIds: z.array(z.string().startsWith(IdPrefix.Field)).optional(),
});

export type IGetFieldsQuery = z.infer<typeof getFieldsQuerySchema>;