diff --git a/package-lock.json b/package-lock.json index 3f892498f..b3605ae33 100644 --- a/package-lock.json +++ b/package-lock.json @@ -3188,6 +3188,10 @@ "resolved": "validate/phone", "link": true }, + "node_modules/@flatfile/plugin-validate-string": { + "resolved": "validate/string", + "link": true + }, "node_modules/@flatfile/plugin-view-mapped": { "resolved": "plugins/view-mapped", "link": true @@ -20905,14 +20909,17 @@ }, "validate/number": { "name": "@flatfile/plugin-validate-number", - "version": "1.0.0", - "license": "MIT", + "version": "0.0.0", + "license": "ISC", "dependencies": { "@flatfile/plugin-record-hook": "^1.7.0" }, "devDependencies": { "@flatfile/rollup-config": "^0.1.1" }, + "engines": { + "node": ">= 16" + }, "peerDependencies": { "@flatfile/listener": "^1.0.5" } @@ -20934,6 +20941,19 @@ "peerDependencies": { "@flatfile/listener": "^1.0.5" } + }, + "validate/string": { + "version": "0.0.1", + "license": "ISC", + "dependencies": { + "@flatfile/plugin-record-hook": "^1.7.0" + }, + "devDependencies": { + "@flatfile/rollup-config": "^0.1.1" + }, + "peerDependencies": { + "@flatfile/listener": "^1.0.5" + } } } } diff --git a/validate/phone/jest.config.js b/validate/phone/jest.config.js new file mode 100644 index 000000000..e6d7ca40b --- /dev/null +++ b/validate/phone/jest.config.js @@ -0,0 +1,16 @@ +module.exports = { + testEnvironment: 'node', + + transform: { + '^.+\\.tsx?$': 'ts-jest', + }, + setupFiles: ['../../test/dotenv-config.js'], + setupFilesAfterEnv: [ + '../../test/betterConsoleLog.js', + '../../test/unit.cleanup.js', + ], + testTimeout: 60_000, + globalSetup: '../../test/setup-global.js', + forceExit: true, + passWithNoTests: true, +} diff --git a/validate/phone/package.json b/validate/phone/package.json index 053e4d5dc..fa74a2424 100644 --- a/validate/phone/package.json +++ b/validate/phone/package.json @@ -42,7 +42,9 @@ "build:watch": "rollup -c --watch", "build:prod": "NODE_ENV=production rollup -c", "check": "tsc ./**/*.ts --noEmit --esModuleInterop", - "test": "jest ./**/*.spec.ts --config=../../jest.config.js --runInBand" + "test": "jest src/*.spec.ts --detectOpenHandles", + "test:unit": "jest src/*.spec.ts --testPathIgnorePatterns=.*\\.e2e\\.spec\\.ts$ --detectOpenHandles", + "test:e2e": "jest src/*.e2e.spec.ts --detectOpenHandles" }, "keywords": [ "flatfile-plugins", diff --git a/validate/phone/src/validate.phone.utils.spec.ts b/validate/phone/src/validate.phone.utils.spec.ts index 8a47185e4..771527b7f 100644 --- a/validate/phone/src/validate.phone.utils.spec.ts +++ b/validate/phone/src/validate.phone.utils.spec.ts @@ -3,7 +3,7 @@ import { NumberFormat } from 'libphonenumber-js'; describe('formatPhoneNumber', () => { it('should format a valid US phone number', () => { - const result = formatPhoneNumber('2125551234', 'US', NumberFormat.NATIONAL); + const result = formatPhoneNumber('2125551234', 'US', 'NATIONAL'); expect(result).toEqual({ formattedPhone: '(212) 555-1234', error: null, @@ -11,7 +11,7 @@ describe('formatPhoneNumber', () => { }); it('should format a valid UK phone number', () => { - const result = formatPhoneNumber('2071234567', 'GB', NumberFormat.INTERNATIONAL); + const result = formatPhoneNumber('2071234567', 'GB', 'INTERNATIONAL'); expect(result).toEqual({ formattedPhone: '+44 20 7123 4567', error: null, @@ -19,7 +19,7 @@ describe('formatPhoneNumber', () => { }); it('should return an error for an invalid phone number', () => { - const result = formatPhoneNumber('1234', 'US', NumberFormat.NATIONAL); + const result = formatPhoneNumber('1234', 'US', 'NATIONAL'); expect(result).toEqual({ formattedPhone: '1234', error: 'Invalid phone number format for US', @@ -27,15 +27,15 @@ describe('formatPhoneNumber', () => { }); it('should handle different number formats', () => { - const result = formatPhoneNumber('2125551234', 'US', NumberFormat.E164); + const result = formatPhoneNumber('2125551234', 'US', 'E.164'); expect(result).toEqual({ formattedPhone: '+12125551234', error: null, }); }); - it('should handle format options', () => { - const result = formatPhoneNumber('2125551234', 'US', NumberFormat.INTERNATIONAL, { formatExtension: 'national' }); + it('should handle format options with a valid fromCountry', () => { + const result = formatPhoneNumber('2125551234', 'US', 'INTERNATIONAL', { fromCountry: 'GB' }); expect(result).toEqual({ formattedPhone: '+1 212 555 1234', error: null, @@ -43,10 +43,10 @@ describe('formatPhoneNumber', () => { }); it('should return an error for an invalid country code', () => { - const result = formatPhoneNumber('2125551234', 'XX', NumberFormat.NATIONAL); + const result = formatPhoneNumber('2125551234', 'XX', 'NATIONAL'); expect(result).toEqual({ formattedPhone: '2125551234', - error: 'Error processing phone number', + error: 'Invalid phone number format for XX', }); }); }); diff --git a/validate/string/README.MD b/validate/string/README.MD new file mode 100644 index 000000000..8b0a0fbdb --- /dev/null +++ b/validate/string/README.MD @@ -0,0 +1,87 @@ + + +The `@flatfile/plugin-validate-string` plugin for string configuration and validation. This plugin combines multiple string validations in a single configuration, including regex pattern matching, length validation, case sensitivity, trimming options, and custom transformations. + +**Event Type:** +`listener.on('commit:created')` + + +## Features + +- Regular expression pattern matching +- Length validation (min, max, exact) +- Case type enforcement (lowercase, uppercase, titlecase) +- Whitespace trimming (leading, trailing) +- Custom transformation functions +- Configurable error messages +- Common regex patterns for email, phone, and URL validation +- Empty string handling + +## Installation + +To install the plugin, use npm: + +```bash +npm install @flatfile/plugin-validate-string +``` + +## Example Usage + +```typescript +import { FlatfileListener } from '@flatfile/listener'; +import { validateString } from '@flatfile/plugin-validate-string'; + +const listener = new FlatfileListener(); + +const stringConfig = { + fields: ['name'], + minLength: 2, + maxLength: 50, + caseType: 'titlecase', + errorMessages: { + length: 'Name must be between 2 and 50 characters', + case: 'Name must be in Title Case', + }, + +}; + +listener.use(validateString(stringConfig)); +``` + +**Pattern usage:** +```typescript +const config = { + fields: ['email'], + pattern: 'email' // Uses predefined email pattern +}; + +// Or with a custom pattern: +const customConfig = { + fields: ['customField'], + pattern: /^[A-Z]{3}-\d{2}$/ // Custom pattern for format like 'ABC-12' +}; +``` + +## Configuration + +The `validateString` accepts a `StringValidationConfig` object with the following properties: +- `fields`: string[] - Fields to validate +- `sheetSlug`: string - Sheet slug to validate (defaults to '**' all sheets) +- `pattern`: RegExp - A regular expression pattern to match against +- `pattern`: keyof typeof commonRegexPatterns | RegExp - A regular expression pattern to match against. You can use one of the predefined patterns ('email', 'phone', 'url') or provide a custom RegExp. The predefined patterns are: + - `email`: Validates email addresses + - `phone`: Validates phone numbers (10-14 digits, optional '+' prefix) + - `url`: Validates URLs (with or without protocol) +- `minLength`: number - Minimum length of the string +- `maxLength`: number - Maximum length of the string +- `exactLength`: number - Exact required length of the string +- `caseType`: 'lowercase' | 'uppercase' | 'titlecase' - Enforces specific case type +- `trim`: { leading?: boolean, trailing?: boolean } - Trims whitespace +- `emptyStringAllowed`: boolean - Whether empty strings are allowed +- `errorMessages`: Object with custom error messages for different validations + +## Behavior + +The plugin processes each record in the Flatfile import, applying the configured validations to the specified fields. If a validation fails, an error is added to the record for that field. If a custom transformation is specified and all validations pass, the transformed value is set for the field. + +The plugin uses the `recordHook` to process individual records, allowing for efficient and flexible validation and transformation of string fields during the import process. diff --git a/validate/string/jest.config.js b/validate/string/jest.config.js new file mode 100644 index 000000000..e6d7ca40b --- /dev/null +++ b/validate/string/jest.config.js @@ -0,0 +1,16 @@ +module.exports = { + testEnvironment: 'node', + + transform: { + '^.+\\.tsx?$': 'ts-jest', + }, + setupFiles: ['../../test/dotenv-config.js'], + setupFilesAfterEnv: [ + '../../test/betterConsoleLog.js', + '../../test/unit.cleanup.js', + ], + testTimeout: 60_000, + globalSetup: '../../test/setup-global.js', + forceExit: true, + passWithNoTests: true, +} diff --git a/validate/string/package.json b/validate/string/package.json new file mode 100644 index 000000000..bc0cc3dda --- /dev/null +++ b/validate/string/package.json @@ -0,0 +1,63 @@ +{ + "name": "@flatfile/plugin-validate-string", + "version": "0.0.0", + "description": "A Flatfile plugin for string configuration and validation", + "url": "https://github.com/FlatFilers/flatfile-plugins/tree/main/validate/string", + "main": "./dist/index.cjs", + "module": "./dist/index.mjs", + "types": "./dist/index.d.ts", + "browser": { + "./dist/index.cjs": "./dist/index.browser.cjs", + "./dist/index.mjs": "./dist/index.browser.mjs" + }, + "exports": { + "types": "./dist/index.d.ts", + "node": { + "import": "./dist/index.mjs", + "require": "./dist/index.cjs" + }, + "browser": { + "require": "./dist/index.browser.cjs", + "import": "./dist/index.browser.mjs" + }, + "default": "./dist/index.mjs" + }, + "source": "./src/index.ts", + "files": [ + "dist/**" + ], + "scripts": { + "build": "rollup -c", + "build:watch": "rollup -c --watch", + "build:prod": "NODE_ENV=production rollup -c", + "check": "tsc ./**/*.ts --noEmit --esModuleInterop", + "test": "jest src/*.spec.ts --detectOpenHandles", + "test:unit": "jest src/*.spec.ts --testPathIgnorePatterns=.*\\.e2e\\.spec\\.ts$ --detectOpenHandles", + "test:e2e": "jest src/*.e2e.spec.ts --detectOpenHandles" + }, + "keywords": [ + "flatfile-plugins", + "category-transform" + ], + "author": "Flatfile, Inc", + "license": "ISC", + "dependencies": { + "@flatfile/plugin-record-hook": "^1.7.0" + }, + "peerDependencies": { + "@flatfile/listener": "^1.0.5" + }, + "devDependencies": { + "@flatfile/rollup-config": "^0.1.1" + }, + "repository": { + "type": "git", + "url": "https://github.com/FlatFilers/flatfile-plugins.git", + "directory": "validate/string" + }, + "browserslist": [ + "> 0.5%", + "last 2 versions", + "not dead" + ] +} diff --git a/validate/string/rollup.config.mjs b/validate/string/rollup.config.mjs new file mode 100644 index 000000000..fafa813c6 --- /dev/null +++ b/validate/string/rollup.config.mjs @@ -0,0 +1,5 @@ +import { buildConfig } from '@flatfile/rollup-config' + +const config = buildConfig({}) + +export default config diff --git a/validate/string/src/index.ts b/validate/string/src/index.ts new file mode 100644 index 000000000..caf5636e4 --- /dev/null +++ b/validate/string/src/index.ts @@ -0,0 +1,2 @@ +export * from './validate.string.plugin' +export * from './validate.string.utils' diff --git a/validate/string/src/validate.string.plugin.spec.ts b/validate/string/src/validate.string.plugin.spec.ts new file mode 100644 index 000000000..1573a0f25 --- /dev/null +++ b/validate/string/src/validate.string.plugin.spec.ts @@ -0,0 +1,143 @@ +import { validateAndTransformString, StringValidationConfig } from '.' + +describe('validateAndTransformString', () => { + it('should validate empty string', () => { + const config: StringValidationConfig = { + fields: ['test'], + emptyStringAllowed: false, + } + const result = validateAndTransformString('', config) + expect(result.error).toBe('Field cannot be empty') + }) + + it('should allow empty string when configured', () => { + const config: StringValidationConfig = { + fields: ['test'], + emptyStringAllowed: true, + } + const result = validateAndTransformString('', config) + expect(result.error).toBeNull() + }) + + it('should validate pattern', () => { + const config: StringValidationConfig = { + fields: ['test'], + pattern: 'email', + } + const validResult = validateAndTransformString('test@example.com', config) + expect(validResult.error).toBeNull() + + const invalidResult = validateAndTransformString('not-an-email', config) + expect(invalidResult.error).toBe('Invalid format') + }) + + it('should validate custom pattern', () => { + const config: StringValidationConfig = { + fields: ['test'], + pattern: /^[A-Z]{3}$/, + } + const validResult = validateAndTransformString('ABC', config) + expect(validResult.error).toBeNull() + + const invalidResult = validateAndTransformString('ABCD', config) + expect(invalidResult.error).toBe('Invalid format') + }) + + it('should validate minLength', () => { + const config: StringValidationConfig = { + fields: ['test'], + minLength: 3, + } + const validResult = validateAndTransformString('abc', config) + expect(validResult.error).toBeNull() + + const invalidResult = validateAndTransformString('ab', config) + expect(invalidResult.error).toBe('Minimum length is 3') + }) + + it('should validate maxLength', () => { + const config: StringValidationConfig = { + fields: ['test'], + maxLength: 5, + } + const validResult = validateAndTransformString('abcde', config) + expect(validResult.error).toBeNull() + + const invalidResult = validateAndTransformString('abcdef', config) + expect(invalidResult.error).toBe('Maximum length is 5') + }) + + it('should validate exactLength', () => { + const config: StringValidationConfig = { + fields: ['test'], + exactLength: 4, + } + const validResult = validateAndTransformString('abcd', config) + expect(validResult.error).toBeNull() + + const invalidResult = validateAndTransformString('abc', config) + expect(invalidResult.error).toBe('Exact length must be 4') + }) + + it('should transform to lowercase', () => { + const config: StringValidationConfig = { + fields: ['test'], + caseType: 'lowercase', + } + const result = validateAndTransformString('ABC', config) + expect(result.value).toBe('abc') + expect(result.error).toBe('Field value must be in lowercase') + }) + + it('should transform to uppercase', () => { + const config: StringValidationConfig = { + fields: ['test'], + caseType: 'uppercase', + } + const result = validateAndTransformString('abc', config) + expect(result.value).toBe('ABC') + expect(result.error).toBe('Field value must be in uppercase') + }) + + it('should transform to titlecase', () => { + const config: StringValidationConfig = { + fields: ['test'], + caseType: 'titlecase', + } + const result = validateAndTransformString('hello world', config) + expect(result.value).toBe('Hello World') + expect(result.error).toBe('Field value must be in titlecase') + }) + + it('should trim leading whitespace', () => { + const config: StringValidationConfig = { + fields: ['test'], + trim: { leading: true }, + } + const result = validateAndTransformString(' abc', config) + expect(result.value).toBe('abc') + expect(result.error).toBe('Field value has leading or trailing whitespace') + }) + + it('should trim trailing whitespace', () => { + const config: StringValidationConfig = { + fields: ['test'], + trim: { trailing: true }, + } + const result = validateAndTransformString('abc ', config) + expect(result.value).toBe('abc') + expect(result.error).toBe('Field value has leading or trailing whitespace') + }) + + it('should use custom error messages', () => { + const config: StringValidationConfig = { + fields: ['test'], + pattern: 'email', + errorMessages: { + pattern: 'Custom pattern error', + }, + } + const result = validateAndTransformString('not-an-email', config) + expect(result.error).toBe('Custom pattern error') + }) +}) diff --git a/validate/string/src/validate.string.plugin.ts b/validate/string/src/validate.string.plugin.ts new file mode 100644 index 000000000..fde1a9ddb --- /dev/null +++ b/validate/string/src/validate.string.plugin.ts @@ -0,0 +1,33 @@ +import { FlatfileListener } from '@flatfile/listener' +import { recordHook } from '@flatfile/plugin-record-hook' +import { + StringValidationConfig, + validateAndTransformString, +} from './validate.string.utils' + +export function validateString(config: StringValidationConfig) { + return (listener: FlatfileListener) => { + listener.use( + recordHook(config.sheetSlug || '**', (record) => { + for (const field of config.fields) { + const value = record.get(field) as string + if (value !== null && value !== undefined) { + const { value: newValue, error } = validateAndTransformString( + value, + config + ) + if (error) { + record.addError(field, error) + } + if (newValue !== value) { + record.set(field, newValue) + } + } + } + return record + }) + ) + } +} + +export default validateString diff --git a/validate/string/src/validate.string.utils.ts b/validate/string/src/validate.string.utils.ts new file mode 100644 index 000000000..44ff28431 --- /dev/null +++ b/validate/string/src/validate.string.utils.ts @@ -0,0 +1,110 @@ +export interface StringValidationConfig { + fields: string[] + sheetSlug?: string + pattern?: keyof typeof commonRegexPatterns | RegExp + minLength?: number + maxLength?: number + exactLength?: number + caseType?: 'lowercase' | 'uppercase' | 'titlecase' + trim?: { + leading?: boolean + trailing?: boolean + } + emptyStringAllowed?: boolean + errorMessages?: { + pattern?: string + length?: string + case?: string + trim?: string + } +} +export const commonRegexPatterns = { + email: /^[^\s@]+@[^\s@]+\.[^\s@]+$/, + phone: /^\+?[\d\s-]{10,14}$/, + url: /^(https?:\/\/)?([\da-z\.-]+)\.([a-z\.]{2,6})([\/\w \.-]*)*\/?$/, +} + +export interface ValidationResult { + value: string + error: string | null +} + +export function validateAndTransformString( + value: string, + config: StringValidationConfig +): ValidationResult { + let transformedValue = value + let error: string | null = null + + if (!config.emptyStringAllowed && value.trim() === '') { + return { value, error: 'Field cannot be empty' } + } + + const pattern = + typeof config.pattern === 'string' + ? commonRegexPatterns[config.pattern] + : config.pattern + if (pattern && !pattern.test(value)) { + error = config.errorMessages?.pattern || 'Invalid format' + return { value, error } + } + + if (config.minLength && value.length < config.minLength) { + error = + config.errorMessages?.length || `Minimum length is ${config.minLength}` + return { value, error } + } + + if (config.maxLength && value.length > config.maxLength) { + error = + config.errorMessages?.length || `Maximum length is ${config.maxLength}` + return { value, error } + } + + if (config.exactLength && value.length !== config.exactLength) { + error = + config.errorMessages?.length || + `Exact length must be ${config.exactLength}` + return { value, error } + } + + if (config.caseType) { + switch (config.caseType) { + case 'lowercase': + transformedValue = value.toLowerCase() + break + case 'uppercase': + transformedValue = value.toUpperCase() + break + case 'titlecase': + transformedValue = value.replace( + /\w\S*/g, + (txt) => txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase() + ) + break + } + if (value !== transformedValue) { + error = + config.errorMessages?.case || + `Field value must be in ${config.caseType}` + return { value: transformedValue, error } + } + } + + if (config.trim) { + if (config.trim.leading) { + transformedValue = transformedValue.trimStart() + } + if (config.trim.trailing) { + transformedValue = transformedValue.trimEnd() + } + if (value !== transformedValue) { + error = + config.errorMessages?.trim || + 'Field value has leading or trailing whitespace' + return { value: transformedValue, error } + } + } + + return { value: transformedValue, error: null } +}