Compare commits

...
5 Commits
Author SHA1 Message Date
allan f171375899 Merge pull request 'feat(editor): show applied HARDREGEX/SOFTREGEX pattern in column info dropdown and cell tooltip' (#284) from regex-info into additional-validations-regex
Build / Build-and-ng-test (pull_request) Successful in 5m50s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m53s
Build / Build-and-test-development (pull_request) Successful in 20m59s
Reviewed-on: #284
2026-07-27 11:39:29 +00:00
YuryShkoda 57db1179a9 feat(editor): evaluate HARDREGEX/SOFTREGEX independently instead of hard-wins precedence
Build / Build-and-ng-test (pull_request) Successful in 4m59s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m21s
Build / Build-and-test-development (pull_request) Successful in 21m3s
Both rules can now apply to one column: HARDREGEX still blocks submission
and takes its own tooltip, but SOFTREGEX is evaluated (and shown) whenever
HARDREGEX passes, instead of being silently suppressed whenever HARDREGEX
was merely present. Column-header info dropdown labels each rule separately
when a column has both.
2026-07-27 14:15:36 +03:00
allan d13fab267f Merge branch 'additional-validations-regex' into regex-info
Build / Build-and-ng-test (pull_request) Successful in 5m35s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m51s
Build / Build-and-test-development (pull_request) Successful in 20m2s
2026-07-25 18:17:36 +00:00
allan aaf406b386 Merge branch 'additional-validations-regex' into regex-info
Build / Build-and-ng-test (pull_request) Successful in 4m58s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m51s
Build / Build-and-test-development (pull_request) Successful in 19m50s
2026-07-24 15:43:45 +00:00
YuryShkoda 39c8855f37 feat(editor): show applied HARDREGEX/SOFTREGEX pattern in column info dropdown
Build / Build-and-ng-test (pull_request) Failing after 1m44s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 21m25s
Add DcValidator.getRegexRuleValue (HARDREGEX takes precedence), thread it
through buildColInfoHtml, and cover it with a Cypress test.
2026-07-24 17:15:42 +03:00
9 changed files with 643 additions and 71 deletions
+149 -4
View File
@@ -212,6 +212,12 @@ context('editor tests: ', function () {
.clear()
.type('not-an-email{enter}')
.then(() => {
getCellByHeaderAndRow(1, 'REGEX_HARD_COL').should(
'have.attr',
'title',
'REGEX: /[\\w.]+@[\\w]+\\.[a-z]{2,}/'
)
submitTable(() => {
cy.get('.modal-body').then((modalBody: any) => {
if (
@@ -245,10 +251,13 @@ context('editor tests: ', function () {
.clear()
.type('not a postcode{enter}')
.then(() => {
getCellByHeaderAndRow(1, 'REGEX_SOFT_COL').should(
'have.class',
'dc-warning-cell'
)
getCellByHeaderAndRow(1, 'REGEX_SOFT_COL')
.should('have.class', 'dc-warning-cell')
.and(
'have.attr',
'title',
'REGEX: /[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}/'
)
submitTable(() => {
// Validation passed despite the SOFTREGEX warning: the
@@ -264,6 +273,124 @@ context('editor tests: ', function () {
})
})
})
it('9 | Info dropdown shows the applied HARDREGEX/SOFTREGEX pattern', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
scrollGridRight()
openColumnDropdown('REGEX_HARD_COL')
cy.get('.htDropdownMenu').should(($menu) => {
expect($menu.text()).to.include('REGEX: /[\\w.]+@[\\w]+\\.[a-z]{2,}/')
})
cy.get('body').click(0, 0) // close menu
openColumnDropdown('REGEX_SOFT_COL')
cy.get('.htDropdownMenu').should(($menu) => {
expect($menu.text()).to.include(
'REGEX: /[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}/'
)
})
})
})
})
it('10 | Info dropdown labels HARDREGEX/SOFTREGEX separately when a column has both', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
scrollGridRight()
openColumnDropdown('REGEX_BOTH_COL')
cy.get('.htDropdownMenu').should(($menu) => {
const text = $menu.text()
expect(text).to.include('HARDREGEX: /^[A-Z0-9_-]+$/')
expect(text).to.include('SOFTREGEX: /^.{5,10}$/')
})
})
})
})
it('11 | REGEX_BOTH_COL: a HARDREGEX failure blocks submission and sets its own tooltip, not yellow', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
scrollGridRight()
// 'bad value' fails HARDREGEX (lowercase + space) but is 9 chars,
// within SOFTREGEX's 5-10 range - isolates the hard-only failure.
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type('bad value{enter}')
.then(() => {
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
.should('not.have.class', 'dc-warning-cell')
.and('have.attr', 'title', 'REGEX: /^[A-Z0-9_-]+$/')
submitTable(() => {
cy.get('.modal-body').then((modalBody: any) => {
if (
modalBody[0].innerHTML
.toLowerCase()
.includes(`invalid values are present`)
) {
done()
}
})
})
})
})
})
})
})
it('12 | REGEX_BOTH_COL: passing HARDREGEX but failing SOFTREGEX warns without blocking', (done) => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_new')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
scrollGridRight()
// 'AB' passes HARDREGEX (uppercase only) but fails SOFTREGEX (too
// short) - previously always inert whenever a column had both rules.
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type('AB{enter}')
.then(() => {
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
.should('have.class', 'dc-warning-cell')
.and('have.attr', 'title', 'REGEX: /^.{5,10}$/')
submitTable(() => {
cy.get('#submitBtn', { timeout: longerCommandTimeout })
.should('exist')
.should('not.be.disabled')
.then(() => done())
})
})
})
})
})
})
})
// Handsontable virtualizes columns — with 17 columns on MPE_X_NEW, only
@@ -280,6 +407,24 @@ const scrollGridRight = () => {
.scrollTo('right')
}
// Opens a column header's dropdown menu to reach its `info` item, which
// has a custom renderer showing NAME/LABEL/TYPE/LENGTH/FORMAT and, when the
// column has a HARDREGEX/SOFTREGEX rule, the applied pattern. Clicking the
// header text first selects the column - the renderer reads
// hot.getSelected() to decide which column to describe.
const openColumnDropdown = (headerText: string) => {
cy.get('#hotTable .ht_clone_top .htCore thead button.changeType', {
timeout: longerCommandTimeout
})
.parents('th')
.filter((_, th) => Cypress.$(th).text().includes(headerText))
.last()
.as('targetHeader')
cy.get('@targetHeader').click()
cy.get('@targetHeader').find('button.changeType').click({ force: true })
}
// 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
+9 -1
View File
@@ -3254,7 +3254,15 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
colName = this.hotInstance?.colToProp(selectedCol) as string
colInfo = this.$dataFormats?.vars[colName]
textInfo = buildColInfoHtml(colName, colInfo)
const { hardRegexValue, softRegexValue } =
this.dcValidator?.getRegexRuleValues(colName) || {}
textInfo = buildColInfoHtml(
colName,
colInfo,
hardRegexValue,
softRegexValue
)
}
elem.innerHTML = textInfo
@@ -19,13 +19,14 @@ describe('makeRegexWarningRenderer', () => {
return { hot, container }
}
it('adds dc-warning-cell when the value fails the pattern', () => {
it('adds dc-warning-cell and a REGEX: title 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()
expect(td?.title).toEqual('REGEX: ^[A-Z]{3}$')
hot.destroy()
container.remove()
@@ -128,4 +129,154 @@ describe('makeRegexWarningRenderer', () => {
hot.destroy()
container.remove()
})
describe('HARDREGEX (second, optional pattern)', () => {
// HARDREGEX already blocks submission via dqValidate/HOT's own
// htInvalid (unchanged, untouched here) - this renderer only adds the
// matching 'REGEX: <pattern>' title on top, so a column with only a
// HARDREGEX rule (no SOFTREGEX at all) still tells the user why a cell
// is red, not just that it is.
const buildHardOnlyHot = (value: string) => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ val: value, _____DELETE__THIS__RECORD_____: 'No' }],
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer(undefined, '^[A-Z]{3}$')
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
return { hot, container }
}
it('sets a REGEX: title (no dc-warning-cell) when only HARDREGEX fails', () => {
const { hot, container } = buildHardOnlyHot('abc')
const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
expect(td?.title).toEqual('REGEX: ^[A-Z]{3}$')
hot.destroy()
container.remove()
})
it('sets no title when the value passes HARDREGEX', () => {
const { hot, container } = buildHardOnlyHot('ABC')
const td = hot.getCell(0, 0)
expect(td?.title).toEqual('')
hot.destroy()
container.remove()
})
it('suppresses the HARDREGEX title on a row marked for delete', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ val: 'abc', _____DELETE__THIS__RECORD_____: 'Yes' }],
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer(undefined, '^[A-Z]{3}$')
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
expect(hot.getCell(0, 0)?.title).toEqual('')
hot.destroy()
container.remove()
})
it('prefers the HARDREGEX title over SOFTREGEX when a value fails both', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ val: 'ab', _____DELETE__THIS__RECORD_____: 'No' }],
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer('^.{5,10}$', '^[A-Z]{3}$')
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
const td = hot.getCell(0, 0)
// 'ab' fails HARDREGEX (not 3 uppercase letters) AND SOFTREGEX (too
// short) - HARDREGEX wins: its title shows, and no yellow class is
// added (HOT's own red htInvalid governs this cell instead).
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
expect(td?.title).toEqual('REGEX: ^[A-Z]{3}$')
hot.destroy()
container.remove()
})
it('falls through to SOFTREGEX when the value passes HARDREGEX but fails SOFTREGEX', () => {
const container = document.createElement('div')
document.body.appendChild(container)
const hot = new Handsontable(container, {
data: [{ val: 'AB', _____DELETE__THIS__RECORD_____: 'No' }],
columns: [
{
data: 'val',
renderer: makeRegexWarningRenderer('^.{5,10}$', '^[A-Z0-9]+$')
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
const td = hot.getCell(0, 0)
// 'AB' passes HARDREGEX (uppercase/digits) but fails SOFTREGEX (too
// short) - this is the case that was previously always inert (no
// warning ever shown for a column with both rules).
expect(td?.classList.contains('dc-warning-cell')).toBeTrue()
expect(td?.title).toEqual('REGEX: ^.{5,10}$')
hot.destroy()
container.remove()
})
it('does not throw and never warns on a malformed HARDREGEX 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(undefined, '[unterminated')
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
expect(() => hot.render()).not.toThrow()
expect(hot.getCell(0, 0)?.title).toEqual('')
hot.destroy()
container.remove()
})
})
})
@@ -2,28 +2,43 @@ import Handsontable from 'handsontable'
import { isRegexRuleExempt } from '../../shared/dc-validator/utils/isRegexRuleExempt'
import { parseRegexRule } from '../../shared/dc-validator/utils/parseRegexRule'
const compileRegex = (
pattern: string | undefined,
ruleType: 'SOFTREGEX' | 'HARDREGEX'
): RegExp | null => {
try {
return pattern ? parseRegexRule(pattern) : null
} catch (e) {
console.warn(`${ruleType} - invalid pattern, warning disabled: ${pattern}`)
return null
}
}
/**
* 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.
* Builds a display-only HOT renderer for HARDREGEX/SOFTREGEX: neither ever
* returns false from the cell validator here (that's dqValidate's job for
* HARDREGEX, which blocks submission and paints HOT's own red htInvalid
* independently of this renderer) - this renderer only adds the matching
* `REGEX: <pattern>` title, plus a yellow `dc-warning-cell` class when only
* SOFTREGEX fails, same split as makeNumberFormatRenderer.
*
* A column can carry both rules at once. Hard is validated first: a value
* failing HARDREGEX gets its title (no yellow class - red htInvalid already
* covers the color), and SOFTREGEX is only evaluated once HARDREGEX passes.
* This mirrors DcValidator.failsSoftRegex's own precedence.
*
* 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.
* Falls back to no warning ever showing when a pattern itself is malformed,
* rather than breaking the cell.
*/
export const makeRegexWarningRenderer = (pattern?: string) => {
let regex: RegExp | null = null
try {
if (pattern) regex = parseRegexRule(pattern)
} catch (e) {
console.warn(`SOFTREGEX - invalid pattern, warning disabled: ${pattern}`)
regex = null
}
export const makeRegexWarningRenderer = (
softPattern?: string,
hardPattern?: string
) => {
const softRegex = compileRegex(softPattern, 'SOFTREGEX')
const hardRegex = compileRegex(hardPattern, 'HARDREGEX')
const baseRenderer = Handsontable.renderers.getRenderer('text')
@@ -40,13 +55,20 @@ export const makeRegexWarningRenderer = (pattern?: string) => {
const markedForDelete =
instance.getDataAtRowProp(row, '_____DELETE__THIS__RECORD_____') === 'Yes'
const exempt = isRegexRuleExempt(value)
const failsPattern =
!!regex && !isRegexRuleExempt(value) && !regex.test(value.toString())
const failsHard =
!!hardRegex && !exempt && !hardRegex.test(value.toString())
const failsSoft =
!!softRegex && !exempt && !softRegex.test(value.toString())
if (failsPattern && !markedForDelete) {
if (markedForDelete) return td
if (failsHard) {
td.title = `REGEX: ${hardPattern}`
} else if (failsSoft) {
td.classList.add('dc-warning-cell')
td.title = 'Value does not match the expected pattern'
td.title = `REGEX: ${softPattern}`
}
return td
@@ -204,6 +204,32 @@ export class DcValidator {
return isNaN(digits) ? undefined : digits
}
/**
* Returns the RULE_VALUEs of a HARDREGEX/SOFTREGEX rule on the given
* column, for display in the column-header info dropdown. A column can
* carry both rules at once (HARDREGEX blocks submission, SOFTREGEX only
* warns), so both are surfaced independently rather than one taking
* precedence over the other.
*
* @param col column name
*/
getRegexRuleValues(col: string): {
hardRegexValue: string | undefined
softRegexValue: string | undefined
} {
const hardRegexRule = this.dqrules.find(
(rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'HARDREGEX'
)
const softRegexRule = this.dqrules.find(
(rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'SOFTREGEX'
)
return {
hardRegexValue: hardRegexRule?.RULE_VALUE,
softRegexValue: softRegexRule?.RULE_VALUE
}
}
/**
* Retrieves dropdown source for given dc validation rule
* The values comes from MPE_SELECTBOX table
@@ -247,15 +273,31 @@ export class DcValidator {
* 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.
* directly to show the same warning outside the grid.
*
* A column can carry both HARDREGEX and SOFTREGEX at once. HARDREGEX is
* checked first: if this value fails it, SOFTREGEX is never evaluated —
* the cell is already red/blocked, so a yellow warning on top would be
* redundant. If HARDREGEX passes (or doesn't apply), SOFTREGEX is checked
* independently — same precedence as makeRegexWarningRenderer.
*/
failsSoftRegex(col: string, value: any): boolean {
if (this.hasDqRules(col, ['HARDREGEX'])) return false
if (isRegexRuleExempt(value)) return false
const hardRegexRule = this.dqrules.find(
(rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'HARDREGEX'
)
if (hardRegexRule) {
try {
if (!parseRegexRule(hardRegexRule.RULE_VALUE).test(value.toString())) {
return false
}
} catch (e) {
// Malformed HARDREGEX is treated as always-valid (see dqValidate) -
// fall through to SOFTREGEX.
}
}
const softRegexRule = this.dqrules.find(
(rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'SOFTREGEX'
)
@@ -425,25 +467,20 @@ export class DcValidator {
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'
)
// HARDREGEX/SOFTREGEX: submission-blocking for HARDREGEX still goes
// through the normal validator/dqValidate path (see dq-validation.ts)
// and is unaffected by this renderer. This only wires the display
// layer - a 'REGEX: <pattern>' title, plus a yellow dc-warning-cell
// when only SOFTREGEX fails (never for HARDREGEX, which relies on
// HOT's own red htInvalid instead). Last-wins against NUMBER_FORMAT
// if a column somehow carried both (not expected in practice — one
// formats numbers, the other pattern-matches text).
if (this.hasDqRules(ruleColName, ['HARDREGEX', 'SOFTREGEX'])) {
const { hardRegexValue, softRegexValue } =
this.getRegexRuleValues(ruleColName)
this.rules[i].renderer = makeRegexWarningRenderer(
softRegexRule?.RULE_VALUE
softRegexValue,
hardRegexValue
)
}
}
@@ -555,7 +555,32 @@ describe('DC Validator', () => {
})
})
it('11 | wires a function renderer for a SOFTREGEX rule, without blocking submission', () => {
it('11 | wires a function renderer for a HARDREGEX-only rule too (for the REGEX: tooltip)', () => {
// HARDREGEX blocking itself is covered by test 10 above - this isolates
// the newer addition: even with no SOFTREGEX at all, a renderer must
// still be wired so a failing cell gets a 'REGEX: <pattern>' title on
// top of HOT's own red htInvalid, not just silence.
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
}
],
example_dqData
)
const someCharAnyRule = dcValidator.getRule('SOME_CHAR_ANY')
expect(typeof someCharAnyRule?.renderer).toEqual('function')
})
it('12 | 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 —
@@ -589,11 +614,11 @@ describe('DC Validator', () => {
)
})
it('12 | 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.
it('13 | wires a renderer for a dual-rule column, and HARDREGEX still blocks submission', () => {
// Both rules on the same column: submission blocking is still governed
// entirely by HARDREGEX/dqValidate (unchanged). A renderer is wired so
// the cell gets a 'REGEX: <pattern>' title - its own hard-vs-soft
// precedence and coloring are covered by regex-warning-renderer.spec.ts.
const dcValidator: DcValidator = new DcValidator(
example_sasparams,
example_dataformats,
@@ -624,10 +649,10 @@ describe('DC Validator', () => {
expect(valid).toBeFalse()
}
)
expect(rule?.renderer).toBeUndefined()
expect(typeof rule?.renderer).toEqual('function')
})
describe('13 | failsSoftRegex (edit-record modal support for SOFTREGEX)', () => {
describe('14 | 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.
@@ -713,25 +738,122 @@ describe('DC Validator', () => {
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', '.a')).toBeFalse()
})
it('is false when the column also has HARDREGEX (precedence — no yellow on a red cell)', () => {
it('is false when a value fails both HARDREGEX and SOFTREGEX (precedence — no yellow on a red cell)', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '^[A-Z]{3}$',
X: 0
},
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^.{5,10}$',
X: 0
}
])
// 'ab' fails both HARDREGEX (not 3 uppercase letters) and SOFTREGEX
// (too short) - HARDREGEX wins, so this must stay false rather than
// report a SOFTREGEX warning on a value that's already blocked.
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'ab')).toBeFalse()
})
it('is true when a value passes HARDREGEX but fails SOFTREGEX', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '^[A-Z0-9]+$',
X: 0
},
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^.{5,10}$',
X: 0
}
])
// 'AB' passes HARDREGEX (uppercase/digits) but fails SOFTREGEX (too
// short) -
// evaluated independently once HARDREGEX passes.
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'AB')).toBeTrue()
})
})
describe('15 | getRegexRuleValues (column-header info display)', () => {
const buildValidator = (dqRules: DQRule[]) =>
new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
dqRules,
example_dqData
)
it('returns only hardRegexValue for a column with just HARDREGEX', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '^[A-Z]+$',
X: 0
},
}
])
expect(dcValidator.getRegexRuleValues('SOME_CHAR_ANY')).toEqual({
hardRegexValue: '^[A-Z]+$',
softRegexValue: undefined
})
})
it('returns only softRegexValue for a column with just SOFTREGEX', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$',
RULE_VALUE: '/\\b(the|data)\\b/i',
X: 0
}
])
expect(
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'lowercase')
).toBeFalse()
expect(dcValidator.getRegexRuleValues('SOME_CHAR_ANY')).toEqual({
hardRegexValue: undefined,
softRegexValue: '/\\b(the|data)\\b/i'
})
})
it('returns both values for a column with both HARDREGEX and SOFTREGEX', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '^HARD$',
X: 0
},
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^SOFT$',
X: 0
}
])
expect(dcValidator.getRegexRuleValues('SOME_CHAR_ANY')).toEqual({
hardRegexValue: '^HARD$',
softRegexValue: '^SOFT$'
})
})
it('returns both undefined for a column with no regex rule', () => {
const dcValidator = buildValidator([])
expect(dcValidator.getRegexRuleValues('SOME_CHAR_ANY')).toEqual({
hardRegexValue: undefined,
softRegexValue: undefined
})
})
})
})
@@ -18,4 +18,60 @@ describe('buildColInfoHtml', () => {
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.'
)
})
it('appends a REGEX line when only a HARDREGEX value is provided', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(
buildColInfoHtml('SOME_CHAR', colInfo, '/^[A-Z]+$/i', undefined)
).toBe(
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.<br>REGEX: /^[A-Z]+$/i'
)
})
it('appends a REGEX line when only a SOFTREGEX value is provided', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(
buildColInfoHtml('SOME_CHAR', colInfo, undefined, '/^[a-z]+$/')
).toBe(
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.<br>REGEX: /^[a-z]+$/'
)
})
it('appends separate HARDREGEX/SOFTREGEX lines when a column has both', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(
buildColInfoHtml('SOME_CHAR', colInfo, '/^[A-Z]+$/i', '/^.{5,10}$/')
).toBe(
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.<br>HARDREGEX: /^[A-Z]+$/i<br>SOFTREGEX: /^.{5,10}$/'
)
})
it('omits the REGEX line when no regex rule value is provided', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(buildColInfoHtml('SOME_CHAR', colInfo)).not.toContain('REGEX:')
})
})
+15 -2
View File
@@ -7,9 +7,22 @@ import { DataFormat } from '../../models/sas/common/DateFormat'
*/
export function buildColInfoHtml(
colName: string,
colInfo?: DataFormat
colInfo?: DataFormat,
hardRegexValue?: string,
softRegexValue?: string
): string {
if (!colInfo) return 'No info found'
return `NAME: ${colName}<br>LABEL: ${colInfo.label}<br>TYPE: ${colInfo.type}<br>LENGTH: ${colInfo.length}<br>FORMAT: ${colInfo.format}`
let html = `NAME: ${colName}<br>LABEL: ${colInfo.label}<br>TYPE: ${colInfo.type}<br>LENGTH: ${colInfo.length}<br>FORMAT: ${colInfo.format}`
// HARDREGEX/SOFTREGEX are only distinguished by name when a column has
// both - with just one rule, which one it is is already implied, so the
// generic REGEX label keeps the common case uncluttered.
if (hardRegexValue && softRegexValue) {
html += `<br>HARDREGEX: ${hardRegexValue}<br>SOFTREGEX: ${softRegexValue}`
} else if (hardRegexValue || softRegexValue) {
html += `<br>REGEX: ${hardRegexValue || softRegexValue}`
}
return html
}
+24 -6
View File
@@ -52,7 +52,8 @@ function makeRows(n) {
ROUND_COL: Number((i + 1 + i / 7).toFixed(5)), // rounds on edit
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
REGEX_SOFT_COL: "SW1A 1AA", // SOFTREGEX: UK postcode — starts valid
REGEX_BOTH_COL: "ABC-123" // HARDREGEX + SOFTREGEX together — starts valid against both
})
}
return rows
@@ -241,6 +242,19 @@ let webouts = {
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\"}"
},
{
NAME: "REGEX_BOTH_COL",
LABEL: "REGEX_BOTH_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "HARDREGEX + SOFTREGEX together: must be uppercase/digits/-/_ (blocking), recommended 5-10 chars long (warning)",
LONGDESC: ""
// No COLTYPE - DcValidator falls back to a plain { data: name }
// rule when COLTYPE is absent, same shape this trivial JSON
// would produce anyway (see parseColTypeRow).
}
],
dqdata: [
@@ -266,7 +280,9 @@ let webouts = {
{ 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: "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}/" }
{ BASE_COL: "REGEX_SOFT_COL", RULE_TYPE: "SOFTREGEX", RULE_VALUE: "/[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}/" },
{ BASE_COL: "REGEX_BOTH_COL", RULE_TYPE: "HARDREGEX", RULE_VALUE: "/^[A-Z0-9_-]+$/" },
{ BASE_COL: "REGEX_BOTH_COL", RULE_TYPE: "SOFTREGEX", RULE_VALUE: "/^.{5,10}$/" }
],
dsmeta: [
{ ODS_TABLE: "ATTRIBUTES", NAME: "Data Set Name", VALUE: "DC996664.MPE_X_TEST" },
@@ -319,7 +335,8 @@ let webouts = {
{ NAME: "round_col", MAXLEN: 8 },
{ NAME: "numfmt_col", MAXLEN: 8 },
{ NAME: "regex_hard_col", MAXLEN: 128 },
{ NAME: "regex_soft_col", MAXLEN: 128 }
{ NAME: "regex_soft_col", MAXLEN: 128 },
{ NAME: "regex_both_col", MAXLEN: 128 }
],
query: [],
sasdata: makeRows(100),
@@ -341,12 +358,13 @@ let webouts = {
ROUND_COL: { format: "best.", label: "ROUND_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" }
REGEX_SOFT_COL: { format: "$128.", label: "REGEX_SOFT_COL", length: "128", type: "char" },
REGEX_BOTH_COL: { format: "$128.", label: "REGEX_BOTH_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,REGEX_HARD_COL,REGEX_SOFT_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,REGEX_BOTH_COL",
FILTER_TEXT: FILTER_TEXT,
PKCNT: 1,
PK: "PRIMARY_KEY_FIELD",
@@ -1501,7 +1519,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', 'REGEX_HARD_COL', 'REGEX_SOFT_COL']
const RULE_DEMO_COLS = ['SOME_HARDSELECT', 'READONLY_COL', 'HIDDEN_COL', 'ROUND_COL', 'NUMFMT_COL', 'REGEX_HARD_COL', 'REGEX_SOFT_COL', 'REGEX_BOTH_COL']
function stripRuleCols(t) {
const uc = new Set(RULE_DEMO_COLS)