feat(editor): add HARDREGEX/SOFTREGEX validation rules
Build / Build-and-ng-test (pull_request) Successful in 5m25s
Build / Build-and-test-development (pull_request) Successful in 14m56s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m35s

Two new DQ rule types apply regular expressions to cell values:
HARDREGEX blocks submission on a non-matching value (same path as the
existing CASE/MINVAL/MAXVAL rules); SOFTREGEX is display-only — a
non-matching value gets a yellow warning cell but can still submit,
so it's wired as a grid renderer rather than a validator, and mirrored
in the edit-record modal (which has no grid renderer to hook into) via
DcValidator.failsSoftRegex. Both rules exempt blank and SAS special
missing values, and fail open on a malformed pattern rather than
blocking every submission on that column. HARDREGEX takes precedence
when both rules apply to the same column, so a failing value renders
red/blocked, never yellow.
This commit is contained in:
YuryShkoda
2026-07-20 18:15:57 +03:00
parent 2dcae9060f
commit 17e4802895
14 changed files with 1681 additions and 932 deletions
+119
View File
@@ -115,8 +115,127 @@ context('editor tests: ', function () {
})
})
})
// REGEX_HARD_COL/REGEX_SOFT_COL only exist un-stripped on MPE_X_NEW (see
// RULE_DEMO_COLS in the getdata.js mock) — MPE_X_TEST stays clean to
// match the excel fixtures.
it('5 | Blocks submission of a value that fails HARDREGEX', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
scrollGridRight()
getCellByHeaderAndRow(1, 'REGEX_HARD_COL')
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type('not-an-email{enter}')
.then(() => {
submitTable(() => {
cy.get('.modal-body').then((modalBody: any) => {
if (
modalBody[0].innerHTML
.toLowerCase()
.includes(`invalid values are present`)
) {
done()
}
})
})
})
})
})
})
})
it('6 | Warns (but still submits) a value that fails SOFTREGEX', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
scrollGridRight()
getCellByHeaderAndRow(1, 'REGEX_SOFT_COL')
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type('not a postcode{enter}')
.then(() => {
getCellByHeaderAndRow(1, 'REGEX_SOFT_COL').should(
'have.class',
'dc-warning-cell'
)
submitTable(() => {
// Validation passed despite the SOFTREGEX warning: the
// confirm-submit modal's Submit button is enabled
// (validationDone === 1), not the "invalid values" abort.
cy.get('#submitBtn', { timeout: longerCommandTimeout })
.should('exist')
.should('not.be.disabled')
.then(() => done())
})
})
})
})
})
})
})
// Handsontable virtualizes columns — with 17 columns on MPE_X_NEW, only
// the ones in the current viewport actually exist in the DOM. Scroll all
// the way right so REGEX_HARD_COL/REGEX_SOFT_COL (the last two) render at
// all. Same technique already used in excel.cy.ts. Must be called (and
// re-settle) before any header/body query below, since scrolling replaces
// the previously-rendered column nodes.
const scrollGridRight = () => {
return cy
.get('#hotTable')
.find('div.ht_master.handsontable')
.find('div.wtHolder')
.scrollTo('right')
}
// Locates a body cell by its column's header text rather than a hardcoded
// childNodes index. Handsontable's hiddenColumns plugin (e.g. HIDDEN_COL,
// used by several demo columns ahead of REGEX_HARD_COL/REGEX_SOFT_COL in
// COLHEADERS) removes a hidden column's cells from BOTH the header row and
// every body row identically, so the header row's th index always lines up
// with the same-position childNode in any body row — this sidesteps having
// to know how many hidden columns precede the target. Re-queries both the
// header and body row fresh each call (rather than accepting a pre-fetched
// row) since virtualization/scrolling can replace either pane's nodes.
//
// The `.should()` retries until headerText actually shows up: Handsontable
// re-renders virtualized columns asynchronously after a scroll event, on
// its own schedule outside Cypress's command queue, so scrollTo() settling
// does not mean the target column has been rendered yet.
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
return cy
.get('.ht_clone_top .htCore thead tr th')
.should(($ths) => {
const texts = [...$ths].map((th) => th.innerText.trim())
expect(texts).to.include(headerText)
})
.then(($ths) => {
const index = [...$ths].findIndex(
(th) => th.innerText.trim() === headerText
)
return cy
.get('.ht_master tbody tr')
.then((rows: any) => rows[rowIndex].childNodes[index])
.then((cell) => cy.get(cell))
})
}
const clickOnEdit = (callback?: any) => {
cy.get('.btnCtrl button.btn-primary', { timeout: longerCommandTimeout })
.click()
@@ -84,6 +84,20 @@
status="warning"
></clr-icon>
<!-- SOFTREGEX: value doesn't match the expected pattern, but
submission is still allowed (unlike HARDREGEX, which is
surfaced via the existing invalid-data class below). -->
<clr-icon
*ngIf="
!currentRecordErrors.includes(colIndex) &&
currentRecordWarningCols.includes(col.key)
"
class="flex-unset position-absolute entry-input-left-offset"
shape="error-standard"
status="warning"
title="Value does not match the expected pattern"
></clr-icon>
<ng-container *ngSwitchCase="'numeric'">
<clr-input-container
*ngIf="
@@ -55,6 +55,7 @@ export class EditRecordComponent implements OnInit {
@Output() onPreviousRecord: EventEmitter<any> = new EventEmitter<any>()
public currentRecordInvalidCols: string[] = []
public currentRecordWarningCols: string[] = []
public generateEditRecordUrlLoading: boolean = false
public generatedRecordUrl: string | null = null
public addRecordUrl: string | null = null
@@ -120,9 +121,12 @@ export class EditRecordComponent implements OnInit {
*/
private revalidateRecordCol(colName: string, value: any) {
const colRules = this.currentRecordValidator?.getRule(colName)
this.validateRecordCol(colRules, value).then((valid: boolean) =>
this.updateValidationState(colName, valid)
)
this.updateWarningState(colName, value)
}
/**
@@ -221,6 +225,8 @@ export class EditRecordComponent implements OnInit {
this.tryAutoPopulateNotNull(event, colName, colRules, value)
}
})
this.updateWarningState(colName, value)
})
}
@@ -237,6 +243,22 @@ export class EditRecordComponent implements OnInit {
}
}
/**
* Updates the SOFTREGEX warning columns list — the modal's equivalent of
* makeRegexWarningRenderer in the grid (see DcValidator.failsSoftRegex).
*/
private updateWarningState(colName: string, value: any): void {
const failsSoftRegex =
this.currentRecordValidator?.failsSoftRegex(colName, value) ?? false
const index = this.currentRecordWarningCols.indexOf(colName)
if (!failsSoftRegex && index > -1) {
this.currentRecordWarningCols.splice(index, 1)
} else if (failsSoftRegex && index < 0) {
this.currentRecordWarningCols.push(colName)
}
}
/**
* Auto-populates NOTNULL default value when the field is empty and has a default
*/
@@ -264,6 +286,8 @@ export class EditRecordComponent implements OnInit {
this.validateRecordCol(colRules, defaultValue).then((isValid: boolean) => {
this.updateValidationState(colName, isValid)
})
this.updateWarningState(colName, defaultValue)
}
onNextRecordClick() {
@@ -0,0 +1,102 @@
import Handsontable from 'handsontable'
import { makeRegexWarningRenderer } from './regex-warning-renderer'
describe('makeRegexWarningRenderer', () => {
const buildHot = (data: any[]) => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data,
columns: [
{ data: 'val', renderer: makeRegexWarningRenderer('^[A-Z]{3}$') },
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
return { hot, container }
}
it('adds dc-warning-cell when the value fails the pattern', () => {
const { hot, container } = buildHot([
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }
])
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeTrue()
hot.destroy()
container.remove()
})
it('does not add dc-warning-cell when the value matches the pattern', () => {
const { hot, container } = buildHot([
{ val: 'ABC', _____DELETE__THIS__RECORD_____: 'No' }
])
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
hot.destroy()
container.remove()
})
it('suppresses the warning on a row marked for delete, even though the value fails the pattern', () => {
const { hot, container } = buildHot([
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'Yes' }
])
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
hot.destroy()
container.remove()
})
it('does not add dc-warning-cell for a SAS special missing value', () => {
const { hot, container } = buildHot([
{ val: '.a', _____DELETE__THIS__RECORD_____: 'No' }
])
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
hot.destroy()
container.remove()
})
it('does not throw and never warns on a malformed pattern', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }],
columns: [
{ data: 'val', renderer: makeRegexWarningRenderer('[unterminated') },
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
expect(() => hot.render()).not.toThrow()
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
hot.destroy()
container.remove()
})
it('is display-only — the stored value is untouched', () => {
const { hot, container } = buildHot([
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }
])
expect(hot.getDataAtCell(0, 0)).toEqual('abc')
hot.destroy()
container.remove()
})
})
@@ -0,0 +1,53 @@
import Handsontable from 'handsontable'
import { isRegexRuleExempt } from '../../shared/dc-validator/utils/isRegexRuleExempt'
/**
* Builds a display-only HOT renderer for SOFTREGEX: a value that fails the
* pattern gets a yellow `dc-warning-cell` class (styles.scss) instead of
* blocking submission — SOFTREGEX must never return false from the cell
* validator (that would paint htInvalid and block submit), so the warning
* lives purely at the render layer, same split as makeNumberFormatRenderer.
*
* Suppressed on rows marked for delete (_____DELETE__THIS__RECORD_____ =
* 'Yes') — a warning about data about to be removed is just noise.
*
* Falls back to no warning ever showing when the pattern itself is
* malformed, rather than breaking the cell.
*/
export const makeRegexWarningRenderer = (pattern?: string) => {
let regex: RegExp | null = null
try {
if (pattern) regex = new RegExp(pattern)
} catch (e) {
console.warn(`SOFTREGEX - invalid pattern, warning disabled: ${pattern}`)
regex = null
}
const baseRenderer = Handsontable.renderers.getRenderer('text')
return (
instance: any,
td: any,
row: number,
col: number,
prop: string | number,
value: any,
cellProperties: any
) => {
baseRenderer(instance, td, row, col, prop, value, cellProperties)
const markedForDelete =
instance.getDataAtRowProp(row, '_____DELETE__THIS__RECORD_____') === 'Yes'
const failsPattern =
!!regex && !isRegexRuleExempt(value) && !regex.test(value.toString())
if (failsPattern && !markedForDelete) {
td.classList.add('dc-warning-cell')
td.title = 'Value does not match the expected pattern'
}
return td
}
}
@@ -29,6 +29,8 @@ import { mapIntlCellTypes } from './utils/mapIntlCellTypes'
import { CustomAutocompleteEditor } from './editors/numericAutocomplete'
import { registerIntlCellTypes } from './cellTypes/intlCellTypes'
import { makeNumberFormatRenderer } from '../../editor/utils/renderers.utils'
import { makeRegexWarningRenderer } from '../../editor/utils/regex-warning-renderer'
import { isRegexRuleExempt } from './utils/isRegexRuleExempt'
export class DcValidator {
private rules: DcValidation[] = []
@@ -239,6 +241,32 @@ export class DcValidator {
return details
}
/**
* Whether a value fails a SOFTREGEX rule on the given column, if one
* exists. SOFTREGEX never goes through dqValidate/the cell validator (see
* setupValidations — it's a display-only grid renderer instead), so the
* edit-record modal, which has no grid renderer to hook into, uses this
* directly to show the same warning outside the grid. HARDREGEX takes
* precedence when both rules apply to the column, matching the grid's own
* behaviour (setupValidations skips wiring the SOFTREGEX renderer there
* too) — a dual-rule column should render red/blocked, never yellow.
*/
failsSoftRegex(col: string, value: any): boolean {
if (this.hasDqRules(col, ['HARDREGEX'])) return false
if (isRegexRuleExempt(value)) return false
const softRegexRule = this.dqrules.find(
(rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'SOFTREGEX'
)
if (!softRegexRule) return false
try {
return !new RegExp(softRegexRule.RULE_VALUE).test(value.toString())
} catch (e) {
return false
}
}
/**
* SOFTSELECT is not defined in DQ RULES
* This function fetches it's values and pushes in the DQ RULES array with SOFTSELECT type
@@ -395,6 +423,28 @@ export class DcValidator {
// editor/validator stay intact via `type`.
this.rules[i].numericFormat = undefined
}
// SOFTREGEX: display-only warning (yellow), never blocks submission —
// unlike HARDREGEX (see dq-validation.ts), which goes through the
// normal validator/dqValidate path instead. Last-wins against
// NUMBER_FORMAT if a column somehow carried both (not expected in
// practice — one formats numbers, the other pattern-matches text).
//
// HARDREGEX takes precedence when both apply to the same column: skip
// wiring this renderer entirely, so a failing value stays HOT's own
// red htInvalid (from HARDREGEX/dqValidate) rather than being
// visually overridden by this renderer's yellow dc-warning-cell.
if (
this.hasDqRules(ruleColName, ['SOFTREGEX']) &&
!this.hasDqRules(ruleColName, ['HARDREGEX'])
) {
const softRegexRule = this.getDqDetails(ruleColName).find(
(rule: DQRule) => rule.RULE_TYPE === 'SOFTREGEX'
)
this.rules[i].renderer = makeRegexWarningRenderer(
softRegexRule?.RULE_VALUE
)
}
}
const self = this
@@ -18,3 +18,5 @@ export type DQRuleTypes =
| 'HIDDEN'
| 'ROUND'
| 'NUMBER_FORMAT'
| 'HARDREGEX'
| 'SOFTREGEX'
@@ -414,6 +414,195 @@ describe('DC Validator', () => {
// what keeps SECRET_COL hidden here.
expect(dcValidator.getHiddenColumns()).toContain(2)
})
it('9 | blocks submission of a value that fails a HARDREGEX rule', () => {
// SOME_CHAR already has its own CASE=UPCASE rule in example_dqRules —
// 'AB1' passes that (it equals its own uppercase form) but fails a
// letters-only HARDREGEX pattern, isolating HARDREGEX's own effect
// rather than piggybacking on CASE rejecting the value too.
const dcValidator: DcValidator = new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
[
...example_dqRules,
{
BASE_COL: 'SOME_CHAR',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
],
example_dqData
)
const someCharRule = dcValidator.getRule('SOME_CHAR')
dcValidator.executeHotValidator(someCharRule!, 'ABC', (valid: boolean) => {
expect(valid).toBeTrue()
})
dcValidator.executeHotValidator(someCharRule!, 'AB1', (valid: boolean) => {
expect(valid).toBeFalse()
})
})
it('10 | wires a function renderer for a SOFTREGEX rule, without blocking submission', () => {
// SOME_CHAR_ANY carries no other DQ rules in the shared fixture, so this
// isolates SOFTREGEX's own wiring. The renderer's own pass/fail/delete-
// suppression behaviour is covered by regex-warning-renderer.spec.ts —
// this only proves setupValidations() assigns it and that, unlike
// HARDREGEX, a non-matching value still submits.
const dcValidator: DcValidator = new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
[
...example_dqRules,
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
],
example_dqData
)
const someCharAnyRule = dcValidator.getRule('SOME_CHAR_ANY')
expect(typeof someCharAnyRule?.renderer).toEqual('function')
dcValidator.executeHotValidator(
someCharAnyRule!,
'not uppercase',
(valid: boolean) => {
expect(valid).toBeTrue()
}
)
})
it('11 | HARDREGEX takes precedence over SOFTREGEX on a dual-rule column', () => {
// Both rules on the same column: HARDREGEX must win, so the cell
// renders red (blocked) via HOT's own htInvalid, not yellow. No
// SOFTREGEX renderer is wired at all here, so there's nothing that could
// visually compete with htInvalid for that column.
const dcValidator: DcValidator = new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
[
...example_dqRules,
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
},
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
],
example_dqData
)
const rule = dcValidator.getRule('SOME_CHAR_ANY')
dcValidator.executeHotValidator(
rule!,
'not uppercase',
(valid: boolean) => {
expect(valid).toBeFalse()
}
)
expect(rule?.renderer).toBeUndefined()
})
describe('12 | failsSoftRegex (edit-record modal support for SOFTREGEX)', () => {
// SOFTREGEX never goes through dqValidate (see the wiring in
// setupValidations), so the edit-record modal — which has no grid
// renderer to hook into — calls this directly to show the same warning.
const buildValidator = (dqRules: DQRule[]) =>
new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
dqRules,
example_dqData
)
it('is true for a value that fails the pattern', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
])
expect(
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'lowercase')
).toBeTrue()
})
it('is false for a value that matches the pattern', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
])
expect(
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'UPPERCASE')
).toBeFalse()
})
it('is false for a column with no SOFTREGEX rule', () => {
const dcValidator = buildValidator([])
expect(
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'anything')
).toBeFalse()
})
it('is false for blank/special-missing values, same exemption as HARDREGEX', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
])
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', '')).toBeFalse()
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', '.a')).toBeFalse()
})
it('is false when the column also has HARDREGEX (precedence — no yellow on a red cell)', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
},
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
}
])
expect(
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'lowercase')
).toBeFalse()
})
})
})
/** Minimal cols[] entry — only the fields rule ordering depends on. */
@@ -0,0 +1,19 @@
import { isRegexRuleExempt } from './isRegexRuleExempt'
describe('isRegexRuleExempt', () => {
it('exempts blank/undefined/null', () => {
expect(isRegexRuleExempt('')).toBeTrue()
expect(isRegexRuleExempt(undefined)).toBeTrue()
expect(isRegexRuleExempt(null)).toBeTrue()
})
it('exempts SAS special missing values', () => {
expect(isRegexRuleExempt('.')).toBeTrue()
expect(isRegexRuleExempt('.a')).toBeTrue()
expect(isRegexRuleExempt('_')).toBeTrue()
})
it('does not exempt an ordinary value', () => {
expect(isRegexRuleExempt('ABC123')).toBeFalse()
})
})
@@ -0,0 +1,13 @@
import { isSpecialMissing } from '@sasjs/utils/input/validators'
/**
* HARDREGEX/SOFTREGEX both skip pattern-matching for blank and SAS special
* missing values (., .a-.z, _) — the same exemption NOTNULL/MINVAL/MAXVAL
* already apply elsewhere, since neither convention represents a real
* formatted value the pattern is meant to check.
*/
export const isRegexRuleExempt = (value: any): boolean => {
if (value === undefined || value === null || value === '') return true
return isSpecialMissing(value)
}
@@ -0,0 +1,72 @@
import { DQRule } from '../models/dq-rules.model'
import { dqValidate } from './dq-validation'
const rule = (overrides: Partial<DQRule>): DQRule => ({
BASE_COL: 'SOME_CHAR',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '',
X: 0,
...overrides
})
describe('dqValidate - HARDREGEX', () => {
it('accepts a value matching the pattern', () => {
const rules = [rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' })]
expect(dqValidate(rules, 'ABC1234')).toBeTrue()
})
it('rejects a value not matching the pattern', () => {
const rules = [rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' })]
expect(dqValidate(rules, 'abc1234')).toBeFalse()
})
it('treats blank/undefined/null as valid regardless of the pattern', () => {
const rules = [rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' })]
expect(dqValidate(rules, '')).toBeTrue()
expect(dqValidate(rules, undefined)).toBeTrue()
expect(dqValidate(rules, null)).toBeTrue()
})
it('treats SAS special missing values as valid regardless of the pattern', () => {
const rules = [rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' })]
expect(dqValidate(rules, '.')).toBeTrue()
expect(dqValidate(rules, '.a')).toBeTrue()
expect(dqValidate(rules, '_')).toBeTrue()
})
it('fails open (treats as valid) when the pattern is malformed', () => {
const rules = [rule({ RULE_VALUE: '[unterminated' })]
expect(dqValidate(rules, 'anything')).toBeTrue()
})
it('handles a more elaborate pattern (multiple character classes, quantifiers, an escaped literal dot)', () => {
// Same email pattern used in the getdata.js mock's REGEX_HARD_COL demo.
const rules = [rule({ RULE_VALUE: '[\\w.]+@[\\w]+\\.[a-z]{2,}' })]
expect(dqValidate(rules, 'user@example.com')).toBeTrue()
expect(dqValidate(rules, 'user.name@sub.example.co')).toBeTrue()
expect(dqValidate(rules, 'not-an-email')).toBeFalse()
// No TLD to satisfy [a-z]{2,} after the escaped dot.
expect(dqValidate(rules, 'user@example')).toBeFalse()
})
it('still evaluates a second, unrelated rule on the same column', () => {
const rules = [
rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' }),
rule({ RULE_TYPE: 'NOTNULL', RULE_VALUE: '' })
]
// Passes HARDREGEX (blank is exempt) but fails NOTNULL — the NOTNULL
// rule still runs even though HARDREGEX is earlier in the array.
expect(dqValidate(rules, '')).toBeFalse()
// Passes NOTNULL (non-blank) but fails HARDREGEX's pattern.
expect(dqValidate(rules, 'nope')).toBeFalse()
// Passes both.
expect(dqValidate(rules, 'ABC1234')).toBeTrue()
})
})
@@ -1,5 +1,6 @@
import { DQRule } from '../models/dq-rules.model'
import { specialMissingNumericValidator } from './hot-custom-validators'
import { isRegexRuleExempt } from '../utils/isRegexRuleExempt'
const dqValidation: {
[key: string]: (value: any, ruleValue: string | number) => boolean
@@ -46,6 +47,20 @@ const dqValidation: {
},
NOTNULL: (value: any, ruleValue: string | number): boolean => {
return value !== undefined && value !== null && value.toString().length > 0
},
// Pattern is used as authored, not auto-anchored — a rule author who
// wants a full-value match must write ^...$ themselves.
HARDREGEX: (value: any, ruleValue: string | number): boolean => {
if (isRegexRuleExempt(value)) return true
try {
return new RegExp(ruleValue.toString()).test(value.toString())
} catch (e) {
console.warn(
`HARDREGEX - invalid pattern, treated as always-valid: ${ruleValue}`
)
return true
}
}
}
+971 -926
View File
File diff suppressed because it is too large Load Diff
+38 -6
View File
@@ -50,7 +50,9 @@ function makeRows(n) {
READONLY_COL: "Readonly default",
HIDDEN_COL: "Hidden default",
ROUND_COL: Number((i + 1 + i / 7).toFixed(5)), // rounds on edit
NUMFMT_COL: 1000 + i * 12.5 // shown as EUR
NUMFMT_COL: 1000 + i * 12.5, // shown as EUR
REGEX_HARD_COL: "user@example.com", // HARDREGEX: email — starts valid
REGEX_SOFT_COL: "SW1A 1AA" // SOFTREGEX: UK postcode — starts valid
})
}
return rows
@@ -231,6 +233,30 @@ let webouts = {
DESC: "Number format: displayed as EUR currency (value unchanged)",
LONGDESC: "",
COLTYPE: "{\"data\":\"NUMFMT_COL\",\"type\":\"numeric\",\"format\":\"0\"}"
},
{
NAME: "REGEX_HARD_COL",
VARNUM: 15,
LABEL: "REGEX_HARD_COL",
FMTNAME: "",
DDTYPE: "CHARACTER",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "HARDREGEX: must be a valid email address or submission is blocked",
LONGDESC: "",
COLTYPE: "{\"data\":\"REGEX_HARD_COL\"}"
},
{
NAME: "REGEX_SOFT_COL",
VARNUM: 16,
LABEL: "REGEX_SOFT_COL",
FMTNAME: "",
DDTYPE: "CHARACTER",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "SOFTREGEX: should be a valid UK postcode, shown as a yellow warning if not, but can still submit",
LONGDESC: "",
COLTYPE: "{\"data\":\"REGEX_SOFT_COL\"}"
}
],
dqdata: [
@@ -254,7 +280,9 @@ let webouts = {
{ BASE_COL: "READONLY_COL", RULE_TYPE: "READONLY", RULE_VALUE: "Readonly default" },
{ BASE_COL: "HIDDEN_COL", RULE_TYPE: "HIDDEN", RULE_VALUE: "Hidden default" },
{ BASE_COL: "ROUND_COL", RULE_TYPE: "ROUND", RULE_VALUE: "2" },
{ BASE_COL: "NUMFMT_COL", RULE_TYPE: "NUMBER_FORMAT", RULE_VALUE: '{"style":"currency","currency":"EUR"}' }
{ BASE_COL: "NUMFMT_COL", RULE_TYPE: "NUMBER_FORMAT", RULE_VALUE: '{"style":"currency","currency":"EUR"}' },
{ BASE_COL: "REGEX_HARD_COL", RULE_TYPE: "HARDREGEX", RULE_VALUE: "[\\w.]+@[\\w]+\\.[a-z]{2,}" },
{ BASE_COL: "REGEX_SOFT_COL", RULE_TYPE: "SOFTREGEX", RULE_VALUE: "[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}" }
],
dsmeta: [
{ ODS_TABLE: "ATTRIBUTES", NAME: "Data Set Name", VALUE: "DC996664.MPE_X_TEST" },
@@ -305,7 +333,9 @@ let webouts = {
{ NAME: "readonly_col", MAXLEN: 16 },
{ NAME: "hidden_col", MAXLEN: 14 },
{ NAME: "round_col", MAXLEN: 8 },
{ NAME: "numfmt_col", MAXLEN: 8 }
{ NAME: "numfmt_col", MAXLEN: 8 },
{ NAME: "regex_hard_col", MAXLEN: 128 },
{ NAME: "regex_soft_col", MAXLEN: 128 }
],
query: [],
sasdata: makeRows(100),
@@ -325,12 +355,14 @@ let webouts = {
READONLY_COL: { format: "$200.", label: "READONLY_COL", length: "200", type: "char" },
HIDDEN_COL: { format: "$200.", label: "HIDDEN_COL", length: "200", type: "char" },
ROUND_COL: { format: "best.", label: "ROUND_COL", length: "8", type: "num" },
NUMFMT_COL: { format: "best.", label: "NUMFMT_COL", length: "8", type: "num" }
NUMFMT_COL: { format: "best.", label: "NUMFMT_COL", length: "8", type: "num" },
REGEX_HARD_COL: { format: "$128.", label: "REGEX_HARD_COL", length: "128", type: "char" },
REGEX_SOFT_COL: { format: "$128.", label: "REGEX_SOFT_COL", length: "128", type: "char" }
}
},
sasparams: [
{
COLHEADERS: "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,SOME_HARDSELECT,SOME_NUM,SOME_DATE,SOME_DATETIME,SOME_TIME,SOME_SHORTNUM,SOME_BESTNUM,READONLY_COL,HIDDEN_COL,ROUND_COL,NUMFMT_COL",
COLHEADERS: "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,SOME_CHAR,SOME_DROPDOWN,SOME_HARDSELECT,SOME_NUM,SOME_DATE,SOME_DATETIME,SOME_TIME,SOME_SHORTNUM,SOME_BESTNUM,READONLY_COL,HIDDEN_COL,ROUND_COL,NUMFMT_COL,REGEX_HARD_COL,REGEX_SOFT_COL",
FILTER_TEXT: FILTER_TEXT,
PKCNT: 1,
PK: "PRIMARY_KEY_FIELD",
@@ -1519,7 +1551,7 @@ let webouts = {
// MPE_X_TEST keeps matching the excel upload fixtures (which predate these
// columns). The demo tables are built lazily (below) so we don't clone the large
// MPE_X_TEST payload unless MPE_X_NEW is actually requested.
const RULE_DEMO_COLS = ['SOME_HARDSELECT', 'READONLY_COL', 'HIDDEN_COL', 'ROUND_COL', 'NUMFMT_COL']
const RULE_DEMO_COLS = ['SOME_HARDSELECT', 'READONLY_COL', 'HIDDEN_COL', 'ROUND_COL', 'NUMFMT_COL', 'REGEX_HARD_COL', 'REGEX_SOFT_COL']
function stripRuleCols(t) {
const uc = new Set(RULE_DEMO_COLS)