feat(editor): add HARDREGEX/SOFTREGEX validation rules
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:
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user