@wisemen/nestjs-custom-fields
v6.0.0
Published
Custom field definitions and values for NestJS applications with TypeORM persistence, DTO support, and runtime validation.
Maintainers
Keywords
Readme
@wisemen/nestjs-custom-fields
Custom field definitions and values for NestJS applications with TypeORM persistence, DTO support, and runtime validation.
Custom field definitions are developer-managed. This package does not target end-user managed custom field creation.
It is intended that custom field definitions are read-only for users.
Creating, updating, and deleting definitions is handled manually by developers
through migrations or playground.ts scripts.
Definition uniqueness is scoped by entityType. For non-tenant definitions,
key must be unique per entityType. For tenant-scoped definitions,
key + tenantUuid must be unique per entityType.
Step 1: Register The Exported Entity And Create The Table
This package already exports the effective CustomFieldDefinition TypeORM
entity. Add it to all TypeORM datasources yourself so TypeORM knows about the
table metadata. After that, generate and run a migration so the table and the
exported entity's partial unique indexes are created in your database.
import { DataSource } from 'typeorm'
import { CustomFieldDefinition } from '@wisemen/nestjs-custom-fields'
const datasource = new DataSource({
entities: ['dist/src/**/*.entity.js', CustomFieldDefinition]
})Step 2: Define Definitions And Insert Them In The Database
Create a definition with customFieldDefinition(...), then insert it yourself.
The package does not discover definitions automatically. At the moment this is
mainly done through a migration or through playground.ts.
For persisted definitions, tenantUuid is part of the definition shape and
should be null for non-tenant definitions or a tenant UUID for tenant-scoped
definitions. This is different from repository lookups, where tenantUuid is
optional and can simply be omitted.
key uniqueness is enforced per entityType. Use a unique key for global
definitions, and a unique key + tenantUuid combination for tenant-specific
definitions.
It is recommended to define your entityType values in an enum instead of
repeating string literals across your application.
import type { INestApplicationContext } from '@nestjs/common'
import { NestFactory } from '@nestjs/core'
import { JobContainer } from '@wisemen/app-container/fastify'
import { LocalizedString } from '@wisemen/localized-string'
import { DataSource } from 'typeorm'
import {
CustomFieldDefinition,
CustomFieldType,
customFieldDefinition
} from '@wisemen/nestjs-custom-fields'
enum CustomFieldEntityType {
TICKET = 'ticket'
}
export class Playground extends JobContainer {
async bootstrap(): Promise<INestApplicationContext> {
return await NestFactory.createApplicationContext(PlayGroundModule)
}
async execute(app: INestApplicationContext): Promise<void> {
const priorityField = customFieldDefinition(CustomFieldType.SINGLE_SELECT, {
tenantUuid: null,
entityType: CustomFieldEntityType.TICKET,
key: 'priority',
label: new LocalizedString([{ locale: 'en', value: 'Priority' }]),
description: null,
isRequired: true,
choices: [
{
value: 'low',
order: 1,
label: new LocalizedString([{ locale: 'en', value: 'Low' }])
},
{
value: 'high',
order: 2,
label: new LocalizedString([{ locale: 'en', value: 'High' }])
}
]
})
await app.get(DataSource).manager.insert(CustomFieldDefinition, priorityField)
}
}Views
Use views to control where a definition is shown. A definition is shown in a
view only when that view's key is in its views array; any view not listed
does not show it. order is the field's position within that view, so the same
field can be first in a detail page and last in a table. The package does not
sort by order; sort by it where you render the view.
View keys are app-defined strings, like entityType: the package stores them
as-is. Keep them in an enum shared with your frontend. views defaults to [],
so a definition without views is hidden everywhere. View keys must be non-empty
and unique per definition, and order must be a finite number.
const referenceField = customFieldDefinition(CustomFieldType.TEXT, {
tenantUuid: null,
entityType: CustomFieldEntityType.TICKET,
key: 'reference',
label: new LocalizedString([{ locale: 'en', value: 'Reference' }]),
description: null,
isRequired: false,
views: [
{ key: 'ticket-detail', order: 10 },
{ key: 'ticket-overview', order: 20 }
]
})When upgrading from 1.x to 2.0.0, generate a migration for the new views
column and backfill views for your existing definitions in the same release.
Without a backfill, every existing definition is hidden in every view.
Choice Settings
Choices of single and multi select fields take three optional settings:
isArchived: the choice can't be selected anew. Records that already have it keep it and can be saved unchanged. Pass the stored values aspreviousValuestovalidateCustomFieldValues(...)so it can tell the two apart. Show archived choices on records and in filters, but leave them out of the options of a form.group: a stable key to group choices, e.g. for reporting. The package only stores it.dependsOn:{ definitionKey, values }. The choice can only be selected when the definition with thatkeyhas one of those values. A choice withoutdependsOncan always be selected.
const componentField = customFieldDefinition(CustomFieldType.MULTI_SELECT, {
tenantUuid: null,
entityType: CustomFieldEntityType.TICKET,
key: 'component',
label: new LocalizedString([{ locale: 'en', value: 'Component' }]),
description: null,
isRequired: false,
choices: [
{
value: 'login_page',
label: new LocalizedString([{ locale: 'en', value: 'Login page' }]),
order: 1,
group: 'frontend',
dependsOn: { definitionKey: 'category', values: ['web'] }
},
{
value: 'legacy_portal',
label: new LocalizedString([{ locale: 'en', value: 'Legacy portal' }]),
order: 2,
isArchived: true
}
]
})The parent definition must be part of the definitions you validate against, and
its value part of the values: validate the full set of values of a record, not
only the ones that changed. A group can't be empty, a choice can't depend on
its own definition, and dependsOn.values can't be empty.
Reference Fields
Use CustomFieldType.REFERENCE for a field that points to another record. The
value is the referenced record's ID as a non-empty string. The package does not
know which entity is referenced: map the definition's entityType and key to
the right route in your frontend.
Rules are optional. format restricts the ID to a UUID
(ReferenceCustomFieldFormat.UUID), digits only
(ReferenceCustomFieldFormat.NUMERIC) or letters and digits only
(ReferenceCustomFieldFormat.ALPHANUMERIC), and regex adds a custom pattern.
When both are set, the value must pass both.
import { ReferenceCustomFieldFormat } from '@wisemen/nestjs-custom-fields'
const customerField = customFieldDefinition(CustomFieldType.REFERENCE, {
tenantUuid: null,
entityType: CustomFieldEntityType.TICKET,
key: 'customer',
label: new LocalizedString([{ locale: 'en', value: 'Customer' }]),
description: null,
isRequired: false,
rules: {
format: ReferenceCustomFieldFormat.UUID
}
})When upgrading from 3.x to 4.0.0, handle CustomFieldType.REFERENCE wherever
you switch over CustomFieldType or build a Record<CustomFieldType, ...>;
those no longer type-check until the new case is added. No database migration is
needed: type is stored as varchar and rules as jsonb.
Progress Fields
Use CustomFieldType.PROGRESS for a progress bar. The definition's choices
are the bar's segments, and rules set a ProgressCustomFieldColor for every
choice and an optional icon. The value is { total?, segments }: segments
is a list of { key, amount }, where key is a choice value and amount is a
finite number of at least 0. Keys can appear only once. A missing segment
counts as 0, so { segments: [] } is a valid value.
Each segment fills amount / total of the bar. Set total when it is known,
such as 100 for a percentage: it must be greater than 0, the amounts can not
add up to more than it, and the rest of the bar stays empty. Leave total out
when it is not known: the total is then the sum of the amounts and the bar is
always fully filled.
import { ProgressCustomFieldColor } from '@wisemen/nestjs-custom-fields'
const tasksField = customFieldDefinition(CustomFieldType.PROGRESS, {
tenantUuid: null,
entityType: CustomFieldEntityType.TICKET,
key: 'tasks',
label: new LocalizedString([{ locale: 'en', value: 'Tasks' }]),
description: null,
isRequired: false,
choices: [
{
value: 'completed',
order: 1,
label: new LocalizedString([{ locale: 'en', value: 'Completed' }])
},
{
value: 'in_progress',
order: 2,
label: new LocalizedString([{ locale: 'en', value: 'In progress' }])
}
],
rules: {
icon: 'tasks',
colors: {
completed: ProgressCustomFieldColor.SUCCESS,
in_progress: ProgressCustomFieldColor.WARNING
}
}
})
// 2 completed and 3 in progress, total unknown: 2/5 green, 3/5 yellow
const value = {
segments: [
{ key: 'completed', amount: 2 },
{ key: 'in_progress', amount: 3 }
]
}
// 20% completed: 20/100 green, the rest empty
const percentage = {
total: 100,
segments: [{ key: 'completed', amount: 20 }]
}The icon is an app-defined string, like entityType: the package stores it
as-is. Leave it out to render the bar without an icon; an empty string is
rejected.
When upgrading from 4.x to 5.0.0, handle CustomFieldType.PROGRESS wherever
you switch over CustomFieldType or build a Record<CustomFieldType, ...>;
those no longer type-check until the new case is added. No database migration is
needed: type is stored as varchar, and choices and rules as jsonb.
Step 3: Store Values On The Entity With The Custom Column
Resolved custom field values live on your own entity. Use
@CustomFieldValueColumn() for the persisted values column.
import { Entity, PrimaryGeneratedColumn } from 'typeorm'
import {
CustomFieldValueColumn,
type CustomFieldValue
} from '@wisemen/nestjs-custom-fields'
@Entity()
export class Ticket {
@PrimaryGeneratedColumn('uuid')
uuid: string
@CustomFieldValueColumn({ nullable: true })
customFields: CustomFieldValue[] | null
}Step 4: Receive And Show Values With DTOs
Requests and responses use the DTO layer from this package. For incoming
payloads, use CustomFieldValueDto with @IsCustomFields(). For outgoing
payloads, map stored values back with CustomFieldValueDto.from(...).
Calling parse() on a DTO turns the transport-friendly input into the domain
custom field value shape, including richer value objects when needed. For
example, a MonetaryDto is parsed into the package's monetary value object
instead of staying a plain JSON structure.
import { ApiProperty } from '@nestjs/swagger'
import {
CustomFieldValueApiExtraModels,
CustomFieldValueDto,
CustomFieldValueDtoApiProperty,
IsCustomFields
} from '@wisemen/nestjs-custom-fields'
@CustomFieldValueApiExtraModels()
export class UpdateTicketCommand {
@CustomFieldValueDtoApiProperty({ isArray: true })
@IsCustomFields()
customFields: CustomFieldValueDto[]
}
@CustomFieldValueApiExtraModels()
export class TicketResponseDto {
@ApiProperty({ type: String, format: 'uuid' })
uuid: string
@CustomFieldValueDtoApiProperty({ isArray: true })
customFields: CustomFieldValueDto[]
constructor(ticket: Ticket) {
this.uuid = ticket.uuid
this.customFields = ticket.customFields?.map(value => CustomFieldValueDto.from(value)) ?? []
}
}@IsCustomFields() already applies array validation, nested DTO validation,
and uniqueness on definitionUuid.
import {
CustomFieldType,
MonetaryCustomFieldValueDto
} from '@wisemen/nestjs-custom-fields'
const dto = new MonetaryCustomFieldValueDto()
dto.definitionUuid = definitionUuid
dto.value = {
amount: '12.50',
currency: 'EUR',
precision: 2
}
const parsed = dto.parse()
// parsed is now a domain custom field value object:
// {
// definitionUuid,
// type: CustomFieldType.MONETARY,
// value: dto.value.parse()
// }Step 5: Retrieve Definitions For Validation
When you process submitted values, retrieve the matching definitions through
CustomFieldDefinitionsRepository and validate against them with
validateCustomFieldValues(...). If you also want to return definitions from
an API, map the same repository results with CustomFieldDefinitionResponse.
To inject the repository, register CustomFieldDefinitionRepositoryModule in
the Nest module that owns your use case or controller.
validateCustomFieldValues(...) throws CustomFieldValueValidationError when
the submitted values do not match the definitions. When a record is updated,
pass its stored values as the third argument,
validateCustomFieldValues(definitions, values, { previousValues }), so archived
choices it already has stay valid (see Choice Settings). In controllers, document
that error according to the @wisemen/api-error package pattern by registering
it with @ApiErrorResponse(...).
import { Module } from '@nestjs/common'
import { CustomFieldDefinitionRepositoryModule } from '@wisemen/nestjs-custom-fields'
@Module({
imports: [CustomFieldDefinitionRepositoryModule],
providers: [UpdateTicketCustomFieldsUseCase]
})
export class UpdateTicketModule {}On repository lookups, tenantUuid is optional. Omit it to work with
non-tenant definitions, or set it to retrieve tenant-specific definitions in a
multi-tenant setup.
Pass view to retrieve only the definitions shown in that view. Results are
ordered by key, with or without view.
const definitions = await repository.findDefinitions({
entityType: CustomFieldEntityType.TICKET,
view: 'ticket-overview'
})After DTO parsing, validate the resolved values against the definitions that apply for that entity and tenant. This is the runtime check that enforces the definition rules.
import { Injectable } from '@nestjs/common'
import {
CustomFieldDefinitionResponse,
CustomFieldDefinitionsRepository,
validateCustomFieldValues
} from '@wisemen/nestjs-custom-fields'
enum CustomFieldEntityType {
TICKET = 'ticket'
}
@Injectable()
export class UpdateTicketCustomFieldsUseCase {
constructor(
private readonly repository: CustomFieldDefinitionsRepository
) {}
async execute(command: UpdateTicketCommand): Promise<void> {
const definitions = await this.repository.findDefinitions({
entityType: CustomFieldEntityType.TICKET
})
const values = command.customFields.map(value => value.parse())
validateCustomFieldValues(definitions, values)
// Persist the validated values here.
}
}If you want to return definitions from a read endpoint, use
CustomFieldDefinitionResponse to map the repository results into the response
shape:
async function findTicketDefinitions(
repository: CustomFieldDefinitionsRepository
): Promise<CustomFieldDefinitionResponse[]> {
const definitions = await repository.findDefinitions({
entityType: CustomFieldEntityType.TICKET
})
return definitions.map(definition => new CustomFieldDefinitionResponse(definition))
}import { ApiErrorResponse } from '@wisemen/api-error'
import { CustomFieldValueValidationError } from '@wisemen/nestjs-custom-fields'
@ApiErrorResponse(CustomFieldValueValidationError)
async update(...): Promise<void> {
// controller logic
}