Help me properly type this enum field creator plugin =)

Feature(s) impacted

We use a lot of enums, which are stored as integers in the database. Integers are pointless for a backoffice so we expose enum fields in FA instead.

We’re migrating to the new agent.

Adding an enum in the new agent requires one call to addField and one call to replaceFieldWriting with some boilerplate inside.

I figured it’d be a good way to try my hand at making a simple plugin, so here it is currently as a wip:

/**
 * To be used on a collection.
 * @param field Target field to turn into an enum, for example a status stored as a number
 * @param enumObject The object with enum descriptors as keys, and real values as... values.
 * @param enumFieldName If you want a different field name than `${field}Enum`
 */
export default function defineEnum<
  K extends keyof Schema,
  K2 extends TFieldName<Schema, K>,
>(
  field: K2,
  enumObject: Record<string, unknown>,
  enumFieldName: string | null = null,
) {
  return function (
    _dataSource: DataSourceCustomizer<Schema>,
    collection?: CollectionCustomizer<Schema, K>,
  ) {
    if (!collection) {
      throw new Error('defineEnum may only be use() on a collection')
    }
    collection
      .addField(enumFieldName ?? `${field}Enum`, {
        columnType: 'Enum',
        enumValues: Object.keys(enumObject),
        dependencies: [field],
        getValues: (records) => {
          const enumEntries = Object.entries(enumObject)
          return records.map(
            (r) => enumEntries.find(([, v]) => v === r[field])?.[0],
          )
        },
      })
      // @ts-expect-error TS does not detect the new field here
      .replaceFieldWriting(enumFieldName ?? `${field}Enum`, (v) => ({
        [field]: Object.entries(enumObject).find(([k]) => v === k)?.[1],
      }))
  }
}

Here is an example use:

const BandStatus = {
  JustCreated: 0,
  GrowingHigh: 50,
  BrokenUp: 100,
} as const

export function BandCustomizer(
  bands: CollectionCustomizer<Schema, 'Band'>,
) {
  bands
    .use(defineEnum('CurrentStatus', BandStatus))

I could probably use plugin options instead of a HOF. The reason I didn’t is because I can’t read documentation.

Observed behavior

The plugin does its job well, but I would love to improve my typing. Any tips?

Expected behavior

  1. The field parameter can be any string. It should ideally only allow field names of the collection;
  2. The enumObject values can be anything (unknown). They should ideally be only of the type of the database field.
  3. Removing the @ts-expect-error would be nice, but that’s just beyond my reach.

Context

  • Project / Team / Environment: N/A, this is on a migration project that is not in prod yet.
  • Agent technology: nodejs
  • Agent (forest package) name & version: 1.40.1
  • Typescript version: 5.4.1
  • Database type: MSSQL (with sequelize-typescript)
  • Recent changes made on your end if any: Migration.

Hello @kll :wave:

Superbe use case. Plugin are exactly there for this kind of “factorizing” behaviours.

You can found more documentation around home made plugins in there. → Write your own | Node.js Developer Guide

You can probably get the inspiration by looking at our public plugins. TColumnName<S, N>allow you to do what you need. It will list all column name for a given collection (the one you use the plugin).

export type Options<
  S extends TSchema = TSchema,
  N extends TCollectionName<S> = TCollectionName<S>,
> = {
  /** Name of the field that you want to use as a file-picker on the frontend */
  fieldname: TColumnName<S, N>;
};

I’m not sure to fully understand this one. You would love to detect the enum type values from the DB instead of the values from your code ?

We will try to help you. I will come back to you.

Regards,
Morgan

Hey again,

I’ve made a POC in our experimental repository. feat(define-enum): create a plugin for creating enum fields over an existing field by Thenkei · Pull Request #83 · ForestAdmin/forestadmin-experimental · GitHub

If it’s ok for you. We gonna add some tests and release it.

Regards,
Morgan

I’m getting your code locally and trying to get it through TypeScript =)

Basically, if the schema expects integer values for the source of the enum field then it should check that your object has integer values as well.
This might be pointless, I don’t know if enum values in databases are ever anything else but numbers.

I commented on the PR as well.

Oh ok. You want to check the initial field is matching the enum values to ensure it overlaps ?

import { createAgent } from '@forestadmin/agent';
import { Schema } from './typings';

import defineEnum from '@forestadmin-experimental/plugin-define-enum';
import type { DefineEnumOption } from '@forestadmin-experimental/plugin-define-enum';

const BandStatus = {
  JustCreated: 0,
  GrowingHigh: 50,
  BrokenUp: 100,
} as const

await createAgent<Schema>(Options)
  .addDataSource(DataSourceOptions)
  .customizeCollection('Band', usersCollection => {
    .use<DefineEnumOption<Schema, 'Band'>>(defineEnum, {
      fieldName: 'currentStatus',
      enumObject: BandStatus,
    })
  })

You can try with this one. Let me know if it works for you.

Regards,
Morgan

2 Likes

Yes. In the example, we would check that because the field currentStatus is a number, then the values of the BandStatus object should be number.
If enums are always stored as integers then it’s probably pointless though…

I saw that you merged your PR, I’m trying your code now =D

1 Like

(btw you folks are ninjas, appreciate it a lot)

3 Likes

It makes sense, it’s simple to add this when running the plugin (customisation step = starting the agent). I’m gonna open a new PR. But I think this might not be breaking (throw) ? :eyes:

We appreciate it too. You use case make sense and it’s a good practice to write plugins that can be reused and enhanced by the community. :pray:

Regards,
Morgan

1 Like

The solution works pretty well! I have a couple nitpicks from my dictatorial eslint config:

In defineEnum, the parameter dataSource should have the type DataSourceCustomizer<S>.
Similarly, collection should be collection? and have the type CollectionCustomizer<S, N>.

I’m also getting this error:


image

And finally, it seems that while fieldName is properly hinted by my IDE (allowing for some sweet sweet autocomplete juice), I can still input anything without errors as long as it’s a string.

Oh by the way, if you believe it could be useful to others then it could be on Smart Fields | Node.js Developer Guide (forestadmin.com)

This might be due to your TS config. It works well on my side. :astonished:

And I don’t have the issue and the auto-completion works on my end. :eyes:

1 Like

Unfortunately, for now we don’t really promote the @forestadmin-experimental (ideas from the community) within our main documentation. But I know that some people here would love to promote them to encourage external contributions !

It’s the typescript-eslint linter on strict settings… I’m trying it out to see if it really helps. Sorry for the confusion.

On the actual compilation side, I tried adding it on a new collection I’m migrating and the same error happened.

import { CollectionCustomizer } from '@forestadmin/agent'
import type { Schema } from '../../typings.js'
import defineEnum from '@forestadmin-experimental/plugin-define-enum'
import type { DefineEnumOption } from '@forestadmin-experimental/plugin-define-enum'

const MailtrailStatus = {
  EN_ATTENTE: 0,
  DELIVRE: 100,
  OUVERT: 101,
  CLIQUE: 102,

  REJETE: 1000,
} as const

export function MailTrailCustomizer(
  mailtrails: CollectionCustomizer<Schema, 'MailTrail'>,
) {
  mailtrails.use<DefineEnumOption<Schema, 'MailTrail'>>(defineEnum, {
    fieldName: 'CurrentStatus',
    enumObject: MailtrailStatus,
    enumFieldName: 'StatusEnum',
  })
}

Makes sense, perhaps most of your customers don’t really enjoy the nitty-gritty bits of tech here and there.

I’m not sure to really be able to help. I saw 2 things that can be improved.

  1. CollectionCustomizer are directly exported within typings
import type { MailTrailCustomizer } from '../../typings';
  1. You should import without the .js at the end since we generate typings.ts
import type { Schema } from '../../typings';

Let me know if you get better results.

Regards,

1 Like

Ah-ha! I did successfully unmess my tsconfig. Taking the opportunity to update everything during the migration was, to put it mildly, a bold move.

Everything works fine. I don’t know what amount of magic you’d need to be able to avoid the use of DefineEnumOption at the call site, but it’s great as it is. Thank you very much!

1 Like

Awesome :muscle: Have a nice experience with the tool.

I will forward you feedback to the team, we should be able to somehow make it work without the overhead of adding plugin options type (DefineEnumOption).

Kind regards,
Morgan

1 Like

@morganperre I’ve filed a new issue on the repo to extend the functionality of the plugin :slight_smile:

1 Like