Compare commits

...
Author SHA1 Message Date
YuryShkoda acb97f4bfb fix(formulas): avoid EDIT_STATUS colliding with a real column of that name
Build / Build-and-ng-test (pull_request) Successful in 5m15s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m48s
Build / Build-and-test-development (pull_request) Successful in 21m38s
2026-07-31 10:26:35 +03:00
YuryShkoda bb808617f4 chore: merge remote-tracking branch 'origin/main' into additional-validations-formulae
Build / Build-and-ng-test (pull_request) Successful in 5m7s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m44s
Build / Build-and-test-development (pull_request) Successful in 21m54s
2026-07-30 18:00:48 +03:00
YuryShkoda 4a8c39b4c0 feat(formulas): live DC.ROW_STATUS, insert-row formula fixes, UserService singleton fix
Build / Build-and-ng-test (pull_request) Successful in 5m13s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m31s
Build / Build-and-test-development (pull_request) Successful in 21m56s
- addRow()/insertRowAtPosition() use hot.alter() so HARDFORMULA/SOFTFORMULA
  values realign on insert instead of going stale
- New hidden EDIT_STATUS column gives DC.ROW_STATUS a real, live cell
  reference; row-header +/-/~ indicator now translates visual->physical
  row index so it stays correct when the grid is sorted
- UserService is now providedIn: 'root' instead of module-scoped, fixing
  DC.USER_NAME (and any other lazy-module reader) always seeing an unset user
- Column-info dropdown shows the applied formula ("√x=<formula>")
2026-07-30 16:03:11 +03:00
semantic-release-bot 42e02cdb05 chore(release): 7.12.0 [skip ci]
# [7.12.0](https://git.datacontroller.io/dc/dc/compare/v7.11.0...v7.12.0) (2026-07-28)

### Bug Fixes

* adding REGEX validations to mpe_x_test ([bd798b4](bd798b424a))
* default value for label ([a3e46a9](a3e46a968e))
* **deps:** retarget Angular upgrade to 20, not 21 (CI install was broken) ([cac9244](cac9244f92))
* ensure only one REGEX applies at a time ([8fb58eb](8fb58eb36e))
* ensure that no assets (including og links) ever fetch from external sources ([33dcb98](33dcb989d3))
* include peer dependencies in package-lock for npm ci in pipeline ([b51c770](b51c770782))
* licensecheker ([f60bcef](f60bcef583))
* **lint:** remove redundant optional chaining ([d881290](d881290618))
* optimisation, renamed values for DDTYPE to save space ([cfb60e5](cfb60e5e4b))
* patch npm audit vulnerabilities in sas and client dependencies ([e22edf7](e22edf7ed3))
* **query:** isolate viewbox filter state from the base table's ([7a35cf4](7a35cf4a45))
* regenerate client lockfile to resolve Angular peer-dependency drift breaking npm ci ([05fe474](05fe4744d5))
* **regex:** special missing handling ([180c247](180c2477ed))
* removing low severity warning in npm audit ([2a771bb](2a771bb91a))
* removing thousand seperator from plain numerics in EDIT mode ([0392a81](0392a81cbd))
* **validations:** parse SAS PRX /pattern/flags syntax in HARDREGEX/SOFTREGEX ([7ed3730](7ed3730ae3))

### Features

* **docs:** adding agents.md and docs for RLS ([359d833](359d833406))
* **editor:** add HARDREGEX/SOFTREGEX validation rules ([17e4802](17e4802895))
* **editor:** evaluate HARDREGEX/SOFTREGEX independently instead of hard-wins precedence ([57db117](57db1179a9))
* **editor:** show applied HARDREGEX/SOFTREGEX pattern in column info dropdown ([39c8855](39c8855f37))
* **regex:** backend validations on regex strings ([d2c93a4](d2c93a46fa))
* using ALL libraries as validation in MPE_SECURITY.  Closes [#279](#279) ([62ff0ae](62ff0aee4a))
* validation checks to prevent incompatible RLS rules (eg REPLACE load type).  Closes [#211](#211) ([ea00c5a](ea00c5afad))
* validation on RLS for REPLACE, + docs + tests.  Closes [#211](#211) ([7378f3b](7378f3ba30))
2026-07-28 10:15:48 +00:00
Yury 347923900f Merge pull request 'v7.12 release' (#278) from additional-validations-regex into main
Release / Build-production-and-ng-test (push) Successful in 4m30s
Release / Build-and-test-development (push) Successful in 15m24s
Release / release (push) Successful in 8m47s
Reviewed-on: #278
2026-07-28 09:52:12 +00:00
allan a4c3989c26 Merge pull request 'fix(regex): special missing handling' (#286) from regexfix into additional-validations-regex
Build / Build-and-ng-test (pull_request) Successful in 5m0s
Lighthouse Checks / lighthouse (pull_request) Successful in 20m53s
Build / Build-and-test-development (pull_request) Successful in 20m17s
Reviewed-on: #286
2026-07-27 18:06:47 +00:00
4gl 0392a81cbd fix: removing thousand seperator from plain numerics in EDIT mode
Build / Build-and-ng-test (pull_request) Successful in 5m11s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m12s
Build / Build-and-test-development (pull_request) Successful in 20m7s
2026-07-27 18:37:46 +01:00
4gl f9ea53cf78 chore(demo): adding extra regex's to mpe_x_test
Build / Build-and-ng-test (pull_request) Successful in 5m13s
Build / Build-and-test-development (pull_request) Canceled after 11m54s
Lighthouse Checks / lighthouse (pull_request) Canceled after 16m54s
2026-07-27 18:18:41 +01:00
4gl 8fb58eb36e fix: ensure only one REGEX applies at a time
Build / Build-and-ng-test (pull_request) Successful in 5m32s
Build / Build-and-test-development (pull_request) Canceled after 7m12s
Lighthouse Checks / lighthouse (pull_request) Canceled after 10m14s
2026-07-27 18:05:30 +01:00
4gl 180c2477ed fix(regex): special missing handling
Build / Build-and-ng-test (pull_request) Successful in 4m59s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m20s
Build / Build-and-test-development (pull_request) Successful in 20m45s
2026-07-27 17:51:00 +01:00
YuryShkoda 69cfccd565 chore: merge additional-validations-regex branch 2026-07-27 15:30:50 +03:00
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 44bc7f7fea Merge pull request 'fix: optimisation, renamed values for DDTYPE to save space' (#285) from ddtype_rename into additional-validations-regex
Build / Build-and-ng-test (pull_request) Successful in 5m23s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m50s
Build / Build-and-test-development (pull_request) Successful in 20m35s
Reviewed-on: #285
2026-07-25 18:17:22 +00:00
4gl f60bcef583 fix: licensecheker
Build / Build-and-ng-test (pull_request) Successful in 5m0s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m7s
Build / Build-and-test-development (pull_request) Successful in 19m40s
2026-07-25 17:41:39 +01:00
4gl e7abb0a08a chore: down to 0 vulns
Build / Build-and-ng-test (pull_request) Failing after 1m57s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Failing after 2m12s
2026-07-25 17:29:00 +01:00
4gl cfb60e5e4b fix: optimisation, renamed values for DDTYPE to save space
Build / Build-and-ng-test (pull_request) Failing after 1m45s
Build / Build-and-test-development (pull_request) Skipped
Lighthouse Checks / lighthouse (pull_request) Successful in 20m4s
2026-07-25 16:18:42 +01: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
YuryShkoda cbea04c8e1 feat(formulas): wire HyperFormula into HARDFORMULA/SOFTFORMULA rules
Adds HARDFORMULA/SOFTFORMULA DQ rule types, backed by Handsontable's
formulas plugin (gated per-table via hasFormulaRules). parseFormulaRule
substitutes column names with row-relative cell references (quote-aware,
blank-boundary matching per spec) and resolves DC.USER_NAME/DC.ORIG_VALUE
to literal values; applyFormulaRules injects the computed formula per row,
reusing the existing READONLY mechanism for HARDFORMULA.

DC.ROW_STATUS/EDIT_STATUS deliberately deferred - would require prepending
COLHEADERS, which headerColumns has undocumented positional coupling to
elsewhere in editor.component.ts.
2026-07-23 17:35:07 +03:00
49 changed files with 3303 additions and 726 deletions
+19
View File
@@ -0,0 +1,19 @@
# Dependency Updates Checklist
Whenever any `package.json` (root, `client/`, or `sas/`) or lockfile is modified, run the same checks CI runs before pushing:
1. **npm audit** (must be clean for prod deps):
```bash
npm audit --omit=dev # in repo root
cd sas && npm audit --omit=dev
cd ../client && npm audit --omit=dev
```
Fix with `npm audit fix`, targeted `overrides` in `package.json`, or version bumps — never `npm audit fix --force` blindly, as it can introduce breaking changes.
2. **License checker** (client only):
```bash
cd client && npm run license-checker
```
If a new dependency fails, either add its SPDX id to the `onlyAllow` list in `client/licenseChecker.js` (if the license is acceptable, e.g. permissive ones like `BlueOak-1.0.0`) or add the specific package to `excludePackages` with justification. Data Controller ships on-prem, so only OSI-approved permissive licenses are acceptable for production dependencies.
Both checks run in `.gitea/workflows/build.yaml` (`Check audit` and `Licence checker` steps) and will fail the build if skipped locally.
+86
View File
@@ -0,0 +1,86 @@
# Regex Validations (HARDREGEX / SOFTREGEX)
This document describes how the regex validation rules work internally. For user-facing documentation see `docs/dcc-validations.md` in the `docs.datacontroller.io` repo.
## Overview
Two validation rule types in `MPE_VALIDATIONS` validate cell values against a regular expression supplied in `RULE_VALUE`:
- `HARDREGEX` - submission-blocking. A failing value is rejected by the cell validator and painted red (HOT's own `htInvalid` class), so the row cannot be submitted.
- `SOFTREGEX` - display-only warning. A failing value is painted yellow (`dc-warning-cell` class) with a tooltip, but submission is not blocked.
Both rule types are selectable in the MPE_VALIDATIONS RULE_TYPE dropdown; they were added to the selectbox seed data in `sas/sasjs/macros/mpe_makedata.sas` and via the optional migration `sas/sasjs/db/migrations/20260720_v7.12_release.sas`. `RULE_VALUE` is limited to 128 characters, which constrains very long patterns.
## Config-time validation (SAS side)
`sas/sasjs/services/hooks/mpe_validations_postedit.sas` runs `prxparse()` on any staged HARDREGEX/SOFTREGEX rule value and aborts the edit with a list of offending `libref.table.column` references if the pattern is invalid. This is a best-effort syntax check to catch typos at config time; an empty pattern is treated as valid (it matches everything in JS). Rows marked for delete are skipped.
Because patterns must pass `prxparse`, rule values are authored in the SAS PRX delimiter form `/pattern/flags` (although a bare pattern is also accepted for backwards compatibility).
## Frontend evaluation
All regex handling lives in the client; there is no server-side re-validation of data values. The per-cell decision flow:
```mermaid
flowchart TD
A[Cell value] --> B{Row marked for delete<br/>and not a PK column?}
B -- Yes --> Z[No validation / no warning]
B -- No --> C{isRegexRuleExempt?<br/>blank, or "." on a numeric column}
C -- Yes --> Z
C -- No --> D{HARDREGEX rule on column?}
D -- Yes --> E{Pattern matches?}
E -- No --> F[Invalid: submission blocked,<br/>red htInvalid + REGEX tooltip]
E -- Yes --> J[Valid]
D -- No --> G{SOFTREGEX rule on column?}
G -- Yes --> H{Pattern matches?}
H -- No --> I[Warning: yellow dc-warning-cell<br/>+ REGEX tooltip, submission allowed]
H -- Yes --> J[Valid]
G -- No --> J
```
A malformed pattern never reaches this flow: it is treated as always-valid (HARDREGEX) / never-warn (SOFTREGEX) with a `console.warn`, rather than breaking the editor.
### `client/src/app/shared/dc-validator/utils/parseRegexRule.ts`
Converts an authored SAS PRX pattern into a JavaScript `RegExp`:
1. If the value matches `/^\/(.*)\/([a-z]*)$/s`, the delimiters are stripped and the trailing flags are passed to `new RegExp(body, flags)`. Otherwise the value is used as-is (backwards compatibility with bare patterns).
2. Three mechanical Perl→JS translations are applied to the body:
- a leading `(?i)` inline modifier is removed and folded into the `i` flag;
- `\Q...\E` literal sequences are replaced with escaped literal text;
- `\A``^` and `\z``(?![\s\S])` (end-of-string anchor).
3. Perl-only constructs that would need a capture-group-renumbering rewrite (atomic groups `(?>...)`, possessive quantifiers `a++`) are deliberately NOT translated. They throw from `new RegExp`, and every caller treats a throw as "always valid / never warn" (see below) rather than breaking the editor.
### `client/src/app/shared/dc-validator/utils/isRegexRuleExempt.ts`
Blank values (`undefined`, `null`, `''`) are exempt from pattern matching on any column type - use a separate NOTNULL rule if populated values must also be enforced. On numeric columns the plain SAS missing (`.`) is also exempt; special missings (`.A`-`.Z`, `._`, bare letters) are NOT exempt anywhere - being deliberately set, they are real values the pattern must match (and on a character column even `.` is real text). `isSpecialMissing` from `@sasjs/utils` is deliberately not used: its optional-dot regex would exempt any single-letter character value ("d", "z") before the regex ever ran. The check takes an `isNumeric` flag, passed by all callers (the dq validator via `dqValidate(rules, value, colType === 'numeric')`, the warning renderer via a `makeRegexWarningRenderer` argument, and `failsSoftRegex` via the column's HOT type).
### HARDREGEX - blocking validation
`HARDREGEX` is implemented as a cell validator in `client/src/app/shared/dc-validator/validations/dq-validation.ts`. It returns `true` (valid) for exempt values and for patterns that fail to compile (with a `console.warn`), and otherwise returns `parseRegexRule(ruleValue).test(value.toString())`. Returning `false` makes HOT mark the cell invalid, block submission, and paint it red via its standard `htInvalid` styling.
### SOFTREGEX - warning renderer
`client/src/app/editor/utils/regex-warning-renderer.ts` builds a display-only Handsontable renderer (registered per-column by `DcValidator.setupRules` in `client/src/app/shared/dc-validator/dc-validator.ts`). It never returns false; it only:
- adds a `REGEX: <pattern>` tooltip (`td.title`) when a rule fails;
- adds the yellow `dc-warning-cell` class when only SOFTREGEX fails.
`DcValidator.failsSoftRegex(col, value)` provides the same logic outside the grid (e.g. the edit-record screen).
### Precedence: HARD and SOFT on the same column
Only one regex ever runs per column. If a HARDREGEX rule exists, SOFTREGEX is ignored entirely - never compiled, never evaluated - regardless of whether individual cell values pass or fail the hard rule. A value failing HARDREGEX gets the red invalid styling (blocking submission) plus a `REGEX: <pattern>` tooltip; a SOFTREGEX-only column warns in yellow without blocking. This holds in both the renderer and `failsSoftRegex`.
### Other behaviour
- Rows marked for delete (`_____DELETE__THIS__RECORD_____ = 'Yes'`) are not warned/validated by the renderer (except primary key columns, which still validate).
- A malformed pattern never breaks the editor: the dq validator treats it as always-valid and the renderer disables the warning, logging to the console instead.
- The pattern is used as authored - it is NOT auto-anchored. Authors must include `^`/`$` to match the entire cell value.
- Column info: `client/src/app/shared/utils/col-info-html.ts` shows the applied pattern in the column-info dropdown - the HARDREGEX pattern if one exists (it is the rule actually applied when both are present), otherwise the SOFTREGEX pattern, otherwise nothing.
## Testing
- Unit tests: `parseRegexRule.spec.ts`, `isRegexRuleExempt.spec.ts`, `dq-validation.spec.ts`, `dc-validator.spec.ts` (under `client/src/app/shared/dc-validator/`), `client/src/app/editor/utils/regex-warning-renderer.spec.ts`, `client/src/app/shared/utils/col-info-html.spec.ts`.
- E2E: `client/cypress/e2e/editor.cy.ts`.
- SAS side: `sas/sasjs/services/editors/stagedata.test.3.sas`, plus seed data in `mpe_makedata.sas`: HARDREGEX "SOME_CHAR must contain 'the' or 'data'" (`/the|data/i`), SOFTREGEX "SOME_CHAR should contain the letter 't'", SOFTREGEX on PRIMARY_KEY_FIELD (`/^\d+$/` - yellow if the key contains a decimal), and HARDREGEX on SOME_SHORTNUM (`/^(?![1-5](\.\d+)?$).*/` - values 1-5 blocked; generated data starts at 6 so demos aren't blocked accidentally).
+9 -2
View File
@@ -12,9 +12,12 @@ This is different from `.sas` files, where a maximum line length applies. The no
Rationale: hard-wrapped prose produces noisy diffs when sentences are edited and reflowed, and Markdown renderers already handle wrapping. Rationale: hard-wrapped prose produces noisy diffs when sentences are edited and reflowed, and Markdown renderers already handle wrapping.
## SAS files ## Linting (required before "done")
After creating or modifying any `.sas` files, run `sasjs lint` from the `sas/` directory and ensure the files you touched have no lint warnings (the repo currently has pre-existing warnings in other files, which can be ignored). Never consider a change complete until the relevant linters pass on the files you touched — do not rely on the user's pre-commit hooks to catch it:
- **Client (TypeScript/HTML/etc.)**: run `npm run lint:check` from the `client/` directory (prettier). Fix any failures with `npm run lint:fix`.
- **SAS**: after creating or modifying any `.sas` files, run `sasjs lint` from the `sas/` directory and ensure the files you touched have no lint warnings (the repo currently has pre-existing warnings in other files, which can be ignored).
## No external assets ## No external assets
@@ -23,3 +26,7 @@ Data Controller must run entirely locally (offline / on-prem, no internet access
## The .agent folder ## The .agent folder
Agent-related content lives in `.agent/`: technical/agent-facing documentation goes in `.agent/docs/` (not `docs/`), and skills in `.agent/skills/`. When writing explanatory or technical docs about the codebase, put them in `.agent/docs/`. Agent-related content lives in `.agent/`: technical/agent-facing documentation goes in `.agent/docs/` (not `docs/`), and skills in `.agent/skills/`. When writing explanatory or technical docs about the codebase, put them in `.agent/docs/`.
## Code comments and test names
Never reference items that are not active parts of the repository — no "the original bug", "regression from this fix", "this session/PR/commit", or similar ephemeral context. Comments and test names must be self-contained: describe the behaviour being asserted, not the history of how it was discovered. The one exception is a literal link to a ticket/issue tracker.
+34
View File
@@ -1,3 +1,37 @@
# [7.12.0](https://git.datacontroller.io/dc/dc/compare/v7.11.0...v7.12.0) (2026-07-28)
### Bug Fixes
* adding REGEX validations to mpe_x_test ([bd798b4](https://git.datacontroller.io/dc/dc/commit/bd798b424a7c4b90d613425348d3f99eeb49b025))
* default value for label ([a3e46a9](https://git.datacontroller.io/dc/dc/commit/a3e46a968ef08295347b230235f1e79dfa51c5a2))
* **deps:** retarget Angular upgrade to 20, not 21 (CI install was broken) ([cac9244](https://git.datacontroller.io/dc/dc/commit/cac9244f92544f432078125c312d05c36ed4b855))
* ensure only one REGEX applies at a time ([8fb58eb](https://git.datacontroller.io/dc/dc/commit/8fb58eb36e05fdf7262459b05e53643b3ca672f7))
* ensure that no assets (including og links) ever fetch from external sources ([33dcb98](https://git.datacontroller.io/dc/dc/commit/33dcb989d3ae0c5ee0a13fe64195862a945c4348))
* include peer dependencies in package-lock for npm ci in pipeline ([b51c770](https://git.datacontroller.io/dc/dc/commit/b51c770782a8b62816e5145496dbea9fb83059e0))
* licensecheker ([f60bcef](https://git.datacontroller.io/dc/dc/commit/f60bcef58381ba269415640fa19a27305274939a))
* **lint:** remove redundant optional chaining ([d881290](https://git.datacontroller.io/dc/dc/commit/d881290618f25ddb6335125f1a08b5aaf56a8c48))
* optimisation, renamed values for DDTYPE to save space ([cfb60e5](https://git.datacontroller.io/dc/dc/commit/cfb60e5e4bfd37dc79fa4e9a6c6649ab1a61f2e4))
* patch npm audit vulnerabilities in sas and client dependencies ([e22edf7](https://git.datacontroller.io/dc/dc/commit/e22edf7ed3aa54fc08411cf90d51df9ec30877c3))
* **query:** isolate viewbox filter state from the base table's ([7a35cf4](https://git.datacontroller.io/dc/dc/commit/7a35cf4a458791b26ec8141c0c84d97fcb555145))
* regenerate client lockfile to resolve Angular peer-dependency drift breaking npm ci ([05fe474](https://git.datacontroller.io/dc/dc/commit/05fe4744d58e7985ecc5ef5f9c1969dbab2d2efb))
* **regex:** special missing handling ([180c247](https://git.datacontroller.io/dc/dc/commit/180c2477ed9ae44b4e479e81ec642f40580bcbc8))
* removing low severity warning in npm audit ([2a771bb](https://git.datacontroller.io/dc/dc/commit/2a771bb91acaaa16aba44e2d7ddb51b8b7123c10))
* removing thousand seperator from plain numerics in EDIT mode ([0392a81](https://git.datacontroller.io/dc/dc/commit/0392a81cbd9a634590ca7a60a316cd12c690a5a5))
* **validations:** parse SAS PRX /pattern/flags syntax in HARDREGEX/SOFTREGEX ([7ed3730](https://git.datacontroller.io/dc/dc/commit/7ed3730ae3145dffd639ce3d2f831447d78f4e85))
### Features
* **docs:** adding agents.md and docs for RLS ([359d833](https://git.datacontroller.io/dc/dc/commit/359d833406ace79f96d03c08a5c72fbd3df29442))
* **editor:** add HARDREGEX/SOFTREGEX validation rules ([17e4802](https://git.datacontroller.io/dc/dc/commit/17e48028955d7b091e53bad9d7f9e3a089b53af9))
* **editor:** evaluate HARDREGEX/SOFTREGEX independently instead of hard-wins precedence ([57db117](https://git.datacontroller.io/dc/dc/commit/57db1179a97973ecf0f711bda6057383de546dd3))
* **editor:** show applied HARDREGEX/SOFTREGEX pattern in column info dropdown ([39c8855](https://git.datacontroller.io/dc/dc/commit/39c8855f37477f06d8b8cf5c97e04543070e8385))
* **regex:** backend validations on regex strings ([d2c93a4](https://git.datacontroller.io/dc/dc/commit/d2c93a46facb386fd31661c02c0d3d34f38f6e43))
* using ALL libraries as validation in MPE_SECURITY. Closes [#279](https://git.datacontroller.io/dc/dc/issues/279) ([62ff0ae](https://git.datacontroller.io/dc/dc/commit/62ff0aee4a976184de65d87ea3c8b8b7cb777938))
* validation checks to prevent incompatible RLS rules (eg REPLACE load type). Closes [#211](https://git.datacontroller.io/dc/dc/issues/211) ([ea00c5a](https://git.datacontroller.io/dc/dc/commit/ea00c5afad0daf2a66cdb206f2a7481b992192bf))
* validation on RLS for REPLACE, + docs + tests. Closes [#211](https://git.datacontroller.io/dc/dc/issues/211) ([7378f3b](https://git.datacontroller.io/dc/dc/commit/7378f3ba3014141008b4dbde0b89c21a6ce02f69))
# [7.11.0](https://git.datacontroller.io/dc/dc/compare/v7.10.1...v7.11.0) (2026-07-20) # [7.11.0](https://git.datacontroller.io/dc/dc/compare/v7.10.1...v7.11.0) (2026-07-20)
+572 -9
View File
@@ -212,6 +212,12 @@ context('editor tests: ', function () {
.clear() .clear()
.type('not-an-email{enter}') .type('not-an-email{enter}')
.then(() => { .then(() => {
getCellByHeaderAndRow(1, 'REGEX_HARD_COL').should(
'have.attr',
'title',
'REGEX: /[\\w.]+@[\\w]+\\.[a-z]{2,}/'
)
submitTable(() => { submitTable(() => {
cy.get('.modal-body').then((modalBody: any) => { cy.get('.modal-body').then((modalBody: any) => {
if ( if (
@@ -245,10 +251,13 @@ context('editor tests: ', function () {
.clear() .clear()
.type('not a postcode{enter}') .type('not a postcode{enter}')
.then(() => { .then(() => {
getCellByHeaderAndRow(1, 'REGEX_SOFT_COL').should( getCellByHeaderAndRow(1, 'REGEX_SOFT_COL')
'have.class', .should('have.class', 'dc-warning-cell')
'dc-warning-cell' .and(
) 'have.attr',
'title',
'REGEX: /[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}/'
)
submitTable(() => { submitTable(() => {
// Validation passed despite the SOFTREGEX warning: the // Validation passed despite the SOFTREGEX warning: the
@@ -264,14 +273,509 @@ context('editor tests: ', function () {
}) })
}) })
}) })
// MPE_X_FORMULA_TEST is a small, dedicated fixture for formula testing
// (kept separate from MPE_X_NEW, which carries no formula columns at
// all) - row index 1 (0-indexed, i=1 in the mock's row generator) has
// A_COL=2, B_COL=10: HARDFORMULA (A_COL * B_COL) computes to 20,
// SOFTFORMULA (A_COL + B_COL) computes to 12.
it('9 | HARDFORMULA shows the computed value and blocks direct edits', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should('have.text', '20')
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL')
.dblclick({ force: true })
.then(() => {
// readOnly cells don't open an editor - nothing is focused to
// type into, so this is a no-op if the column is truly readonly.
cy.focused().type('9999{enter}')
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should(
'have.text',
'20'
)
})
})
})
})
it('10 | SOFTFORMULA shows the computed default but accepts an override', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '12')
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('override{enter}')
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should(
'have.text',
'override'
)
})
})
})
})
it('11 | Info dropdown shows the applied formula as √x=<formula>', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
openColumnDropdown('FORMULA_HARD_COL')
cy.get('.htDropdownMenu').should(($menu) => {
expect($menu.text()).to.include('√x=A_COL * B_COL')
})
cy.get('body').click(0, 0) // close menu
openColumnDropdown('FORMULA_SOFT_COL')
cy.get('.htDropdownMenu').should(($menu) => {
expect($menu.text()).to.include('√x=A_COL + B_COL')
})
})
})
})
it('12 | 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(
'HARDREGEX: /[\\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(
'SOFTREGEX: /[A-Z]{1,2}\\d{1,2}[A-Z]?\\s?\\d[A-Z]{2}/'
)
})
})
})
})
it('13 | Info dropdown shows only the applied HARDREGEX pattern 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.not.include('SOFTREGEX: /^.{5,10}$/')
})
})
})
})
it('14 | 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('15 | REGEX_BOTH_COL: SOFTREGEX is ignored entirely when HARDREGEX is present', (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) - only one regex runs per column, so no warning is shown.
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
.dblclick({ force: true })
.then(() => {
cy.focused()
.clear()
.type('AB{enter}')
.then(() => {
getCellByHeaderAndRow(1, 'REGEX_BOTH_COL')
.should('not.have.class', 'dc-warning-cell')
.and('not.have.attr', 'title')
submitTable(() => {
cy.get('#submitBtn', { timeout: longerCommandTimeout })
.should('exist')
.should('not.be.disabled')
.then(() => done())
})
})
})
})
})
})
it("16 | Insert Row above keeps existing rows' formulas aligned with their own shifted data", () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
// Row index 2 (0-indexed): PRIMARY_KEY_FIELD=3, A_COL=3, B_COL=10 ->
// HARDFORMULA (A_COL*B_COL) = 30, SOFTFORMULA (A_COL+B_COL) = 13.
getCellByHeaderAndRow(2, 'PRIMARY_KEY_FIELD').should('have.text', '3')
insertRowViaContextMenu(2, 'Insert Row above')
// The new blank row lands at index 2 - its formula columns are
// seeded with their own row-relative formula (A_COL/B_COL are both
// still empty, so HyperFormula evaluates the arithmetic as 0).
getCellByHeaderAndRow(2, 'FORMULA_HARD_COL').should('have.text', '0')
getCellByHeaderAndRow(2, 'FORMULA_SOFT_COL').should('have.text', '0')
// The row that WAS at index 2 (PK=3) is now shifted down to index
// 3 - its formulas must recompute from ITS OWN data, not read
// whatever the engine had cached at index 3 before the insert
// (that would be PK=4's values: 40/14).
getCellByHeaderAndRow(3, 'PRIMARY_KEY_FIELD').should('have.text', '3')
getCellByHeaderAndRow(3, 'FORMULA_HARD_COL').should('have.text', '30')
getCellByHeaderAndRow(3, 'FORMULA_SOFT_COL').should('have.text', '13')
})
})
})
it("17 | Insert Row below keeps existing rows' formulas aligned with their own shifted data", () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
// Row index 3 (0-indexed): PRIMARY_KEY_FIELD=4, A_COL=4, B_COL=10 ->
// HARDFORMULA = 40, SOFTFORMULA = 14.
getCellByHeaderAndRow(3, 'PRIMARY_KEY_FIELD').should('have.text', '4')
// 'Insert Row below' on row index 2 (PK=3) inserts the new blank
// row at index 3, pushing the old index-3 row (PK=4) to index 4.
insertRowViaContextMenu(2, 'Insert Row below')
// The new blank row lands at index 3 - seeded the same way as
// above, evaluating to 0 while A_COL/B_COL are still empty.
getCellByHeaderAndRow(3, 'FORMULA_HARD_COL').should('have.text', '0')
getCellByHeaderAndRow(3, 'FORMULA_SOFT_COL').should('have.text', '0')
getCellByHeaderAndRow(4, 'PRIMARY_KEY_FIELD').should('have.text', '4')
getCellByHeaderAndRow(4, 'FORMULA_HARD_COL').should('have.text', '40')
getCellByHeaderAndRow(4, 'FORMULA_SOFT_COL').should('have.text', '14')
})
})
})
it('18 | Insert Row seeds formulas that live-recalculate once A_COL/B_COL are filled in', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
insertRowViaContextMenu(0, 'Insert Row below')
// New row at index 1 - both formula columns evaluate the seeded
// formula (=C2*D2 / =C2+D2 in spreadsheet notation) against
// still-empty A_COL/B_COL, so HyperFormula treats them as 0.
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should('have.text', '0')
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '0')
getCellByHeaderAndRow(1, 'A_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().type('5{enter}')
})
getCellByHeaderAndRow(1, 'B_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().type('3{enter}')
})
// Both formula columns must recompute live from THIS row's own
// A_COL/B_COL - proving the seeded formula is genuinely wired to
// HyperFormula, not a one-time static snapshot.
getCellByHeaderAndRow(1, 'FORMULA_HARD_COL').should('have.text', '15')
getCellByHeaderAndRow(1, 'FORMULA_SOFT_COL').should('have.text', '8')
})
})
})
it('19 | Row header shows blank for unchanged, ~ for a modified row', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getRowHeaderSymbol(0).should('have.text', ' ')
getCellByHeaderAndRow(0, 'A_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('999{enter}')
})
getRowHeaderSymbol(0).should('have.text', '~')
})
})
})
it('20 | Row header shows - for a delete-marked row and + for a newly inserted row', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(2, 'Delete?').then(($cell) => {
setDeleteFlag($cell[0], 'Yes', () => {
getRowHeaderSymbol(2).should('have.text', '-')
insertRowViaContextMenu(0, 'Insert Row below')
// New row lands at index 1, shifting the delete-marked row
// (was index 2) down to index 3 - its own status is unaffected
// by the shift, only its position.
getRowHeaderSymbol(1).should('have.text', '+')
getRowHeaderSymbol(3).should('have.text', '-')
})
})
})
})
})
it('21 | Inserting a row does not falsely mark unrelated rows as modified or revert an existing override', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(0, 'FORMULA_SOFT_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('999{enter}')
})
getRowHeaderSymbol(0).should('have.text', '~')
// Untouched row, well away from the insert point below.
getRowHeaderSymbol(5).should('have.text', ' ')
insertRowViaContextMenu(2, 'Insert Row below')
// The override and its '~' mark must survive the insert...
getCellByHeaderAndRow(0, 'FORMULA_SOFT_COL').should('have.text', '999')
getRowHeaderSymbol(0).should('have.text', '~')
// ...and a row that was never touched must stay unmarked, even
// though its position shifted (row 5 is now row 6).
getRowHeaderSymbol(6).should('have.text', ' ')
})
})
})
it("22 | DC.ROW_STATUS resolves to a live cell reference, reacting to this row's own edit status", () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
// _____EDIT_STATUS_____ itself is a purely client-synthesized
// column (see editStatusColumnRule.ts) - it must never render as a
// visible header, only exist as something DC.ROW_STATUS can
// point a cell reference at.
cy.get('.ht_clone_top .htCore thead tr th').should(($ths) => {
const texts = [...$ths].map((th) => th.innerText.trim())
expect(texts).not.to.include('_____EDIT_STATUS_____')
})
getCellByHeaderAndRow(0, 'ROW_STATUS_COL').should('have.text', 'U')
getCellByHeaderAndRow(0, 'A_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('999{enter}')
})
getCellByHeaderAndRow(0, 'ROW_STATUS_COL').should('have.text', 'M')
})
})
})
it('23 | DC.USER_NAME resolves to the current logged-in user', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(0, 'USER_NAME_COL').should('have.text', 'sasdemo')
})
})
})
it("24 | DC.ORIG_VALUE echoes this row's pre-edit value, and is blank for a newly-inserted row", () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(0, 'ORIG_VALUE_COL').should('have.text', 'orig-1')
insertRowViaContextMenu(0, 'Insert Row below')
// No dataSourceUnchanged match exists for a brand-new row, so
// DC.ORIG_VALUE falls back to blank rather than echoing anything.
getCellByHeaderAndRow(1, 'ORIG_VALUE_COL').should('have.text', '')
})
})
})
it('25 | Row header symbols stay aligned with their own row after sorting by another column', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
// Mark row 2 (PRIMARY_KEY_FIELD=3) for delete before sorting, so
// there's a distinctive symbol ('-') to track across the reorder.
getCellByHeaderAndRow(2, 'Delete?').then(($cell) => {
setDeleteFlag($cell[0], 'Yes', () => {
getRowHeaderSymbol(2).should('have.text', '-')
sortByColumn('FORMULA_SOFT_COL')
// Wherever PRIMARY_KEY_FIELD=3 lands visually after sorting,
// its OWN row header must show '-' - not whatever symbol
// previously belonged to that visual position.
cy.get('.ht_clone_top .htCore thead tr th')
.should(($ths) => {
const texts = [...$ths].map((th) => th.innerText.trim())
expect(texts).to.include('PRIMARY_KEY_FIELD')
})
.then(($ths) => {
const pkColIndex = [...$ths].findIndex(
(th) => th.innerText.trim() === 'PRIMARY_KEY_FIELD'
)
cy.get('.ht_master tbody tr').then((rows: any) => {
const sortedRowIndex = [...rows].findIndex(
(row: any) =>
row.childNodes[pkColIndex].innerText.trim() === '3'
)
getRowHeaderSymbol(sortedRowIndex).should('have.text', '-')
})
})
})
})
})
})
})
// CHANGE_SUMMARY_COL combines all three DC.* variables in one formula:
// IF( DC.ROW_STATUS ="U","unedited", DC.USER_NAME &" changed from "& DC.ORIG_VALUE ).
// DC.USER_NAME/DC.ORIG_VALUE are frozen literals baked in at load time,
// while DC.ROW_STATUS is a live cell reference - so editing the row
// doesn't recompute the "changed from" text, it just flips which
// already-computed branch IF() reveals.
it("26 | Combining DC.ROW_STATUS/DC.USER_NAME/DC.ORIG_VALUE in one formula reveals the frozen 'changed from' text once the row is edited", () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(0, 'CHANGE_SUMMARY_COL').should(
'have.text',
'unedited'
)
getCellByHeaderAndRow(0, 'A_COL')
.dblclick({ force: true })
.then(() => {
cy.focused().clear().type('999{enter}')
})
getCellByHeaderAndRow(0, 'CHANGE_SUMMARY_COL').should(
'have.text',
'sasdemo changed from orig-1'
)
})
})
})
}) })
// Handsontable virtualizes columns — with 17 columns on MPE_X_NEW, only // Handsontable virtualizes columns — with 18 columns on MPE_X_NEW, only
// the ones in the current viewport actually exist in the DOM. Scroll all // 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 // the way right so REGEX_HARD_COL/REGEX_SOFT_COL/REGEX_BOTH_COL (the last
// all. Same technique already used in excel.cy.ts. Must be called (and // three) render at all. Same technique already used in excel.cy.ts. Must
// re-settle) before any header/body query below, since scrolling replaces // be called (and re-settle) before any header/body query below, since
// the previously-rendered column nodes. // scrolling replaces the previously-rendered column nodes.
const scrollGridRight = () => { const scrollGridRight = () => {
return cy return cy
.get('#hotTable') .get('#hotTable')
@@ -280,6 +784,37 @@ const scrollGridRight = () => {
.scrollTo('right') .scrollTo('right')
} }
// Clicks a column header's sort indicator (Handsontable's multiColumnSorting
// plugin decorates every sortable header with a `.columnSorting` element -
// HEADER_SORT_CLASS in Handsontable's own source) to sort ascending by that
// column. force: true for the same reason as openColumnDropdown - an
// overlay clone layer can sit over the real target.
const sortByColumn = (headerText: string) => {
cy.get('.ht_clone_top .htCore thead tr th')
.filter((_, th) => Cypress.$(th).text().includes(headerText))
.last()
.find('.columnSorting')
.click({ force: true })
}
// 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 // Locates a body cell by its column's header text rather than a hardcoded
// childNodes index. Handsontable's hiddenColumns plugin (e.g. HIDDEN_COL, // childNodes index. Handsontable's hiddenColumns plugin (e.g. HIDDEN_COL,
// used by several demo columns ahead of REGEX_HARD_COL/REGEX_SOFT_COL in // used by several demo columns ahead of REGEX_HARD_COL/REGEX_SOFT_COL in
@@ -313,6 +848,34 @@ const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
}) })
} }
// Reads the row-header gutter's text for the given row - this is the
// left-hand "select entire row" bar (rowHeaders callback in
// editor.component.ts), doubling as the edit-status indicator
// (+/-/~/blank). .ht_clone_left mirrors .ht_clone_top's frozen-clone role,
// just for the row axis instead of the column axis.
const getRowHeaderSymbol = (rowIndex: number) => {
return cy
.get('.ht_clone_left .htCore tbody tr')
.eq(rowIndex)
.find('th .rowHeader')
}
// Right-clicks the given row's PRIMARY_KEY_FIELD cell to open the row's
// context menu, then clicks the given item. 'Insert Row above'/'below' are
// hidden when hotTable.readOnly is true, so this only works after
// clickOnEdit(). force: true - same reasoning as toggleColumnLabelsFromContextMenu
// in viewer-labels.cy.ts, the right-clicked cell can sit under an overlay
// clone layer that fails Cypress's actionability check.
const insertRowViaContextMenu = (
rowIndex: number,
menuItemText: 'Insert Row above' | 'Insert Row below'
) => {
getCellByHeaderAndRow(rowIndex, 'PRIMARY_KEY_FIELD').rightclick({
force: true
})
cy.get('.htContextMenu').contains(menuItemText).click()
}
// Opens the _____DELETE__THIS__RECORD_____ dropdown editor on the given cell // Opens the _____DELETE__THIS__RECORD_____ dropdown editor on the given cell
// and picks the given choice ('Yes'/'No') — same technique as // and picks the given choice ('Yes'/'No') — same technique as
// coltype-delete-record.cy.ts (arrow -> autocompleteEditor choice list). // coltype-delete-record.cy.ts (arrow -> autocompleteEditor choice list).
+1 -1
View File
@@ -8,7 +8,7 @@ const check = (cwd) => {
start: cwd, start: cwd,
excludePrivatePackages: true, excludePrivatePackages: true,
onlyAllow: onlyAllow:
'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;', 'AFLv2.1;Apache 2.0;Apache-2.0;Apache*;Artistic-2.0;BlueOak-1.0.0;0BSD;BSD*;BSD-2-Clause;BSD-3-Clause;CC0-1.0;CC-BY-3.0;CC-BY-4.0;ISC;MIT;MPL-2.0;ODC-By-1.0;Python-2.0;Unlicense;',
excludePackages: excludePackages:
'@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;@handsontable/angular-wrapper@18.0.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1' '@cds/city@1.1.0;@handsontable/angular-wrapper@16.0.1;@handsontable/angular-wrapper@17.1.0;@handsontable/angular-wrapper@18.0.0;handsontable@^16.0.1;handsontable@16.2.0;handsontable@17.1.0;handsontable@18.0.0;hyperformula@2.7.1;hyperformula@3.0.0;hyperformula@3.1.0;hyperformula@3.2.0;hyperformula@3.3.0;jackspeak@3.4.3;path-scurry@1.11.1;package-json-from-dist@1.0.1;buffers@0.1.1'
}, },
+494 -304
View File
File diff suppressed because it is too large Load Diff
+5 -1
View File
@@ -147,6 +147,10 @@
"overrides": { "overrides": {
"ajv": "8.18.0", "ajv": "8.18.0",
"uuid": "11.1.1", "uuid": "11.1.1",
"lighthouse": "13.4.0" "lighthouse": "13.4.0",
"exceljs": {
"archiver": "^8.0.0",
"unzipper": "^0.12.5"
}
} }
} }
+237 -60
View File
@@ -39,6 +39,9 @@ import { EventService } from '../services/event.service'
import { HelperService } from '../services/helper.service' import { HelperService } from '../services/helper.service'
import { LoggerService } from '../services/logger.service' import { LoggerService } from '../services/logger.service'
import { SasService } from '../services/sas.service' import { SasService } from '../services/sas.service'
import { UserService } from '../shared/user.service'
import { applyFormulaRules } from '../shared/dc-validator/utils/applyFormulaRules'
import { parseFormulaRule } from '../shared/dc-validator/utils/parseFormulaRule'
import { DcValidator } from '../shared/dc-validator/dc-validator' import { DcValidator } from '../shared/dc-validator/dc-validator'
import { Col } from '../shared/dc-validator/models/col.model' import { Col } from '../shared/dc-validator/models/col.model'
import { DcValidation } from '../shared/dc-validator/models/dc-validation.model' import { DcValidation } from '../shared/dc-validator/models/dc-validation.model'
@@ -46,6 +49,8 @@ import { DQRule } from '../shared/dc-validator/models/dq-rules.model'
import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema' import { getHotDataSchema } from '../shared/dc-validator/utils/getHotDataSchema'
import { excelRound } from '../shared/dc-validator/utils/excelRound' import { excelRound } from '../shared/dc-validator/utils/excelRound'
import { isEmpty } from '../shared/dc-validator/utils/isEmpty' import { isEmpty } from '../shared/dc-validator/utils/isEmpty'
import { hasFormulaRules } from '../shared/dc-validator/utils/hasFormulaRules'
import { HyperFormula } from 'hyperformula'
import { parseLabelsParam } from '../shared/utils/parse-labels-param' import { parseLabelsParam } from '../shared/utils/parse-labels-param'
import { getDisplayColHeaders } from '../shared/utils/display-col-headers' import { getDisplayColHeaders } from '../shared/utils/display-col-headers'
import { buildColInfoHtml } from '../shared/utils/col-info-html' import { buildColInfoHtml } from '../shared/utils/col-info-html'
@@ -58,6 +63,9 @@ import {
import { EditRecordInputFocusedEvent } from './models/edit-record/edit-record-events' import { EditRecordInputFocusedEvent } from './models/edit-record/edit-record-events'
import { EditorRestrictions } from './models/editor-restrictions.model' import { EditorRestrictions } from './models/editor-restrictions.model'
import { parseTableColumns } from './utils/grid.utils' import { parseTableColumns } from './utils/grid.utils'
import { classifyRow } from './utils/classifyRow'
import { getEditStatusSymbol } from './utils/getEditStatusSymbol'
import { EDIT_STATUS_COLUMN_NAME } from '../shared/dc-validator/utils/editStatusColumnRule'
import { import {
errorRenderer, errorRenderer,
noSpinnerRenderer, noSpinnerRenderer,
@@ -486,7 +494,8 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
private cdf: ChangeDetectorRef, private cdf: ChangeDetectorRef,
private spreadsheetService: SpreadsheetService, private spreadsheetService: SpreadsheetService,
private vaMessaging: VaMessagingService, private vaMessaging: VaMessagingService,
private vaFilter: VaFilterService private vaFilter: VaFilterService,
private userService: UserService
) { ) {
this.parseRestrictions() this.parseRestrictions()
this.setRestrictions() this.setRestrictions()
@@ -760,6 +769,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
if (!itemObject['_____DELETE__THIS__RECORD_____']) if (!itemObject['_____DELETE__THIS__RECORD_____'])
itemObject['_____DELETE__THIS__RECORD_____'] = 'No' itemObject['_____DELETE__THIS__RECORD_____'] = 'No'
// EDIT_STATUS is never part of the uploaded file (client-only, see
// editStatusColumnRule.ts) - default it the same as a fresh load.
itemObject[EDIT_STATUS_COLUMN_NAME] = 'U'
previewDatasource.push(itemObject) previewDatasource.push(itemObject)
}) })
@@ -1301,18 +1314,20 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
setTimeout(() => { setTimeout(() => {
const hot = this.hotInstance const hot = this.hotInstance
const newIndex = this.dataSource.length
// Create a new empty row object with proper structure // hot.alter() (rather than splicing dataSource and calling
const newRow = this.createEmptyRow() // updateSettings) is what triggers the formulas plugin's own
// insert-row hooks - without it, HyperFormula's sheet never learns
// Add the new row to the data source // about the new row and HARDFORMULA/SOFTFORMULA columns silently
this.dataSource.push(newRow) // fall out of sync with the grid's own data.
hot.alter('insert_row_below', newIndex - 1, 1)
// Update the hot table with the new data this.dataSource[newIndex].noLinkOption = true
hot.updateSettings({ data: this.dataSource }, false) this.seedFormulaValuesForRow(newIndex)
this.updateEditStatusForRow(newIndex)
// Select the newly added row // Select the newly added row
hot.selectCell(this.dataSource.length - 1, 0) hot.selectCell(newIndex, 0)
hot.render() hot.render()
this.addingNewRow = false this.addingNewRow = false
@@ -1321,39 +1336,102 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
}) })
} }
/**
* Creates a new empty row object with proper structure.
* Columns with NOTNULL DQ rules are pre-populated with their RULE_VALUE.
*/
private createEmptyRow(): any {
const newRow: any = {}
this.cellValidation.forEach((rule: any) => {
const dataKey = rule.data
newRow[dataKey] = this.hotDataSchema.hasOwnProperty(dataKey)
? this.hotDataSchema[dataKey]
: ''
})
newRow['noLinkOption'] = true
return newRow
}
/** /**
* Inserts a new row at the specified position and updates the table * Inserts a new row at the specified position and updates the table
*/ */
private insertRowAtPosition(targetRow: number): void { private insertRowAtPosition(targetRow: number): void {
const newRow = this.createEmptyRow()
// Insert the new row at the target position
this.dataSource.splice(targetRow, 0, newRow)
// Update the hot table
const hot = this.hotInstance const hot = this.hotInstance
hot.updateSettings({ data: this.dataSource }, false)
hot.render() // See addRow()'s comment - hot.alter() is required for HyperFormula to
// learn about the new row. beforeCreateRow only allows this while
// addingNewRow is set (see its own hook registration).
this.addingNewRow = true
hot.alter('insert_row_above', targetRow, 1)
this.addingNewRow = false
// alter() builds the new row from the grid's configured dataSchema
// (NOTNULL defaults included), which doesn't know about noLinkOption -
// set it directly on the row alter() just spliced into dataSource.
this.dataSource[targetRow].noLinkOption = true
this.seedFormulaValuesForRow(targetRow)
this.updateEditStatusForRow(targetRow)
this.reSetCellValidationValues() this.reSetCellValidationValues()
} }
/**
* Seeds HARDFORMULA/SOFTFORMULA columns on a newly-inserted row with
* their computed formula string, the same way applyFormulaRules seeds
* every row on initial load - a new row otherwise sits with a blank
* formula cell until the next full reload. DC.ORIG_VALUE naturally
* resolves to blank for this row (applyFormulaRules can't find a
* dataSourceUnchanged match for a row that never existed before).
*
* Written via hot.setDataAtRowProp (not a bare dataSource mutation, which
* is what applyFormulaRules itself does) so HyperFormula's engine
* actually learns about the new cell content - the same class of fix as
* hot.alter() above, just for a single cell instead of a row structure
* change.
*/
private seedFormulaValuesForRow(rowIndex: number): void {
const dqRules = this.dcValidator?.getDqDetails()
if (!dqRules || !hasFormulaRules(dqRules)) return
const hot = this.hotInstance
const userName = this.userService.user?.username ?? ''
const formulaRules = dqRules.filter(
(rule) =>
rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA'
)
// Computes only THIS row's formula string (parseFormulaRule directly,
// not applyFormulaRules on the full dataSource) - applyFormulaRules
// would overwrite every other row's formula column too, silently
// reverting any SOFTFORMULA override a user already typed elsewhere
// and rewriting every shifted row's cell-reference string (its
// spreadsheet position changed), both of which make classifyRow see a
// false diff against dataSourceUnchanged and misreport those untouched
// rows as modified.
for (const rule of formulaRules) {
const value = parseFormulaRule(rule.RULE_VALUE, {
columnNames: this.headerColumns,
rowIndex,
userName,
origValue: undefined
})
hot.setDataAtRowProp(rowIndex, rule.BASE_COL, value)
}
}
/**
* Recomputes this row's classification (M/A/D/U) and writes it into the
* EDIT_STATUS cell - the same classification the row-header symbol shows,
* but written into a real Handsontable cell so DC.ROW_STATUS-based
* formulas can actually react to it. Written via hot.setDataAtRowProp
* with a distinct source string (not a bare dataSource mutation) so
* HyperFormula is notified and dependents recalculate - the source string
* also lets the afterChange hook below recognise and ignore its own
* writes, avoiding infinite recursion.
*/
private updateEditStatusForRow(rowIndex: number): void {
const dataRow = this.dataSource[rowIndex]
if (!dataRow) return
const status = classifyRow(
dataRow,
this.dataSourceUnchanged ?? this.dataSource,
this.headerPks
)
this.hotInstance.setDataAtRowProp(
rowIndex,
EDIT_STATUS_COLUMN_NAME,
status,
'editStatus'
)
}
public cancelSubmit() { public cancelSubmit() {
this.dataSource = this.helperService.deepClone(this.dataSourceBeforeSubmit) this.dataSource = this.helperService.deepClone(this.dataSourceBeforeSubmit)
this.dataSourceBeforeSubmit = [] this.dataSourceBeforeSubmit = []
@@ -1402,31 +1480,23 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
for (let i = 0; i < this.dataSource.length; i++) { for (let i = 0; i < this.dataSource.length; i++) {
const dataRow = this.helperService.deepClone(this.dataSource[i]) const dataRow = this.helperService.deepClone(this.dataSource[i])
if (dataRow._____DELETE__THIS__RECORD_____ === 'Yes') { switch (classifyRow(dataRow, this.dataSourceUnchanged, this.headerPks)) {
this.dataModified.push(dataRow) case 'D':
rowsDeleted++ this.dataModified.push(dataRow)
} else { rowsDeleted++
const dataRowUnchanged = this.dataSourceUnchanged.find((row: any) => { break
for (const pkCol of this.headerPks) { case 'A':
if (row[pkCol] !== dataRow[pkCol]) {
return false
}
}
return true
})
if (dataRowUnchanged) {
if (JSON.stringify(dataRow) !== JSON.stringify(dataRowUnchanged)) {
this.dataModified.push(dataRow)
this.modifedRowsIndexes.push(i)
rowsUpdated++
}
} else {
this.dataModified.push(dataRow) this.dataModified.push(dataRow)
this.modifedRowsIndexes.push(i) this.modifedRowsIndexes.push(i)
rowsAdded++ rowsAdded++
} break
case 'M':
this.dataModified.push(dataRow)
this.modifedRowsIndexes.push(i)
rowsUpdated++
break
case 'U':
break
} }
} }
@@ -1819,7 +1889,11 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
while (this.dataSource.length > 0) { while (this.dataSource.length > 0) {
const lastRow = this.dataSource[this.dataSource.length - 1] const lastRow = this.dataSource[this.dataSource.length - 1]
const isEmpty = Object.keys(lastRow).every((key) => { const isEmpty = Object.keys(lastRow).every((key) => {
if (key === '_____DELETE__THIS__RECORD_____') return true if (
key === '_____DELETE__THIS__RECORD_____' ||
key === EDIT_STATUS_COLUMN_NAME
)
return true
return !lastRow[key] || lastRow[key] === '' return !lastRow[key] || lastRow[key] === ''
}) })
@@ -1906,6 +1980,10 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
delete row['_____DELETE__THIS__RECORD_____'] delete row['_____DELETE__THIS__RECORD_____']
row['_____DELETE__THIS__RECORD_____'] = deleteColValue row['_____DELETE__THIS__RECORD_____'] = deleteColValue
// EDIT_STATUS is client-only (see editStatusColumnRule.ts) - it must
// never reach the backend.
delete row[EDIT_STATUS_COLUMN_NAME]
// If cell is numeric and value is dot `.` we change it to `null` // If cell is numeric and value is dot `.` we change it to `null`
Object.keys(row).map((key: string) => { Object.keys(row).map((key: string) => {
const colRule = this.dcValidator?.getRule(key) const colRule = this.dcValidator?.getRule(key)
@@ -3125,6 +3203,13 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
this.headerArray = this.headerColumns.slice(1) this.headerArray = this.headerColumns.slice(1)
// EDIT_STATUS is never part of COLHEADERS (it's client-synthesized, see
// editStatusColumnRule.ts) - appended after headerArray is derived so it
// stays out of whatever headerArray drives, but before headerColumns is
// used below for cell-reference math (applyFormulaRules/DcValidator's
// rules must stay index-aligned with headerColumns).
this.headerColumns.push(EDIT_STATUS_COLUMN_NAME)
if (response.data.sasparams[0].DTVARS !== '') { if (response.data.sasparams[0].DTVARS !== '') {
this.dateHeaders = response.data.sasparams[0].DTVARS.split(' ') this.dateHeaders = response.data.sasparams[0].DTVARS.split(' ')
} }
@@ -3146,12 +3231,43 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
response.data.dqdata response.data.dqdata
) )
// Only turn on Handsontable's formulas plugin (HyperFormula engine) for
// tables that actually use HARDFORMULA/SOFTFORMULA - it's not free to
// run for every grid. gpl-v3: this app embeds HyperFormula under its
// GPLv3 free-tier terms, not a purchased commercial key.
this.hotTable.formulas = hasFormulaRules(response.data.dqrules)
? { engine: HyperFormula, licenseKey: 'gpl-v3' }
: false
this.cellValidation = this.dcValidator.getRules() this.cellValidation = this.dcValidator.getRules()
// to take datasource // to take datasource
this.dataSource = response.data.sasdata this.dataSource = response.data.sasdata
this.$dataFormats = response.data.$sasdata this.$dataFormats = response.data.$sasdata
// Seed every row's EDIT_STATUS to 'U' before anything (HyperFormula
// included) ever sees this data - nothing has been edited yet, and this
// is also the baseline dataSourceUnchanged will later be cloned from, so
// future diffs never see a spurious EDIT_STATUS mismatch (see
// classifyRow's withoutEditStatus for why that would matter).
for (const row of this.dataSource) {
row[EDIT_STATUS_COLUMN_NAME] = 'U'
}
// Seed HARDFORMULA/SOFTFORMULA columns with their computed formula
// string per row. dataSourceUnchanged isn't populated yet this early in
// a fresh load (only the excel-preview/add-row flows set it) - at this
// point, before any edits exist, dataSource IS the unchanged baseline,
// so DC.ORIG_VALUE resolves correctly either way.
applyFormulaRules(
this.dataSource,
response.data.dqrules,
this.headerColumns,
this.dataSourceUnchanged ?? this.dataSource,
this.headerPks,
this.userService.user?.username ?? ''
)
// Note: this.headerColumns and this.columnHeader contains same data // Note: this.headerColumns and this.columnHeader contains same data
// need to resolve redundancy // need to resolve redundancy
@@ -3204,9 +3320,35 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
filters: false, filters: false,
manualRowResize: true, manualRowResize: true,
viewportRowRenderingOffset: 100, viewportRowRenderingOffset: 100,
// show a bar on the left to enable users to select an entire row // Doubles as the edit-status indicator: +/-/~ for
// added/deleted/modified rows. Handsontable re-invokes this on
// every render, so it stays live as rows are edited/added/deleted
// without any extra wiring. Falls back to a plain space (not an
// empty string) for unchanged rows and before dataSource is
// populated, keeping the original row-selection bar's click target
// intact - a space and '' render identically, so this changes
// nothing visually.
//
// `index` is the VISUAL row - once multiColumnSorting reorders the
// grid, that no longer matches dataSource's physical order, so it
// must be translated via toPhysicalRow() before indexing into
// dataSource. Without this, a sorted grid shows every row's symbol
// shifted to the wrong row.
rowHeaders: (index: number) => { rowHeaders: (index: number) => {
return ' ' const physicalRow = hot.toPhysicalRow(index)
const dataRow =
physicalRow === null ? undefined : this.dataSource[physicalRow]
if (!dataRow) return ' '
return (
getEditStatusSymbol(
classifyRow(
dataRow,
this.dataSourceUnchanged ?? this.dataSource,
this.headerPks
)
) || ' '
)
}, },
rowHeaderWidth: 15, rowHeaderWidth: 15,
rowHeights: 24, rowHeights: 24,
@@ -3254,7 +3396,18 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
colName = this.hotInstance?.colToProp(selectedCol) as string colName = this.hotInstance?.colToProp(selectedCol) as string
colInfo = this.$dataFormats?.vars[colName] colInfo = this.$dataFormats?.vars[colName]
textInfo = buildColInfoHtml(colName, colInfo) const { hardRegexValue, softRegexValue } =
this.dcValidator?.getRegexRuleValues(colName) || {}
const formulaValue =
this.dcValidator?.getFormulaRuleValue(colName)
textInfo = buildColInfoHtml(
colName,
colInfo,
hardRegexValue,
softRegexValue,
formulaValue
)
} }
elem.innerHTML = textInfo elem.innerHTML = textInfo
@@ -3467,6 +3620,30 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
} }
}) })
// Keeps each edited row's EDIT_STATUS cell in sync live, so
// DC.ROW_STATUS-based formulas recalculate immediately - not just on
// insert/delete (already handled at their own call sites) but on any
// direct edit, paste or autofill too. 'loadData' fires on every
// hot.updateSettings({data: ...}) call this component already makes
// (initial load, cancelSubmit, ...) where dataSource is already
// consistent, so it's skipped; 'editStatus' is this same hook's own
// writes (via updateEditStatusForRow), skipped to avoid recursion.
hot.addHook('afterChange', (changes: any[], source: any) => {
if (!changes || source === 'loadData' || source === 'editStatus') return
const changedRows = new Set<number>()
for (const change of changes) {
if (!change) continue
const [row, prop] = change
if (prop === EDIT_STATUS_COLUMN_NAME) continue
changedRows.add(row)
}
for (const row of changedRows) this.updateEditStatusForRow(row)
})
hot.addHook('afterPaste', async (_data: any, coords: any) => { hot.addHook('afterPaste', async (_data: any, coords: any) => {
// In read-only mode HOT discards the paste itself, so nothing to validate. // In read-only mode HOT discards the paste itself, so nothing to validate.
if (this.hotTable.readOnly) return if (this.hotTable.readOnly) return
@@ -0,0 +1,77 @@
import { classifyRow } from './classifyRow'
import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule'
describe('classifyRow', () => {
const headerPks = ['PRIMARY_KEY_FIELD']
it('is D when the row is marked for delete, regardless of other field changes', () => {
const dataRow = {
PRIMARY_KEY_FIELD: 1,
SOME_CHAR: 'changed',
_____DELETE__THIS__RECORD_____: 'Yes'
}
const dataSourceUnchanged = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
]
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('D')
})
it('is A when no row in dataSourceUnchanged matches the primary key(s)', () => {
const dataRow = { PRIMARY_KEY_FIELD: 99, SOME_CHAR: 'new row' }
const dataSourceUnchanged = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
]
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('A')
})
it('is M when a matching row exists but a field differs', () => {
const dataRow = { PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'changed' }
const dataSourceUnchanged = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
]
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('M')
})
it('is U when a matching row exists and nothing differs', () => {
const dataRow = { PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
const dataSourceUnchanged = [
{ PRIMARY_KEY_FIELD: 1, SOME_CHAR: 'original' }
]
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('U')
})
it('matches on all primary key columns for a composite key', () => {
const headerPksComposite = ['LIB', 'ID']
const dataRow = { LIB: 'WORK', ID: 1, SOME_CHAR: 'original' }
const dataSourceUnchanged = [
{ LIB: 'OTHER', ID: 1, SOME_CHAR: 'original' },
{ LIB: 'WORK', ID: 1, SOME_CHAR: 'original' }
]
// Same ID=1 exists under a different LIB - must not match that one.
expect(
classifyRow(dataRow, dataSourceUnchanged, headerPksComposite)
).toEqual('U')
})
it('ignores its own EDIT_STATUS column value when diffing, so writing the classification back never self-perpetuates a stale M', () => {
const dataRow = {
PRIMARY_KEY_FIELD: 1,
SOME_CHAR: 'original',
[EDIT_STATUS_COLUMN_NAME]: 'M'
}
const dataSourceUnchanged = [
{
PRIMARY_KEY_FIELD: 1,
SOME_CHAR: 'original',
[EDIT_STATUS_COLUMN_NAME]: 'U'
}
]
expect(classifyRow(dataRow, dataSourceUnchanged, headerPks)).toEqual('U')
})
})
@@ -0,0 +1,44 @@
import { EDIT_STATUS_COLUMN_NAME } from '../../shared/dc-validator/utils/editStatusColumnRule'
export type RowEditStatus = 'M' | 'A' | 'D' | 'U'
// EDIT_STATUS holds this very function's own prior output - comparing it
// like any other field would self-perpetuate: once a row is written as 'M',
// its EDIT_STATUS would forever differ from the unchanged snapshot's 'U'
// even after the real edit is reverted, permanently stuck on 'M'.
const withoutEditStatus = (row: any): any => {
const { [EDIT_STATUS_COLUMN_NAME]: _editStatus, ...rest } = row
return rest
}
/**
* Classifies a single row as Modified/Added/Deleted/Unchanged against the
* pre-edit snapshot (`dataSourceUnchanged`), PK-matched via `headerPks`.
*
* Extracted from editor.component.ts's getRowsSubmittingCount(), which used
* this same logic inline, only at submit time. Kept here as a pure function
* so it can run live (e.g. on every afterChange, for the EDIT_STATUS column)
* without duplicating the diffing rules.
*/
export const classifyRow = (
dataRow: any,
dataSourceUnchanged: any[],
headerPks: string[]
): RowEditStatus => {
if (dataRow._____DELETE__THIS__RECORD_____ === 'Yes') return 'D'
const dataRowUnchanged = dataSourceUnchanged.find((row: any) => {
for (const pkCol of headerPks) {
if (row[pkCol] !== dataRow[pkCol]) return false
}
return true
})
if (!dataRowUnchanged) return 'A'
return JSON.stringify(withoutEditStatus(dataRow)) !==
JSON.stringify(withoutEditStatus(dataRowUnchanged))
? 'M'
: 'U'
}
@@ -0,0 +1,19 @@
import { getEditStatusSymbol } from './getEditStatusSymbol'
describe('getEditStatusSymbol', () => {
it("returns '+' for Added", () => {
expect(getEditStatusSymbol('A')).toEqual('+')
})
it("returns '-' for Deleted", () => {
expect(getEditStatusSymbol('D')).toEqual('-')
})
it("returns '~' for Modified", () => {
expect(getEditStatusSymbol('M')).toEqual('~')
})
it("returns '' for Unchanged", () => {
expect(getEditStatusSymbol('U')).toEqual('')
})
})
@@ -0,0 +1,20 @@
import { RowEditStatus } from './classifyRow'
/**
* Maps a row's edit status to the symbol shown in the row-header gutter
* (see editor.component.ts's rowHeaders callback). Unchanged renders as an
* empty string rather than a letter, since it's the common case and would
* otherwise clutter every untouched row.
*/
export const getEditStatusSymbol = (status: RowEditStatus): string => {
switch (status) {
case 'A':
return '+'
case 'D':
return '-'
case 'M':
return '~'
case 'U':
return ''
}
}
@@ -19,13 +19,34 @@ describe('makeRegexWarningRenderer', () => {
return { hot, container } return { hot, container }
} }
it('adds dc-warning-cell when the value fails the pattern', () => { const buildHotNumeric = (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}$', undefined, true)
},
{ data: '_____DELETE__THIS__RECORD_____' }
],
licenseKey: 'non-commercial-and-evaluation'
})
hot.render()
return { hot, container }
}
it('adds dc-warning-cell and a REGEX: title when the value fails the pattern', () => {
const { hot, container } = buildHot([ const { hot, container } = buildHot([
{ val: 'abc', _____DELETE__THIS__RECORD_____: 'No' } { val: 'abc', _____DELETE__THIS__RECORD_____: 'No' }
]) ])
const td = hot.getCell(0, 0) const td = hot.getCell(0, 0)
expect(td?.classList.contains('dc-warning-cell')).toBeTrue() expect(td?.classList.contains('dc-warning-cell')).toBeTrue()
expect(td?.title).toEqual('REGEX: ^[A-Z]{3}$')
hot.destroy() hot.destroy()
container.remove() container.remove()
@@ -55,9 +76,9 @@ describe('makeRegexWarningRenderer', () => {
container.remove() container.remove()
}) })
it('does not add dc-warning-cell for a SAS special missing value', () => { it('does not add dc-warning-cell for the plain SAS missing (".") on a numeric column', () => {
const { hot, container } = buildHot([ const { hot, container } = buildHotNumeric([
{ val: '.a', _____DELETE__THIS__RECORD_____: 'No' } { val: '.', _____DELETE__THIS__RECORD_____: 'No' }
]) ])
const td = hot.getCell(0, 0) const td = hot.getCell(0, 0)
@@ -67,6 +88,18 @@ describe('makeRegexWarningRenderer', () => {
container.remove() container.remove()
}) })
it('DOES add dc-warning-cell for a special missing on a numeric column (a deliberately-set value)', () => {
const { hot, container } = buildHotNumeric([
{ val: '.a', _____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 throw and never warns on a malformed pattern', () => { it('does not throw and never warns on a malformed pattern', () => {
const container = document.createElement('div') const container = document.createElement('div')
document.body.appendChild(container) document.body.appendChild(container)
@@ -128,4 +161,154 @@ describe('makeRegexWarningRenderer', () => {
hot.destroy() hot.destroy()
container.remove() 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('never warns for SOFTREGEX when the column also has HARDREGEX, even for a value that passes HARDREGEX', () => {
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) - only one regex runs per column, so the soft rule is
// ignored entirely: no warning, no title.
expect(td?.classList.contains('dc-warning-cell')).toBeFalse()
expect(td?.title).toEqual('')
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,46 @@ import Handsontable from 'handsontable'
import { isRegexRuleExempt } from '../../shared/dc-validator/utils/isRegexRuleExempt' import { isRegexRuleExempt } from '../../shared/dc-validator/utils/isRegexRuleExempt'
import { parseRegexRule } from '../../shared/dc-validator/utils/parseRegexRule' 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 * Builds a display-only HOT renderer for HARDREGEX/SOFTREGEX: neither ever
* pattern gets a yellow `dc-warning-cell` class (styles.scss) instead of * returns false from the cell validator here (that's dqValidate's job for
* blocking submission — SOFTREGEX must never return false from the cell * HARDREGEX, which blocks submission and paints HOT's own red htInvalid
* validator (that would paint htInvalid and block submit), so the warning * independently of this renderer) - this renderer only adds the matching
* lives purely at the render layer, same split as makeNumberFormatRenderer. * `REGEX: <pattern>` title, plus a yellow `dc-warning-cell` class when only
* SOFTREGEX fails, same split as makeNumberFormatRenderer.
*
* Only one regex ever runs per column: when a HARDREGEX rule exists,
* SOFTREGEX is ignored entirely (never compiled, never evaluated) -
* regardless of whether individual cell values pass or fail the hard
* rule. A value failing HARDREGEX gets its title (no yellow class - red
* htInvalid already covers the color). This mirrors
* DcValidator.failsSoftRegex's own precedence.
* *
* Suppressed on rows marked for delete (_____DELETE__THIS__RECORD_____ = * Suppressed on rows marked for delete (_____DELETE__THIS__RECORD_____ =
* 'Yes') — a warning about data about to be removed is just noise. * 'Yes') — a warning about data about to be removed is just noise.
* *
* Falls back to no warning ever showing when the pattern itself is * Falls back to no warning ever showing when a pattern itself is malformed,
* malformed, rather than breaking the cell. * rather than breaking the cell.
*/ */
export const makeRegexWarningRenderer = (pattern?: string) => { export const makeRegexWarningRenderer = (
let regex: RegExp | null = null softPattern?: string,
hardPattern?: string,
try { isNumeric: boolean = false
if (pattern) regex = parseRegexRule(pattern) ) => {
} catch (e) { const hardRegex = compileRegex(hardPattern, 'HARDREGEX')
console.warn(`SOFTREGEX - invalid pattern, warning disabled: ${pattern}`) const softRegex = hardRegex ? null : compileRegex(softPattern, 'SOFTREGEX')
regex = null
}
const baseRenderer = Handsontable.renderers.getRenderer('text') const baseRenderer = Handsontable.renderers.getRenderer('text')
@@ -40,13 +58,20 @@ export const makeRegexWarningRenderer = (pattern?: string) => {
const markedForDelete = const markedForDelete =
instance.getDataAtRowProp(row, '_____DELETE__THIS__RECORD_____') === 'Yes' instance.getDataAtRowProp(row, '_____DELETE__THIS__RECORD_____') === 'Yes'
const exempt = isRegexRuleExempt(value, isNumeric)
const failsPattern = const failsHard =
!!regex && !isRegexRuleExempt(value) && !regex.test(value.toString()) !!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.classList.add('dc-warning-cell')
td.title = 'Value does not match the expected pattern' td.title = `REGEX: ${softPattern}`
} }
return td return td
+2 -1
View File
@@ -14,7 +14,8 @@ export interface ColumnDetail {
LENGTH: number LENGTH: number
NAME: string NAME: string
TYPE: string TYPE: string
VARNUM: number // No longer sent by viewdata.sas — optional for legacy responses.
VARNUM?: number
} }
export interface Approver { export interface Approver {
@@ -55,8 +55,8 @@ describe('VaFilterService', () => {
'SOME_DATETIME' 'SOME_DATETIME'
] ]
const cols = [ const cols = [
{ NAME: 'SOME_CHAR', DDTYPE: 'CHARACTER' }, { NAME: 'SOME_CHAR', DDTYPE: 'C' },
{ NAME: 'SOME_NUM', DDTYPE: 'NUMERIC' }, { NAME: 'SOME_NUM', DDTYPE: 'N' },
{ NAME: 'SOME_TIME', DDTYPE: 'TIME' }, { NAME: 'SOME_TIME', DDTYPE: 'TIME' },
{ NAME: 'SOME_DATE', DDTYPE: 'DATE' }, { NAME: 'SOME_DATE', DDTYPE: 'DATE' },
{ NAME: 'SOME_DATETIME', DDTYPE: 'DATETIME' } { NAME: 'SOME_DATETIME', DDTYPE: 'DATETIME' }
+8 -2
View File
@@ -163,14 +163,20 @@ export class VaFilterService {
/** /**
* SAS data-type kind of a column spec. DDTYPE carries * SAS data-type kind of a column spec. DDTYPE carries
* TIME/DATE/DATETIME/NUMERIC/CHARACTER; checked DATETIME-before-DATE (substring). * TIME/DATE/DATETIME in full, with C/N for CHARACTER/NUMERIC (legacy
* responses may still carry the full strings); checked DATETIME-before-DATE
* (substring).
*/ */
private columnKind(col: any): VaColumnKind { private columnKind(col: any): VaColumnKind {
const ddtype = (col?.DDTYPE ?? '').toString().toUpperCase() const ddtype = (col?.DDTYPE ?? '').toString().toUpperCase()
if (ddtype.includes('DATETIME')) return 'datetime' if (ddtype.includes('DATETIME')) return 'datetime'
if (ddtype.includes('DATE')) return 'date' if (ddtype.includes('DATE')) return 'date'
if (ddtype.includes('TIME')) return 'time' if (ddtype.includes('TIME')) return 'time'
if (ddtype.includes('NUMERIC') || (col?.TYPE ?? '') === 'num') { if (
ddtype === 'N' ||
ddtype.includes('NUMERIC') ||
(col?.TYPE ?? '') === 'num'
) {
return 'numeric' return 'numeric'
} }
return 'char' return 'char'
@@ -19,6 +19,7 @@ import { getNotNullDefault } from './utils/getNotNullDefault'
import { mergeColsRules } from './utils/mergeColsRules' import { mergeColsRules } from './utils/mergeColsRules'
import { parseColTypeRow } from './utils/parseColTypeRow' import { parseColTypeRow } from './utils/parseColTypeRow'
import { DELETE_RECORD_COLUMN_RULE } from './utils/deleteRecordColumnRule' import { DELETE_RECORD_COLUMN_RULE } from './utils/deleteRecordColumnRule'
import { EDIT_STATUS_COLUMN_RULE } from './utils/editStatusColumnRule'
import { dqValidate } from './validations/dq-validation' import { dqValidate } from './validations/dq-validation'
import { import {
datetimeValidator, datetimeValidator,
@@ -84,6 +85,13 @@ export class DcValidator {
this.rules = mergeColsRules(cols, this.rules, $dataFormats) this.rules = mergeColsRules(cols, this.rules, $dataFormats)
this.rules = applyNumericFormats(this.rules) this.rules = applyNumericFormats(this.rules)
this.rules = mapIntlCellTypes(this.rules) this.rules = mapIntlCellTypes(this.rules)
// EDIT_STATUS is appended last, always hidden - it's a purely
// client-synthesized column (never in COLHEADERS) that gives
// DC.ROW_STATUS a real cell to reference. See its own doc comment.
this.rules.push({ ...EDIT_STATUS_COLUMN_RULE })
this.hiddenColumns.push(this.rules.length - 1)
this.dqrules = dqRules this.dqrules = dqRules
this.dqdata = dqData this.dqdata = dqData
this.primaryKeys = sasparams.PK.split(' ') this.primaryKeys = sasparams.PK.split(' ')
@@ -204,6 +212,50 @@ export class DcValidator {
return isNaN(digits) ? undefined : digits 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. Both are
* returned raw - the caller (buildColInfoHtml) decides which one is
* actually applied (HARDREGEX wins when both exist).
*
* @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
}
}
/**
* Returns the RULE_VALUE of a HARDFORMULA/SOFTFORMULA rule on the given
* column, for display in the column-header info dropdown. A column only
* ever carries one of the two in practice (readonly-computed vs.
* overridable-default aren't a meaningful combination), so unlike
* getRegexRuleValues there's no need to distinguish which one this is.
*
* @param col column name
*/
getFormulaRuleValue(col: string): string | undefined {
const formulaRule = this.dqrules.find(
(rule: DQRule) =>
rule.BASE_COL === col &&
(rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA')
)
return formulaRule?.RULE_VALUE
}
/** /**
* Retrieves dropdown source for given dc validation rule * Retrieves dropdown source for given dc validation rule
* The values comes from MPE_SELECTBOX table * The values comes from MPE_SELECTBOX table
@@ -247,14 +299,23 @@ export class DcValidator {
* exists. SOFTREGEX never goes through dqValidate/the cell validator (see * exists. SOFTREGEX never goes through dqValidate/the cell validator (see
* setupValidations — it's a display-only grid renderer instead), so the * setupValidations — it's a display-only grid renderer instead), so the
* edit-record modal, which has no grid renderer to hook into, uses this * edit-record modal, which has no grid renderer to hook into, uses this
* directly to show the same warning outside the grid. HARDREGEX takes * directly to show the same warning outside the grid.
* precedence when both rules apply to the column, matching the grid's own *
* behaviour (setupValidations skips wiring the SOFTREGEX renderer there * A column can carry both HARDREGEX and SOFTREGEX at once, but only one
* too) — a dual-rule column should render red/blocked, never yellow. * regex ever runs per column: if a HARDREGEX rule exists, SOFTREGEX is
* ignored entirely - the cell is already governed by the blocking rule,
* so a yellow warning on top would be redundant, even for values that
* pass the hard rule. Same precedence as makeRegexWarningRenderer.
*/ */
failsSoftRegex(col: string, value: any): boolean { failsSoftRegex(col: string, value: any): boolean {
if (this.hasDqRules(col, ['HARDREGEX'])) return false const isNumeric =
if (isRegexRuleExempt(value)) return false this.rules.find((rule) => rule.data === col)?.type === 'numeric'
if (isRegexRuleExempt(value, isNumeric)) return false
const hardRegexRule = this.dqrules.find(
(rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'HARDREGEX'
)
if (hardRegexRule) return false
const softRegexRule = this.dqrules.find( const softRegexRule = this.dqrules.find(
(rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'SOFTREGEX' (rule: DQRule) => rule.BASE_COL === col && rule.RULE_TYPE === 'SOFTREGEX'
@@ -405,6 +466,14 @@ export class DcValidator {
this.rules[i].readOnly = true this.rules[i].readOnly = true
} }
// HARDFORMULA: same read-only treatment as an explicit READONLY rule
// - its value is always computed (see applyFormulaRules), never
// user-editable. SOFTFORMULA deliberately does not set this: its
// computed value is only a default, the user can overwrite it.
if (this.hasDqRules(ruleColName, ['HARDFORMULA'])) {
this.rules[i].readOnly = true
}
// HIDDEN: hide column in HOT but keep its data (still submitted via hot.getData()) // HIDDEN: hide column in HOT but keep its data (still submitted via hot.getData())
if (this.hasDqRules(ruleColName, ['HIDDEN'])) { if (this.hasDqRules(ruleColName, ['HIDDEN'])) {
this.hiddenColumns.push(i) this.hiddenColumns.push(i)
@@ -425,25 +494,21 @@ export class DcValidator {
this.rules[i].numericFormat = undefined this.rules[i].numericFormat = undefined
} }
// SOFTREGEX: display-only warning (yellow), never blocks submission — // HARDREGEX/SOFTREGEX: submission-blocking for HARDREGEX still goes
// unlike HARDREGEX (see dq-validation.ts), which goes through the // through the normal validator/dqValidate path (see dq-validation.ts)
// normal validator/dqValidate path instead. Last-wins against // and is unaffected by this renderer. This only wires the display
// NUMBER_FORMAT if a column somehow carried both (not expected in // layer - a 'REGEX: <pattern>' title, plus a yellow dc-warning-cell
// practice — one formats numbers, the other pattern-matches text). // when only SOFTREGEX fails (never for HARDREGEX, which relies on
// // HOT's own red htInvalid instead). Last-wins against NUMBER_FORMAT
// HARDREGEX takes precedence when both apply to the same column: skip // if a column somehow carried both (not expected in practice — one
// wiring this renderer entirely, so a failing value stays HOT's own // formats numbers, the other pattern-matches text).
// red htInvalid (from HARDREGEX/dqValidate) rather than being if (this.hasDqRules(ruleColName, ['HARDREGEX', 'SOFTREGEX'])) {
// visually overridden by this renderer's yellow dc-warning-cell. const { hardRegexValue, softRegexValue } =
if ( this.getRegexRuleValues(ruleColName)
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( this.rules[i].renderer = makeRegexWarningRenderer(
softRegexRule?.RULE_VALUE softRegexValue,
hardRegexValue,
this.rules[i].type === 'numeric'
) )
} }
} }
@@ -532,7 +597,11 @@ export class DcValidator {
} }
if (self.isDqCol(col || '')) { if (self.isDqCol(col || '')) {
const dqValid = dqValidate(self.getDqDetails(col || ''), value) const dqValid = dqValidate(
self.getDqDetails(col || ''),
value,
colType === 'numeric'
)
if (!dqValid) { if (!dqValid) {
console.warn(`DQ Validation - invalid (Value: ${value})`) console.warn(`DQ Validation - invalid (Value: ${value})`)
@@ -1,6 +1,8 @@
export interface Col { export interface Col {
NAME: string NAME: string
VARNUM: number // No longer sent by getdata.sas (dropped to trim the COLS payload) —
// column order in the array is authoritative. Optional for legacy responses.
VARNUM?: number
LABEL: string LABEL: string
FMTNAME: string FMTNAME: string
DDTYPE: string DDTYPE: string
@@ -20,3 +20,5 @@ export type DQRuleTypes =
| 'NUMBER_FORMAT' | 'NUMBER_FORMAT'
| 'HARDREGEX' | 'HARDREGEX'
| 'SOFTREGEX' | 'SOFTREGEX'
| 'HARDFORMULA'
| 'SOFTFORMULA'
@@ -3,6 +3,7 @@ import { DQData, SASParam } from 'src/app/models/TableData'
import { DcValidator } from '../dc-validator' import { DcValidator } from '../dc-validator'
import { Col } from '../models/col.model' import { Col } from '../models/col.model'
import { DQRule } from '../models/dq-rules.model' import { DQRule } from '../models/dq-rules.model'
import { EDIT_STATUS_COLUMN_NAME } from '../utils/editStatusColumnRule'
describe('DC Validator', () => { describe('DC Validator', () => {
it('should create an instance of validator with correct rules', () => { it('should create an instance of validator with correct rules', () => {
@@ -24,10 +25,10 @@ describe('DC Validator', () => {
expect(cols[0].TYPE).toEqual('char') expect(cols[0].TYPE).toEqual('char')
// Get all — one rule per cols[] entry, plus the injected // Get all — one rule per cols[] entry, plus the injected
// DELETE_RECORD_COLUMN_RULE (never present in cols[], see its own // DELETE_RECORD_COLUMN_RULE and EDIT_STATUS_COLUMN_RULE (neither is ever
// doc comment) // present in cols[], see their own doc comments)
const validationRules = dcValidator.getRules() const validationRules = dcValidator.getRules()
expect(validationRules).toHaveSize(example_cols.length + 1) expect(validationRules).toHaveSize(example_cols.length + 2)
// Get col with notnull validation // Get col with notnull validation
const someNumRule = dcValidator.getRule('SOME_NUM') const someNumRule = dcValidator.getRule('SOME_NUM')
@@ -75,6 +76,17 @@ describe('DC Validator', () => {
) )
expect(deleteRecordRule?.type).toEqual('dropdown') expect(deleteRecordRule?.type).toEqual('dropdown')
expect(deleteRecordRule?.source).toEqual(['No', 'Yes']) expect(deleteRecordRule?.source).toEqual(['No', 'Yes'])
// EDIT_STATUS is likewise never a cols[] entry - it's a purely
// client-synthesized column (see editStatusColumnRule.ts) so
// DC.ROW_STATUS has a real cell to reference. Always the last rule,
// always read-only, always hidden.
const editStatusRule = dcValidator.getRule(EDIT_STATUS_COLUMN_NAME)
expect(editStatusRule?.readOnly).toBeTrue()
expect(validationRules[validationRules.length - 1].data).toEqual(
EDIT_STATUS_COLUMN_NAME
)
expect(dcValidator.getHiddenColumns()).toContain(validationRules.length - 1)
}) })
it('should create an instance of validator and execute its functions', () => { it('should create an instance of validator and execute its functions', () => {
@@ -305,9 +317,10 @@ describe('DC Validator', () => {
example_dqData example_dqData
) )
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual( expect(dcValidator.getRules().map((rule) => rule.data)).toEqual([
example_sasparams.COLHEADERS.split(',') ...example_sasparams.COLHEADERS.split(','),
) EDIT_STATUS_COLUMN_NAME
])
}) })
it("6 | keeps rules aligned with COLHEADERS when the PK is not the source table's first column", () => { it("6 | keeps rules aligned with COLHEADERS when the PK is not the source table's first column", () => {
@@ -344,9 +357,10 @@ describe('DC Validator', () => {
[] []
) )
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual( expect(dcValidator.getRules().map((rule) => rule.data)).toEqual([
sasparams.COLHEADERS.split(',') ...sasparams.COLHEADERS.split(','),
) EDIT_STATUS_COLUMN_NAME
])
}) })
it('7 | falls back to a text rule rather than dropping a column with an unparseable COLTYPE', () => { it('7 | falls back to a text rule rather than dropping a column with an unparseable COLTYPE', () => {
@@ -373,9 +387,10 @@ describe('DC Validator', () => {
[] []
) )
expect(dcValidator.getRules().map((rule) => rule.data)).toEqual( expect(dcValidator.getRules().map((rule) => rule.data)).toEqual([
sasparams.COLHEADERS.split(',') ...sasparams.COLHEADERS.split(','),
) EDIT_STATUS_COLUMN_NAME
])
}) })
it('8 | does not un-hide an unrelated column when a CLS EDIT column was never hidden', () => { it('8 | does not un-hide an unrelated column when a CLS EDIT column was never hidden', () => {
@@ -555,7 +570,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 // 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- // isolates SOFTREGEX's own wiring. The renderer's own pass/fail/delete-
// suppression behaviour is covered by regex-warning-renderer.spec.ts — // suppression behaviour is covered by regex-warning-renderer.spec.ts —
@@ -589,11 +629,11 @@ describe('DC Validator', () => {
) )
}) })
it('12 | HARDREGEX takes precedence over SOFTREGEX on a dual-rule column', () => { it('13 | wires a renderer for a dual-rule column, and HARDREGEX still blocks submission', () => {
// Both rules on the same column: HARDREGEX must win, so the cell // Both rules on the same column: submission blocking is still governed
// renders red (blocked) via HOT's own htInvalid, not yellow. No // entirely by HARDREGEX/dqValidate (unchanged). A renderer is wired so
// SOFTREGEX renderer is wired at all here, so there's nothing that could // the cell gets a 'REGEX: <pattern>' title - its own hard-vs-soft
// visually compete with htInvalid for that column. // precedence and coloring are covered by regex-warning-renderer.spec.ts.
const dcValidator: DcValidator = new DcValidator( const dcValidator: DcValidator = new DcValidator(
example_sasparams, example_sasparams,
example_dataformats, example_dataformats,
@@ -624,10 +664,10 @@ describe('DC Validator', () => {
expect(valid).toBeFalse() 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 // SOFTREGEX never goes through dqValidate (see the wiring in
// setupValidations), so the edit-record modal — which has no grid // setupValidations), so the edit-record modal — which has no grid
// renderer to hook into — calls this directly to show the same warning. // renderer to hook into — calls this directly to show the same warning.
@@ -699,7 +739,7 @@ describe('DC Validator', () => {
).toBeFalse() ).toBeFalse()
}) })
it('is false for blank/special-missing values, same exemption as HARDREGEX', () => { it('is false for blank values; special-missing-looking values are real text on a character column', () => {
const dcValidator = buildValidator([ const dcValidator = buildValidator([
{ {
BASE_COL: 'SOME_CHAR_ANY', BASE_COL: 'SOME_CHAR_ANY',
@@ -710,28 +750,188 @@ describe('DC Validator', () => {
]) ])
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', '')).toBeFalse() expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', '')).toBeFalse()
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', '.a')).toBeFalse() // ".a" is real text here and fails ^[A-Z]+$
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', '.a')).toBeTrue()
}) })
it('is false when the column also has HARDREGEX (precedence — no yellow on a red cell)', () => { it('exempts the plain missing (".") but not special missings on a numeric column', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_NUM',
RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[0-9]+$',
X: 0
}
])
expect(dcValidator.failsSoftRegex('SOME_NUM', '.')).toBeFalse()
// Special missings are deliberately-set values and face the pattern.
expect(dcValidator.failsSoftRegex('SOME_NUM', '.a')).toBeTrue()
})
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 false for every value when the column has HARDREGEX - SOFTREGEX never runs, even if HARDREGEX passes', () => {
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) - only one regex runs per column, so the soft rule is
// ignored entirely.
expect(dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'AB')).toBeFalse()
})
})
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([ const dcValidator = buildValidator([
{ {
BASE_COL: 'SOME_CHAR_ANY', BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'HARDREGEX', RULE_TYPE: 'HARDREGEX',
RULE_VALUE: '^[A-Z]+$', RULE_VALUE: '^[A-Z]+$',
X: 0 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', BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTREGEX', RULE_TYPE: 'SOFTREGEX',
RULE_VALUE: '^[A-Z]+$', RULE_VALUE: '/\\b(the|data)\\b/i',
X: 0 X: 0
} }
]) ])
expect( expect(dcValidator.getRegexRuleValues('SOME_CHAR_ANY')).toEqual({
dcValidator.failsSoftRegex('SOME_CHAR_ANY', 'lowercase') hardRegexValue: undefined,
).toBeFalse() 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
})
})
})
describe('16 | getFormulaRuleValue (column-header info display)', () => {
const buildValidator = (dqRules: DQRule[]) =>
new DcValidator(
example_sasparams,
example_dataformats,
example_cols,
dqRules,
example_dqData
)
it('returns the RULE_VALUE for a HARDFORMULA rule', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'HARDFORMULA',
RULE_VALUE: 'A_COL * B_COL',
X: 0
}
])
expect(dcValidator.getFormulaRuleValue('SOME_CHAR_ANY')).toEqual(
'A_COL * B_COL'
)
})
it('returns the RULE_VALUE for a SOFTFORMULA rule', () => {
const dcValidator = buildValidator([
{
BASE_COL: 'SOME_CHAR_ANY',
RULE_TYPE: 'SOFTFORMULA',
RULE_VALUE: 'A_COL + B_COL',
X: 0
}
])
expect(dcValidator.getFormulaRuleValue('SOME_CHAR_ANY')).toEqual(
'A_COL + B_COL'
)
})
it('returns undefined for a column with no formula rule', () => {
const dcValidator = buildValidator([])
expect(dcValidator.getFormulaRuleValue('SOME_CHAR_ANY')).toBeUndefined()
}) })
}) })
}) })
@@ -743,7 +943,7 @@ const makeCol = (name: string, varnum: number): Col =>
VARNUM: varnum, VARNUM: varnum,
LABEL: name, LABEL: name,
FMTNAME: '', FMTNAME: '',
DDTYPE: 'CHARACTER', DDTYPE: 'C',
TYPE: '', TYPE: '',
CLS_RULE: 'READ', CLS_RULE: 'READ',
MEMLABEL: '', MEMLABEL: '',
@@ -865,7 +1065,7 @@ const example_dqRules: any = [
const example_cols = [ const example_cols = [
{ {
CLS_RULE: 'READ', CLS_RULE: 'READ',
DDTYPE: 'CHARACTER', DDTYPE: 'C',
DESC: 'dropdown_desc', DESC: 'dropdown_desc',
TYPE: '', TYPE: '',
FMTNAME: '', FMTNAME: '',
@@ -878,7 +1078,7 @@ const example_cols = [
}, },
{ {
CLS_RULE: 'READ', CLS_RULE: 'READ',
DDTYPE: 'NUMERIC', DDTYPE: 'N',
DESC: '', DESC: '',
TYPE: '', TYPE: '',
FMTNAME: '', FMTNAME: '',
@@ -891,7 +1091,7 @@ const example_cols = [
}, },
{ {
CLS_RULE: 'READ', CLS_RULE: 'READ',
DDTYPE: 'NUMERIC', DDTYPE: 'N',
DESC: '', DESC: '',
TYPE: '', TYPE: '',
FMTNAME: '', FMTNAME: '',
@@ -904,7 +1104,7 @@ const example_cols = [
}, },
{ {
CLS_RULE: 'READ', CLS_RULE: 'READ',
DDTYPE: 'CHARACTER', DDTYPE: 'C',
DESC: '', DESC: '',
TYPE: '', TYPE: '',
FMTNAME: '', FMTNAME: '',
@@ -917,7 +1117,7 @@ const example_cols = [
}, },
{ {
CLS_RULE: 'READ', CLS_RULE: 'READ',
DDTYPE: 'CHARACTER', DDTYPE: 'C',
DESC: '', DESC: '',
TYPE: '', TYPE: '',
FMTNAME: '', FMTNAME: '',
@@ -930,7 +1130,7 @@ const example_cols = [
}, },
{ {
CLS_RULE: 'READ', CLS_RULE: 'READ',
DDTYPE: 'CHARACTER', DDTYPE: 'C',
DESC: '', DESC: '',
TYPE: '', TYPE: '',
FMTNAME: '', FMTNAME: '',
@@ -943,7 +1143,7 @@ const example_cols = [
}, },
{ {
CLS_RULE: 'READ', CLS_RULE: 'READ',
DDTYPE: 'CHARACTER', DDTYPE: 'C',
DESC: '', DESC: '',
TYPE: '', TYPE: '',
FMTNAME: '', FMTNAME: '',
@@ -995,7 +1195,7 @@ const example_cols = [
}, },
{ {
CLS_RULE: 'READ', CLS_RULE: 'READ',
DDTYPE: 'NUMERIC', DDTYPE: 'N',
DESC: '', DESC: '',
TYPE: '', TYPE: '',
FMTNAME: '', FMTNAME: '',
@@ -1008,7 +1208,7 @@ const example_cols = [
}, },
{ {
CLS_RULE: 'READ', CLS_RULE: 'READ',
DDTYPE: 'NUMERIC', DDTYPE: 'N',
DESC: '', DESC: '',
TYPE: '', TYPE: '',
FMTNAME: '', FMTNAME: '',
@@ -0,0 +1,116 @@
import { DQRule } from '../models/dq-rules.model'
import { applyFormulaRules } from './applyFormulaRules'
const rule = (overrides: Partial<DQRule>): DQRule => ({
BASE_COL: 'REVENUE',
RULE_TYPE: 'HARDFORMULA',
RULE_VALUE: 'PRICE * VOLUME',
X: 0,
...overrides
})
const columnNames = ['ITEM', 'PRICE', 'VOLUME', 'REVENUE']
const headerPks = ['ITEM']
describe('applyFormulaRules', () => {
it('leaves the dataset untouched when there are no formula rules', () => {
const dataSource = [{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, REVENUE: '' }]
const result = applyFormulaRules(
dataSource,
[],
columnNames,
dataSource,
headerPks,
'sasdemo'
)
expect(result).toEqual(dataSource)
})
it("injects the row-relative formula string for a HARDFORMULA column (issue's own PRICE*VOLUME example)", () => {
const dataSource = [
{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, REVENUE: '' },
{ ITEM: 'PEN', PRICE: 61.02, VOLUME: 1971, REVENUE: '' }
]
const result = applyFormulaRules(
dataSource,
[rule({})],
columnNames,
dataSource,
headerPks,
'sasdemo'
)
expect(result[0].REVENUE).toEqual('=B1 * C1')
expect(result[1].REVENUE).toEqual('=B2 * C2')
})
it('injects the formula for a SOFTFORMULA column too (only readOnly-ness differs, handled elsewhere)', () => {
const dataSource = [{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, REVENUE: '' }]
const result = applyFormulaRules(
dataSource,
[rule({ RULE_TYPE: 'SOFTFORMULA' })],
columnNames,
dataSource,
headerPks,
'sasdemo'
)
expect(result[0].REVENUE).toEqual('=B1 * C1')
})
it('resolves DC.ORIG_VALUE via the original (pre-edit) row, PK-matched', () => {
const dataSourceUnchanged = [
{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, NOTE: 'original note' }
]
const dataSource = [{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 999, NOTE: '' }]
const result = applyFormulaRules(
dataSource,
[rule({ BASE_COL: 'NOTE', RULE_VALUE: 'DC.ORIG_VALUE' })],
['ITEM', 'PRICE', 'VOLUME', 'NOTE'],
dataSourceUnchanged,
headerPks,
'sasdemo'
)
expect(result[0].NOTE).toEqual('="original note"')
})
it('resolves DC.ORIG_VALUE to an empty literal for a newly-added row with no PK match', () => {
const dataSource = [{ ITEM: 'NEWITEM', PRICE: 1, VOLUME: 1, NOTE: '' }]
const result = applyFormulaRules(
dataSource,
[rule({ BASE_COL: 'NOTE', RULE_VALUE: 'DC.ORIG_VALUE' })],
['ITEM', 'PRICE', 'VOLUME', 'NOTE'],
[],
headerPks,
'sasdemo'
)
expect(result[0].NOTE).toEqual('=""')
})
it('resolves DC.USER_NAME to the current username for every row', () => {
const dataSource = [
{ ITEM: 'PAPER', PRICE: 4.2, VOLUME: 100, NOTE: '' },
{ ITEM: 'PEN', PRICE: 61.02, VOLUME: 1971, NOTE: '' }
]
const result = applyFormulaRules(
dataSource,
[rule({ BASE_COL: 'NOTE', RULE_VALUE: 'DC.USER_NAME' })],
['ITEM', 'PRICE', 'VOLUME', 'NOTE'],
dataSource,
headerPks,
'sasdemo'
)
expect(result[0].NOTE).toEqual('="sasdemo"')
expect(result[1].NOTE).toEqual('="sasdemo"')
})
})
@@ -0,0 +1,44 @@
import { DQRule } from '../models/dq-rules.model'
import { parseFormulaRule } from './parseFormulaRule'
/**
* Injects the computed `=...` formula string into each row's data for every
* HARDFORMULA/SOFTFORMULA column, so Handsontable/HyperFormula displays the
* evaluated result. HARDFORMULA's readOnly-ness is handled separately in
* dc-validator.ts, alongside the existing READONLY rule - this only deals
* with the actual computed value.
*
* Mutates and returns `dataSource` (same in-place-mutate convention as
* applyNumericFormats).
*/
export const applyFormulaRules = (
dataSource: any[],
dqRules: DQRule[],
columnNames: string[],
dataSourceUnchanged: any[],
headerPks: string[],
userName: string
): any[] => {
const formulaRules = dqRules.filter(
(rule) =>
rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA'
)
if (formulaRules.length === 0) return dataSource
dataSource.forEach((row, rowIndex) => {
for (const formulaRule of formulaRules) {
const origRow = dataSourceUnchanged.find((candidate) =>
headerPks.every((pk) => candidate[pk] === row[pk])
)
row[formulaRule.BASE_COL] = parseFormulaRule(formulaRule.RULE_VALUE, {
columnNames,
rowIndex,
userName,
origValue: origRow ? origRow[formulaRule.BASE_COL] : undefined
})
}
})
return dataSource
}
@@ -7,6 +7,10 @@ import { DcValidation } from '../models/dc-validation.model'
* Uses Intl.NumberFormat options (HOT 17+) instead of the deprecated numbro * Uses Intl.NumberFormat options (HOT 17+) instead of the deprecated numbro
* `pattern`/`culture`. `maximumFractionDigits: 20` preserves all natural * `pattern`/`culture`. `maximumFractionDigits: 20` preserves all natural
* decimals (Intl's default of 3 would round); `locale` replaces `culture`. * decimals (Intl's default of 3 would round); `locale` replaces `culture`.
* `useGrouping: false` keeps raw digits (no thousands separator) - a
* separator would leak into anything that pattern-matches the displayed
* value (eg HARDREGEX/SOFTREGEX), and grouping can be opted into per-column
* with the NUMBER_FORMAT rule.
* *
* @param rules Cell Validation rules to be updated * @param rules Cell Validation rules to be updated
* Those rules are passed in the `columns` property Of handsontable settings. * Those rules are passed in the `columns` property Of handsontable settings.
@@ -14,7 +18,7 @@ import { DcValidation } from '../models/dc-validation.model'
export const applyNumericFormats = (rules: DcValidation[]): DcValidation[] => { export const applyNumericFormats = (rules: DcValidation[]): DcValidation[] => {
for (let rule of rules) { for (let rule of rules) {
if (rule.type === 'numeric') { if (rule.type === 'numeric') {
rule.numericFormat = { useGrouping: true, maximumFractionDigits: 20 } rule.numericFormat = { useGrouping: false, maximumFractionDigits: 20 }
rule.locale = window.navigator.language rule.locale = window.navigator.language
} }
} }
@@ -0,0 +1,22 @@
import { DcValidation } from '../models/dc-validation.model'
/**
* EDIT_STATUS is a purely client-synthesized column (M/A/D/U from
* classifyRow), added so DC.ROW_STATUS has a real, HyperFormula-addressable
* cell to point at. It never comes from COLHEADERS, is always hidden (see
* DcValidator's constructor) and is stripped before the submit payload
* leaves the browser (see editor.component.ts's saveTable()).
*
* Heavily decorated with underscores, same convention as
* DELETE_RECORD_COLUMN_RULE - a plain 'EDIT_STATUS' could collide with a
* real column of that name in the actual dataset, which would silently
* overwrite that column's real data (see editor.component.ts's seeding
* loop) and drop it from the submit payload entirely.
*/
export const EDIT_STATUS_COLUMN_NAME = '_____EDIT_STATUS_____'
export const EDIT_STATUS_COLUMN_RULE: DcValidation = {
data: EDIT_STATUS_COLUMN_NAME,
type: 'text',
readOnly: true
}
@@ -0,0 +1,48 @@
import { DQRule } from '../models/dq-rules.model'
import { hasFormulaRules } from './hasFormulaRules'
const rule = (overrides: Partial<DQRule>): DQRule => ({
BASE_COL: 'SOME_COL',
RULE_TYPE: 'NOTNULL',
RULE_VALUE: '',
X: 0,
...overrides
})
describe('hasFormulaRules', () => {
it('is false for an empty rule set', () => {
expect(hasFormulaRules([])).toBeFalse()
})
it('is false when no rule is HARDFORMULA/SOFTFORMULA', () => {
const rules = [
rule({ RULE_TYPE: 'NOTNULL' }),
rule({ RULE_TYPE: 'HARDREGEX' }),
rule({ RULE_TYPE: 'SOFTREGEX' })
]
expect(hasFormulaRules(rules)).toBeFalse()
})
it('is true when a HARDFORMULA rule is present', () => {
const rules = [rule({ RULE_TYPE: 'HARDFORMULA' })]
expect(hasFormulaRules(rules)).toBeTrue()
})
it('is true when a SOFTFORMULA rule is present', () => {
const rules = [rule({ RULE_TYPE: 'SOFTFORMULA' })]
expect(hasFormulaRules(rules)).toBeTrue()
})
it('is true when a formula rule is mixed in with unrelated rules', () => {
const rules = [
rule({ RULE_TYPE: 'NOTNULL' }),
rule({ RULE_TYPE: 'SOFTFORMULA' }),
rule({ RULE_TYPE: 'HARDREGEX' })
]
expect(hasFormulaRules(rules)).toBeTrue()
})
})
@@ -0,0 +1,12 @@
import { DQRule } from '../models/dq-rules.model'
/**
* Whether any rule in the set is HARDFORMULA/SOFTFORMULA - used to gate
* turning on Handsontable's `formulas` plugin (HyperFormula engine) only
* when a table actually uses it, rather than for every grid.
*/
export const hasFormulaRules = (dqRules: DQRule[]): boolean =>
dqRules.some(
(rule) =>
rule.RULE_TYPE === 'HARDFORMULA' || rule.RULE_TYPE === 'SOFTFORMULA'
)
@@ -1,19 +1,40 @@
import { isRegexRuleExempt } from './isRegexRuleExempt' import { isRegexRuleExempt } from './isRegexRuleExempt'
describe('isRegexRuleExempt', () => { describe('isRegexRuleExempt', () => {
it('exempts blank/undefined/null', () => { it('exempts blank/undefined/null on any column type', () => {
expect(isRegexRuleExempt('')).toBeTrue() expect(isRegexRuleExempt('')).toBeTrue()
expect(isRegexRuleExempt(undefined)).toBeTrue() expect(isRegexRuleExempt(undefined)).toBeTrue()
expect(isRegexRuleExempt(null)).toBeTrue() expect(isRegexRuleExempt(null)).toBeTrue()
expect(isRegexRuleExempt('', true)).toBeTrue()
}) })
it('exempts SAS special missing values', () => { it('exempts the plain SAS missing (".") on numeric columns', () => {
expect(isRegexRuleExempt('.')).toBeTrue() expect(isRegexRuleExempt('.', true)).toBeTrue()
expect(isRegexRuleExempt('.a')).toBeTrue() })
expect(isRegexRuleExempt('_')).toBeTrue()
it('does not exempt special missings on numeric columns (they are deliberately-set values)', () => {
expect(isRegexRuleExempt('.a', true)).toBeFalse()
expect(isRegexRuleExempt('._', true)).toBeFalse()
expect(isRegexRuleExempt('_', true)).toBeFalse()
expect(isRegexRuleExempt('d', true)).toBeFalse()
})
it('exempts nothing but blank on character columns', () => {
expect(isRegexRuleExempt('.')).toBeFalse()
expect(isRegexRuleExempt('.a')).toBeFalse()
expect(isRegexRuleExempt('_')).toBeFalse()
})
it('does not exempt a bare single letter on character columns (real value, not a missing)', () => {
// A bare single letter is real character data, not a SAS special
// missing - it must reach the pattern, not be exempted.
expect(isRegexRuleExempt('d')).toBeFalse()
expect(isRegexRuleExempt('z')).toBeFalse()
expect(isRegexRuleExempt('A')).toBeFalse()
}) })
it('does not exempt an ordinary value', () => { it('does not exempt an ordinary value', () => {
expect(isRegexRuleExempt('ABC123')).toBeFalse() expect(isRegexRuleExempt('ABC123')).toBeFalse()
expect(isRegexRuleExempt('ABC123', true)).toBeFalse()
}) })
}) })
@@ -1,13 +1,22 @@
import { isSpecialMissing } from '@sasjs/utils/input/validators'
/** /**
* HARDREGEX/SOFTREGEX both skip pattern-matching for blank and SAS special * HARDREGEX/SOFTREGEX both skip pattern-matching for:
* missing values (., .a-.z, _) — the same exemption NOTNULL/MINVAL/MAXVAL *
* already apply elsewhere, since neither convention represents a real * - blank values (undefined, null, '') on any column type - enforcing
* formatted value the pattern is meant to check. * populated values is NOTNULL's job, not the pattern's; and
* - the plain SAS numeric missing (".") on numeric columns - it
* represents the absence of a value, same as blank.
*
* SPECIAL missings (.a-.z, ._, bare letters) are NOT exempt, even on
* numeric columns: being deliberately set, they are real values the
* pattern is meant to check. This also means isSpecialMissing from
* @sasjs/utils (which would match them, with an optional dot) is
* deliberately not used here.
*/ */
export const isRegexRuleExempt = (value: any): boolean => { export const isRegexRuleExempt = (
value: any,
isNumeric: boolean = false
): boolean => {
if (value === undefined || value === null || value === '') return true if (value === undefined || value === null || value === '') return true
return isSpecialMissing(value) return isNumeric && value === '.'
} }
@@ -0,0 +1,96 @@
import { parseFormulaRule, FormulaVariableContext } from './parseFormulaRule'
import { EDIT_STATUS_COLUMN_NAME } from './editStatusColumnRule'
const context = (overrides: Partial<FormulaVariableContext> = {}) => ({
columnNames: ['ITEM', 'PRICE', 'VOLUME', 'REVENUE'],
rowIndex: 0,
userName: 'sasdemo',
origValue: 'sasinstaller',
...overrides
})
describe('parseFormulaRule', () => {
it("substitutes column-name variables with this row's cell references (issue's own PRICE/VOLUME example)", () => {
expect(parseFormulaRule('PRICE * VOLUME', context())).toEqual('=B1 * C1')
})
it('uses the row-relative reference for a later row', () => {
expect(
parseFormulaRule('PRICE * VOLUME', context({ rowIndex: 1 }))
).toEqual('=B2 * C2')
})
it('requires a leading/trailing blank (or string edge) around a variable - no match without it', () => {
// No spaces around '*' - PRICE/VOLUME here should NOT be recognized as
// variables (the issue's own rule: a named variable must have a
// leading and trailing blank to avoid clashing with function names).
expect(parseFormulaRule('PRICE*VOLUME', context())).toEqual('=PRICE*VOLUME')
})
it("does not substitute a variable name matched inside a function call with no surrounding blanks (the issue's MATCH() clash example)", () => {
// MATCH is not one of our column names here, but this proves adjacency
// to parens alone doesn't trigger substitution - only literal
// surrounding whitespace (or string start/end) does.
expect(parseFormulaRule('MATCH(PRICE)', context())).toEqual('=MATCH(PRICE)')
})
it("leaves variable occurrences inside quoted strings untouched (the issue's own ITEM example)", () => {
expect(parseFormulaRule('ITEM & " string ITEM "', context())).toEqual(
'=A1 & " string ITEM "'
)
})
it('substitutes DC.USER_NAME with the quoted, literal current username', () => {
expect(
parseFormulaRule('DC.USER_NAME', context({ userName: 'sasdemo' }))
).toEqual('=\"sasdemo\"')
})
it('substitutes DC.ORIG_VALUE with the quoted, literal original cell value', () => {
expect(
parseFormulaRule('DC.ORIG_VALUE', context({ origValue: 'sasinstaller' }))
).toEqual('=\"sasinstaller\"')
})
it('does not substitute DC.USER_NAME/DC.ORIG_VALUE inside quoted strings either', () => {
expect(parseFormulaRule('"DC.USER_NAME"', context())).toEqual(
'=\"DC.USER_NAME\"'
)
})
it('substitutes DC.ROW_STATUS with a cell reference to the EDIT_STATUS column, not a quoted literal', () => {
expect(
parseFormulaRule(
'IF( DC.ROW_STATUS ="U","unchanged","changed")',
context({
columnNames: ['ITEM', 'PRICE', 'VOLUME', EDIT_STATUS_COLUMN_NAME],
rowIndex: 0
})
)
).toEqual('=IF( D1 ="U","unchanged","changed")')
})
it('uses the row-relative reference for DC.ROW_STATUS on a later row', () => {
expect(
parseFormulaRule(
'DC.ROW_STATUS',
context({
columnNames: ['ITEM', EDIT_STATUS_COLUMN_NAME],
rowIndex: 4
})
)
).toEqual('=B5')
})
it('leaves DC.ROW_STATUS untouched when the EDIT_STATUS column is not present', () => {
expect(parseFormulaRule('DC.ROW_STATUS', context())).toEqual(
'=DC.ROW_STATUS'
)
})
it('does not substitute DC.ROW_STATUS inside quoted strings either', () => {
expect(parseFormulaRule('"DC.ROW_STATUS"', context())).toEqual(
'=\"DC.ROW_STATUS\"'
)
})
})
@@ -0,0 +1,94 @@
import Handsontable from 'handsontable'
import { EDIT_STATUS_COLUMN_NAME } from './editStatusColumnRule'
export interface FormulaVariableContext {
/** Column names in grid order - index drives the cell-reference letter. */
columnNames: string[]
/** 0-based physical row index - drives the cell-reference row number. */
rowIndex: number
/** Current logged-in user - DC.USER_NAME resolves to this, quoted. */
userName: string
/** Original (pre-edit) value of the cell this rule applies to - DC.ORIG_VALUE resolves to this, quoted. */
origValue: string | number | undefined
}
const escapeRegExpMetacharacters = (text: string): string =>
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
/**
* Replaces `token` with `replacement` wherever it has a leading and
* trailing blank (a literal space, or the start/end of this span). This
* lets `PRICE * VOLUME` substitute correctly while `MATCH(PRICE)` (no
* surrounding blanks) does not, without needing to know anything about
* function names.
*/
const substituteBoundedToken = (
text: string,
token: string,
replacement: string
): string => {
const pattern = new RegExp(
`(?<=^|\\s)${escapeRegExpMetacharacters(token)}(?=$|\\s)`,
'g'
)
return text.replace(pattern, replacement)
}
const quoteLiteral = (value: string | number | undefined): string =>
`"${value ?? ''}"`
export const parseFormulaRule = (
ruleValue: string,
context: FormulaVariableContext
): string => {
const quotedSpanPattern = /"[^"]*"|'[^']*'/g
let result = ''
let lastIndex = 0
let match: RegExpExecArray | null
const substituteUnquotedSpan = (span: string): string => {
let substituted = substituteBoundedToken(
span,
'DC.USER_NAME',
quoteLiteral(context.userName)
)
substituted = substituteBoundedToken(
substituted,
'DC.ORIG_VALUE',
quoteLiteral(context.origValue)
)
// Unlike DC.USER_NAME/DC.ORIG_VALUE, DC.ROW_STATUS resolves to a real
// cell reference (not a quoted literal) - it points at the EDIT_STATUS
// column, which holds this row's live M/A/D/U classification, so other
// formulas can react to it, e.g. `=if(A1!='U',"sasdemo","sasinstaller")`.
const rowStatusColIndex = context.columnNames.indexOf(
EDIT_STATUS_COLUMN_NAME
)
if (rowStatusColIndex !== -1) {
const rowStatusCellRef = `${Handsontable.helper.spreadsheetColumnLabel(rowStatusColIndex)}${context.rowIndex + 1}`
substituted = substituteBoundedToken(
substituted,
'DC.ROW_STATUS',
rowStatusCellRef
)
}
context.columnNames.forEach((columnName, columnIndex) => {
const cellRef = `${Handsontable.helper.spreadsheetColumnLabel(columnIndex)}${context.rowIndex + 1}`
substituted = substituteBoundedToken(substituted, columnName, cellRef)
})
return substituted
}
while ((match = quotedSpanPattern.exec(ruleValue))) {
result += substituteUnquotedSpan(ruleValue.slice(lastIndex, match.index))
result += match[0]
lastIndex = match.index + match[0].length
}
result += substituteUnquotedSpan(ruleValue.slice(lastIndex))
return `=${result}`
}
@@ -30,12 +30,28 @@ describe('dqValidate - HARDREGEX', () => {
expect(dqValidate(rules, null)).toBeTrue() expect(dqValidate(rules, null)).toBeTrue()
}) })
it('treats SAS special missing values as valid regardless of the pattern', () => { it('treats the plain SAS missing (".") as valid on numeric columns, regardless of the pattern', () => {
const rules = [rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' })] const rules = [rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' })]
expect(dqValidate(rules, '.')).toBeTrue() expect(dqValidate(rules, '.', true)).toBeTrue()
expect(dqValidate(rules, '.a')).toBeTrue() })
expect(dqValidate(rules, '_')).toBeTrue()
it('applies the pattern to special missings on numeric columns (deliberately-set values)', () => {
const rules = [rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' })]
expect(dqValidate(rules, '.a', true)).toBeFalse()
expect(dqValidate(rules, '_', true)).toBeFalse()
expect(dqValidate(rules, 'd', true)).toBeFalse()
})
it('applies the pattern to special-missing-looking values on character columns', () => {
const rules = [rule({ RULE_VALUE: '^[A-Z]{3}\\d{4}$' })]
expect(dqValidate(rules, '.')).toBeFalse()
expect(dqValidate(rules, '.a')).toBeFalse()
expect(dqValidate(rules, '_')).toBeFalse()
// A bare single letter is a real character value and must be matched.
expect(dqValidate(rules, 'd')).toBeFalse()
}) })
it('fails open (treats as valid) when the pattern is malformed', () => { it('fails open (treats as valid) when the pattern is malformed', () => {
@@ -44,6 +60,19 @@ describe('dqValidate - HARDREGEX', () => {
expect(dqValidate(rules, 'anything')).toBeTrue() expect(dqValidate(rules, 'anything')).toBeTrue()
}) })
it('validates a single letter on a character column against the pattern', () => {
// A bare single letter ("d", "z") is real character data, not a SAS
// special missing - it must reach the pattern, not be exempted.
const rules = [rule({ RULE_VALUE: '/the|data/i' })]
expect(dqValidate(rules, 'd')).toBeFalse()
expect(dqValidate(rules, 'z')).toBeFalse()
expect(dqValidate(rules, 'the')).toBeTrue()
expect(dqValidate(rules, 'DATA')).toBeTrue()
expect(dqValidate(rules, 'some data here')).toBeTrue()
expect(dqValidate(rules, '')).toBeTrue() // blank stays exempt
})
it('handles a more elaborate pattern (multiple character classes, quantifiers, an escaped literal dot)', () => { 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. // Same email pattern used in the getdata.js mock's REGEX_HARD_COL demo.
const rules = [rule({ RULE_VALUE: '[\\w.]+@[\\w]+\\.[a-z]{2,}' })] const rules = [rule({ RULE_VALUE: '[\\w.]+@[\\w]+\\.[a-z]{2,}' })]
@@ -4,7 +4,11 @@ import { isRegexRuleExempt } from '../utils/isRegexRuleExempt'
import { parseRegexRule } from '../utils/parseRegexRule' import { parseRegexRule } from '../utils/parseRegexRule'
const dqValidation: { const dqValidation: {
[key: string]: (value: any, ruleValue: string | number) => boolean [key: string]: (
value: any,
ruleValue: string | number,
isNumeric?: boolean
) => boolean
} = { } = {
CASE: (value: any, ruleValue: string | number): boolean => { CASE: (value: any, ruleValue: string | number): boolean => {
switch (ruleValue) { switch (ruleValue) {
@@ -51,8 +55,12 @@ const dqValidation: {
}, },
// Pattern is used as authored, not auto-anchored — a rule author who // Pattern is used as authored, not auto-anchored — a rule author who
// wants a full-value match must write ^...$ themselves. // wants a full-value match must write ^...$ themselves.
HARDREGEX: (value: any, ruleValue: string | number): boolean => { HARDREGEX: (
if (isRegexRuleExempt(value)) return true value: any,
ruleValue: string | number,
isNumeric: boolean = false
): boolean => {
if (isRegexRuleExempt(value, isNumeric)) return true
try { try {
return parseRegexRule(ruleValue.toString()).test(value.toString()) return parseRegexRule(ruleValue.toString()).test(value.toString())
@@ -65,10 +73,16 @@ const dqValidation: {
} }
} }
export const dqValidate = (dqRules: DQRule[], value: any): boolean => { export const dqValidate = (
dqRules: DQRule[],
value: any,
isNumeric: boolean = false
): boolean => {
for (let detail of dqRules) { for (let detail of dqRules) {
if (dqValidation[detail.RULE_TYPE]) { if (dqValidation[detail.RULE_TYPE]) {
if (!dqValidation[detail.RULE_TYPE](value, detail.RULE_VALUE)) { if (
!dqValidation[detail.RULE_TYPE](value, detail.RULE_VALUE, isNumeric)
) {
console.warn( console.warn(
`DQ Invalid Reason: ${ `DQ Invalid Reason: ${
detail.RULE_TYPE detail.RULE_TYPE
+1 -2
View File
@@ -6,7 +6,6 @@ import { RouterModule } from '@angular/router'
import { LoadingIndicatorComponent } from './loading-indicator/loading-indicator.component' import { LoadingIndicatorComponent } from './loading-indicator/loading-indicator.component'
import { LoginComponent } from './login/login.component' import { LoginComponent } from './login/login.component'
import { UserService } from './user.service'
import { AlertsService } from './alerts/alerts.service' import { AlertsService } from './alerts/alerts.service'
import { HeaderActions } from './user-nav-dropdown/header-actions.component' import { HeaderActions } from './user-nav-dropdown/header-actions.component'
import { AlertsComponent } from './alerts/alerts.component' import { AlertsComponent } from './alerts/alerts.component'
@@ -50,7 +49,7 @@ import { BulkValidationModalComponent } from './bulk-validation-modal/bulk-valid
ConfirmModalComponent, ConfirmModalComponent,
BulkValidationModalComponent BulkValidationModalComponent
], ],
providers: [UserService, AlertsService] providers: [AlertsService]
}) })
export class SharedModule implements OnInit { export class SharedModule implements OnInit {
ngOnInit(): void {} ngOnInit(): void {}
@@ -0,0 +1,43 @@
import { Injector } from '@angular/core'
import { TestBed } from '@angular/core/testing'
import { UserService } from './user.service'
describe('UserService', () => {
// UserService must be reachable without any consuming module declaring it
// as a local provider - that's what `providedIn: 'root'` gives you.
it('resolves to the exact same instance from an injector that never declares it as a provider', () => {
const rootInstance = TestBed.inject(UserService)
// Simulates a lazy-loaded feature module's own child injector (e.g.
// EditorModule) - it provides nothing of its own, so it can only
// resolve UserService by walking up to the root tree-shakable provider.
const lazyModuleInjector = Injector.create({
providers: [],
parent: TestBed.inject(Injector)
})
expect(lazyModuleInjector.get(UserService)).toBe(rootInstance)
})
it('shares live state (e.g. the logged-in user) across every injected reference', () => {
const fromRootModule = TestBed.inject(UserService)
const lazyModuleAInjector = Injector.create({
providers: [],
parent: TestBed.inject(Injector)
})
const lazyModuleBInjector = Injector.create({
providers: [],
parent: TestBed.inject(Injector)
})
const fromModuleA = lazyModuleAInjector.get(UserService)
const fromModuleB = lazyModuleBInjector.get(UserService)
// sas.service.ts sets .user through whichever reference it was injected
// with - here, simulated via the "root module"'s instance.
fromRootModule.user = { username: 'sasdemo' }
expect(fromModuleA.user?.username).toEqual('sasdemo')
expect(fromModuleB.user?.username).toEqual('sasdemo')
})
})
+1 -1
View File
@@ -2,7 +2,7 @@ import { Injectable } from '@angular/core'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { User } from './user.interface' import { User } from './user.interface'
@Injectable() @Injectable({ providedIn: 'root' })
export class UserService { export class UserService {
private _user!: User private _user!: User
public userChange: Subject<User> = new Subject<User>() public userChange: Subject<User> = new Subject<User>()
@@ -18,4 +18,113 @@ describe('buildColInfoHtml', () => {
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.' 'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.'
) )
}) })
it('appends a HARDREGEX 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>HARDREGEX: /^[A-Z]+$/i'
)
})
it('appends a SOFTREGEX 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>SOFTREGEX: /^[a-z]+$/'
)
})
it('appends only the HARDREGEX line when a column has both rules, since HARDREGEX is the one applied', () => {
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'
)
})
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:')
})
it('appends a √x=<formula> line when a formula value is provided', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(
buildColInfoHtml(
'SOME_CHAR',
colInfo,
undefined,
undefined,
'SOME_SHORTNUM * SOME_BESTNUM'
)
).toBe(
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.<br>√x=SOME_SHORTNUM * SOME_BESTNUM'
)
})
it('omits the formula line when no formula value is provided', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(buildColInfoHtml('SOME_CHAR', colInfo)).not.toContain('√x=')
})
it('appends both the HARDREGEX line and the formula line when both are present', () => {
const colInfo: DataFormat = {
label: 'Some Character Column',
type: 'char',
length: '1024',
format: '$1024.'
}
expect(
buildColInfoHtml(
'SOME_CHAR',
colInfo,
'/^[A-Z]+$/i',
undefined,
'A_COL * B_COL'
)
).toBe(
'NAME: SOME_CHAR<br>LABEL: Some Character Column<br>TYPE: char<br>LENGTH: 1024<br>FORMAT: $1024.<br>HARDREGEX: /^[A-Z]+$/i<br>√x=A_COL * B_COL'
)
})
}) })
+24 -2
View File
@@ -7,9 +7,31 @@ import { DataFormat } from '../../models/sas/common/DateFormat'
*/ */
export function buildColInfoHtml( export function buildColInfoHtml(
colName: string, colName: string,
colInfo?: DataFormat colInfo?: DataFormat,
hardRegexValue?: string,
softRegexValue?: string,
formulaValue?: string
): string { ): string {
if (!colInfo) return 'No info found' 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}`
// Only ever one REGEX rule is applied per column: when both HARDREGEX
// and SOFTREGEX exist, SOFTREGEX is ignored entirely (same precedence as
// makeRegexWarningRenderer / DcValidator.failsSoftRegex). Show only the
// rule that is applied.
if (hardRegexValue) {
html += `<br>HARDREGEX: ${hardRegexValue}`
} else if (softRegexValue) {
html += `<br>SOFTREGEX: ${softRegexValue}`
}
// '√x=' stands in for a text label here - HARDFORMULA vs SOFTFORMULA is
// already conveyed by the column's readOnly state, so there's no need to
// spell out which one this is.
if (formulaValue) {
html += `<br>√x=${formulaValue}`
}
return html
} }
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "dcfrontend", "name": "dcfrontend",
"version": "7.11.0", "version": "7.12.0",
"description": "Data Controller", "description": "Data Controller",
"devDependencies": { "devDependencies": {
"@saithodev/semantic-release-gitea": "^2.1.0", "@saithodev/semantic-release-gitea": "^2.1.0",
@@ -44,10 +44,9 @@ const data = {
cols: [ cols: [
{ {
NAME: "PRIMARY_KEY_FIELD", NAME: "PRIMARY_KEY_FIELD",
VARNUM: 1,
LABEL: "PRIMARY_KEY_FIELD", LABEL: "PRIMARY_KEY_FIELD",
FMTNAME: "", FMTNAME: "",
DDTYPE: "NUMERIC", DDTYPE: "N",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -55,10 +54,9 @@ const data = {
}, },
{ {
NAME: "SOME_BESTNUM", NAME: "SOME_BESTNUM",
VARNUM: 9,
LABEL: "SOME_BESTNUM", LABEL: "SOME_BESTNUM",
FMTNAME: "BEST", FMTNAME: "BEST",
DDTYPE: "NUMERIC", DDTYPE: "N",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -66,10 +64,9 @@ const data = {
}, },
{ {
NAME: "SOME_CHAR", NAME: "SOME_CHAR",
VARNUM: 2,
LABEL: "SOME_CHAR", LABEL: "SOME_CHAR",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -77,7 +74,6 @@ const data = {
}, },
{ {
NAME: "SOME_DATE", NAME: "SOME_DATE",
VARNUM: 5,
LABEL: "SOME_DATE", LABEL: "SOME_DATE",
FMTNAME: "DATE", FMTNAME: "DATE",
DDTYPE: "DATE", DDTYPE: "DATE",
@@ -88,7 +84,6 @@ const data = {
}, },
{ {
NAME: "SOME_DATETIME", NAME: "SOME_DATETIME",
VARNUM: 6,
LABEL: "SOME_DATETIME", LABEL: "SOME_DATETIME",
FMTNAME: "DATETIME", FMTNAME: "DATETIME",
DDTYPE: "DATETIME", DDTYPE: "DATETIME",
@@ -99,10 +94,9 @@ const data = {
}, },
{ {
NAME: "SOME_DROPDOWN", NAME: "SOME_DROPDOWN",
VARNUM: 3,
LABEL: "SOME_DROPDOWN", LABEL: "SOME_DROPDOWN",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -110,10 +104,9 @@ const data = {
}, },
{ {
NAME: "SOME_HARDSELECT", NAME: "SOME_HARDSELECT",
VARNUM: 10,
LABEL: "SOME_HARDSELECT", LABEL: "SOME_HARDSELECT",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -121,10 +114,9 @@ const data = {
}, },
{ {
NAME: "SOME_NUM", NAME: "SOME_NUM",
VARNUM: 4,
LABEL: "SOME_NUM", LABEL: "SOME_NUM",
FMTNAME: "", FMTNAME: "",
DDTYPE: "NUMERIC", DDTYPE: "N",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -132,10 +124,9 @@ const data = {
}, },
{ {
NAME: "SOME_SHORTNUM", NAME: "SOME_SHORTNUM",
VARNUM: 8,
LABEL: "SOME_SHORTNUM", LABEL: "SOME_SHORTNUM",
FMTNAME: "", FMTNAME: "",
DDTYPE: "NUMERIC", DDTYPE: "N",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -143,7 +134,6 @@ const data = {
}, },
{ {
NAME: "SOME_TIME", NAME: "SOME_TIME",
VARNUM: 7,
LABEL: "SOME_TIME", LABEL: "SOME_TIME",
FMTNAME: "TIME", FMTNAME: "TIME",
DDTYPE: "TIME", DDTYPE: "TIME",
@@ -154,10 +144,9 @@ const data = {
}, },
{ {
NAME: "READONLY_COL", NAME: "READONLY_COL",
VARNUM: 11,
LABEL: "READONLY_COL", LABEL: "READONLY_COL",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "Read-only: default value inserted on add-row, not editable", DESC: "Read-only: default value inserted on add-row, not editable",
@@ -165,10 +154,9 @@ const data = {
}, },
{ {
NAME: "HIDDEN_COL", NAME: "HIDDEN_COL",
VARNUM: 12,
LABEL: "HIDDEN_COL", LABEL: "HIDDEN_COL",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "Hidden: invisible in grid but submitted; default on add-row", DESC: "Hidden: invisible in grid but submitted; default on add-row",
@@ -176,10 +164,9 @@ const data = {
}, },
{ {
NAME: "ROUND_COL", NAME: "ROUND_COL",
VARNUM: 13,
LABEL: "ROUND_COL", LABEL: "ROUND_COL",
FMTNAME: "", FMTNAME: "",
DDTYPE: "NUMERIC", DDTYPE: "N",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "Round: edited values rounded Excel-style to 2 decimals", DESC: "Round: edited values rounded Excel-style to 2 decimals",
@@ -187,10 +174,9 @@ const data = {
}, },
{ {
NAME: "NUMFMT_COL", NAME: "NUMFMT_COL",
VARNUM: 14,
LABEL: "NUMFMT_COL", LABEL: "NUMFMT_COL",
FMTNAME: "", FMTNAME: "",
DDTYPE: "NUMERIC", DDTYPE: "N",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "Number format: displayed as EUR currency (value unchanged)", DESC: "Number format: displayed as EUR currency (value unchanged)",
@@ -9,37 +9,33 @@ _webout=`{"SYSDATE" : "26SEP22"
{ {
"NAME": "PRIMARY_KEY_FIELD", "NAME": "PRIMARY_KEY_FIELD",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 1,
"LABEL": "PRIMARY_KEY_FIELD", "LABEL": "PRIMARY_KEY_FIELD",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "8.", "FORMAT": "8.",
"TYPE": "N", "TYPE": "N",
"DDTYPE": "NUMERIC" "DDTYPE": "N"
}, },
{ {
"NAME": "SOME_BESTNUM", "NAME": "SOME_BESTNUM",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 9,
"LABEL": "SOME_BESTNUM", "LABEL": "SOME_BESTNUM",
"FMTNAME": "BEST", "FMTNAME": "BEST",
"FORMAT": "BEST.", "FORMAT": "BEST.",
"TYPE": "N", "TYPE": "N",
"DDTYPE": "NUMERIC" "DDTYPE": "N"
}, },
{ {
"NAME": "SOME_CHAR", "NAME": "SOME_CHAR",
"LENGTH": 32767, "LENGTH": 32767,
"VARNUM": 2,
"LABEL": "SOME_CHAR", "LABEL": "SOME_CHAR",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$32767.", "FORMAT": "$32767.",
"TYPE": "C", "TYPE": "C",
"DDTYPE": "CHARACTER" "DDTYPE": "C"
}, },
{ {
"NAME": "SOME_DATE", "NAME": "SOME_DATE",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 5,
"LABEL": "SOME_DATE", "LABEL": "SOME_DATE",
"FMTNAME": "DATE", "FMTNAME": "DATE",
"FORMAT": "DATE9.", "FORMAT": "DATE9.",
@@ -49,7 +45,6 @@ _webout=`{"SYSDATE" : "26SEP22"
{ {
"NAME": "SOME_DATETIME", "NAME": "SOME_DATETIME",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 6,
"LABEL": "SOME_DATETIME", "LABEL": "SOME_DATETIME",
"FMTNAME": "DATETIME", "FMTNAME": "DATETIME",
"FORMAT": "DATETIME19.", "FORMAT": "DATETIME19.",
@@ -59,37 +54,33 @@ _webout=`{"SYSDATE" : "26SEP22"
{ {
"NAME": "SOME_DROPDOWN", "NAME": "SOME_DROPDOWN",
"LENGTH": 128, "LENGTH": 128,
"VARNUM": 3,
"LABEL": "SOME_DROPDOWN", "LABEL": "SOME_DROPDOWN",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$128.", "FORMAT": "$128.",
"TYPE": "C", "TYPE": "C",
"DDTYPE": "CHARACTER" "DDTYPE": "C"
}, },
{ {
"NAME": "SOME_NUM", "NAME": "SOME_NUM",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 4,
"LABEL": "SOME_NUM", "LABEL": "SOME_NUM",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "8.", "FORMAT": "8.",
"TYPE": "N", "TYPE": "N",
"DDTYPE": "NUMERIC" "DDTYPE": "N"
}, },
{ {
"NAME": "SOME_SHORTNUM", "NAME": "SOME_SHORTNUM",
"LENGTH": 4, "LENGTH": 4,
"VARNUM": 8,
"LABEL": "SOME_SHORTNUM", "LABEL": "SOME_SHORTNUM",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "4.", "FORMAT": "4.",
"TYPE": "N", "TYPE": "N",
"DDTYPE": "NUMERIC" "DDTYPE": "N"
}, },
{ {
"NAME": "SOME_TIME", "NAME": "SOME_TIME",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 7,
"LABEL": "SOME_TIME", "LABEL": "SOME_TIME",
"FMTNAME": "TIME", "FMTNAME": "TIME",
"FORMAT": "TIME8.", "FORMAT": "TIME8.",
+280 -101
View File
@@ -52,7 +52,8 @@ function makeRows(n) {
ROUND_COL: Number((i + 1 + i / 7).toFixed(5)), // rounds on edit 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_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 return rows
@@ -68,10 +69,9 @@ let webouts = {
cols: [ cols: [
{ {
NAME: "PRIMARY_KEY_FIELD", NAME: "PRIMARY_KEY_FIELD",
VARNUM: 1,
LABEL: "PRIMARY_KEY_FIELD", LABEL: "PRIMARY_KEY_FIELD",
FMTNAME: "", FMTNAME: "",
DDTYPE: "NUMERIC", DDTYPE: "N",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -80,10 +80,9 @@ let webouts = {
}, },
{ {
NAME: "SOME_BESTNUM", NAME: "SOME_BESTNUM",
VARNUM: 9,
LABEL: "SOME_BESTNUM", LABEL: "SOME_BESTNUM",
FMTNAME: "BEST", FMTNAME: "BEST",
DDTYPE: "NUMERIC", DDTYPE: "N",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -92,10 +91,9 @@ let webouts = {
}, },
{ {
NAME: "SOME_CHAR", NAME: "SOME_CHAR",
VARNUM: 2,
LABEL: "Some Character Column", LABEL: "Some Character Column",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -104,7 +102,6 @@ let webouts = {
}, },
{ {
NAME: "SOME_DATE", NAME: "SOME_DATE",
VARNUM: 5,
LABEL: "Some Date", LABEL: "Some Date",
FMTNAME: "DATE", FMTNAME: "DATE",
DDTYPE: "DATE", DDTYPE: "DATE",
@@ -116,7 +113,6 @@ let webouts = {
}, },
{ {
NAME: "SOME_DATETIME", NAME: "SOME_DATETIME",
VARNUM: 6,
LABEL: "SOME_DATETIME", LABEL: "SOME_DATETIME",
FMTNAME: "DATETIME", FMTNAME: "DATETIME",
DDTYPE: "DATETIME", DDTYPE: "DATETIME",
@@ -128,10 +124,9 @@ let webouts = {
}, },
{ {
NAME: "SOME_DROPDOWN", NAME: "SOME_DROPDOWN",
VARNUM: 3,
LABEL: "SOME_DROPDOWN", LABEL: "SOME_DROPDOWN",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -140,10 +135,9 @@ let webouts = {
}, },
{ {
NAME: "SOME_HARDSELECT", NAME: "SOME_HARDSELECT",
VARNUM: 10,
LABEL: "SOME_HARDSELECT", LABEL: "SOME_HARDSELECT",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -152,10 +146,9 @@ let webouts = {
}, },
{ {
NAME: "SOME_NUM", NAME: "SOME_NUM",
VARNUM: 4,
LABEL: "", LABEL: "",
FMTNAME: "", FMTNAME: "",
DDTYPE: "NUMERIC", DDTYPE: "N",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -164,10 +157,9 @@ let webouts = {
}, },
{ {
NAME: "SOME_SHORTNUM", NAME: "SOME_SHORTNUM",
VARNUM: 8,
LABEL: "SOME_SHORTNUM", LABEL: "SOME_SHORTNUM",
FMTNAME: "", FMTNAME: "",
DDTYPE: "NUMERIC", DDTYPE: "N",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -176,7 +168,6 @@ let webouts = {
}, },
{ {
NAME: "SOME_TIME", NAME: "SOME_TIME",
VARNUM: 7,
LABEL: "SOME_TIME", LABEL: "SOME_TIME",
FMTNAME: "TIME", FMTNAME: "TIME",
DDTYPE: "TIME", DDTYPE: "TIME",
@@ -188,10 +179,9 @@ let webouts = {
}, },
{ {
NAME: "READONLY_COL", NAME: "READONLY_COL",
VARNUM: 11,
LABEL: "READONLY_COL", LABEL: "READONLY_COL",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "Read-only: default value inserted on add-row, not editable", DESC: "Read-only: default value inserted on add-row, not editable",
@@ -200,10 +190,9 @@ let webouts = {
}, },
{ {
NAME: "HIDDEN_COL", NAME: "HIDDEN_COL",
VARNUM: 12,
LABEL: "HIDDEN_COL", LABEL: "HIDDEN_COL",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "Hidden: invisible in grid but submitted; default on add-row", DESC: "Hidden: invisible in grid but submitted; default on add-row",
@@ -212,10 +201,9 @@ let webouts = {
}, },
{ {
NAME: "ROUND_COL", NAME: "ROUND_COL",
VARNUM: 13,
LABEL: "ROUND_COL", LABEL: "ROUND_COL",
FMTNAME: "", FMTNAME: "",
DDTYPE: "NUMERIC", DDTYPE: "N",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "Round: edited values rounded Excel-style to 2 decimals", DESC: "Round: edited values rounded Excel-style to 2 decimals",
@@ -224,10 +212,9 @@ let webouts = {
}, },
{ {
NAME: "NUMFMT_COL", NAME: "NUMFMT_COL",
VARNUM: 14,
LABEL: "NUMFMT_COL", LABEL: "NUMFMT_COL",
FMTNAME: "", FMTNAME: "",
DDTYPE: "NUMERIC", DDTYPE: "N",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "Number format: displayed as EUR currency (value unchanged)", DESC: "Number format: displayed as EUR currency (value unchanged)",
@@ -236,10 +223,9 @@ let webouts = {
}, },
{ {
NAME: "REGEX_HARD_COL", NAME: "REGEX_HARD_COL",
VARNUM: 15,
LABEL: "REGEX_HARD_COL", LABEL: "REGEX_HARD_COL",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "HARDREGEX: must be a valid email address or submission is blocked", DESC: "HARDREGEX: must be a valid email address or submission is blocked",
@@ -248,15 +234,27 @@ let webouts = {
}, },
{ {
NAME: "REGEX_SOFT_COL", NAME: "REGEX_SOFT_COL",
VARNUM: 16,
LABEL: "REGEX_SOFT_COL", LABEL: "REGEX_SOFT_COL",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "SOFTREGEX: should be a valid UK postcode, shown as a yellow warning if not, but can still submit", DESC: "SOFTREGEX: should be a valid UK postcode, shown as a yellow warning if not, but can still submit",
LONGDESC: "", LONGDESC: "",
COLTYPE: "{\"data\":\"REGEX_SOFT_COL\"}" 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: [ dqdata: [
@@ -282,7 +280,9 @@ let webouts = {
{ BASE_COL: "ROUND_COL", RULE_TYPE: "ROUND", RULE_VALUE: "2" }, { 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_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: [ dsmeta: [
{ ODS_TABLE: "ATTRIBUTES", NAME: "Data Set Name", VALUE: "DC996664.MPE_X_TEST" }, { ODS_TABLE: "ATTRIBUTES", NAME: "Data Set Name", VALUE: "DC996664.MPE_X_TEST" },
@@ -335,7 +335,10 @@ let webouts = {
{ NAME: "round_col", MAXLEN: 8 }, { NAME: "round_col", MAXLEN: 8 },
{ NAME: "numfmt_col", MAXLEN: 8 }, { NAME: "numfmt_col", MAXLEN: 8 },
{ NAME: "regex_hard_col", MAXLEN: 128 }, { NAME: "regex_hard_col", MAXLEN: 128 },
{ NAME: "regex_soft_col", MAXLEN: 128 } { NAME: "regex_soft_col", MAXLEN: 128 },
{ NAME: "regex_both_col", MAXLEN: 128 },
{ NAME: "formula_hard_col", MAXLEN: 128 },
{ NAME: "formula_soft_col", MAXLEN: 128 }
], ],
query: [], query: [],
sasdata: makeRows(100), sasdata: makeRows(100),
@@ -357,12 +360,13 @@ let webouts = {
ROUND_COL: { format: "best.", label: "ROUND_COL", length: "8", type: "num" }, 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_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: [ 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, FILTER_TEXT: FILTER_TEXT,
PKCNT: 1, PKCNT: 1,
PK: "PRIMARY_KEY_FIELD", PK: "PRIMARY_KEY_FIELD",
@@ -404,10 +408,9 @@ let webouts = {
cols: [ cols: [
{ {
NAME: "DD_LONGDESC", NAME: "DD_LONGDESC",
VARNUM: 6,
LABEL: "DD_LONGDESC", LABEL: "DD_LONGDESC",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -416,10 +419,9 @@ let webouts = {
}, },
{ {
NAME: "DD_OWNER", NAME: "DD_OWNER",
VARNUM: 7,
LABEL: "DD_OWNER", LABEL: "DD_OWNER",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -428,10 +430,9 @@ let webouts = {
}, },
{ {
NAME: "DD_RESPONSIBLE", NAME: "DD_RESPONSIBLE",
VARNUM: 8,
LABEL: "DD_RESPONSIBLE", LABEL: "DD_RESPONSIBLE",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -440,10 +441,9 @@ let webouts = {
}, },
{ {
NAME: "DD_SENSITIVITY", NAME: "DD_SENSITIVITY",
VARNUM: 9,
LABEL: "DD_SENSITIVITY", LABEL: "DD_SENSITIVITY",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -452,10 +452,9 @@ let webouts = {
}, },
{ {
NAME: "DD_SHORTDESC", NAME: "DD_SHORTDESC",
VARNUM: 5,
LABEL: "DD_SHORTDESC", LABEL: "DD_SHORTDESC",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -464,10 +463,9 @@ let webouts = {
}, },
{ {
NAME: "DD_SOURCE", NAME: "DD_SOURCE",
VARNUM: 4,
LABEL: "DD_SOURCE", LABEL: "DD_SOURCE",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -476,10 +474,9 @@ let webouts = {
}, },
{ {
NAME: "DD_TYPE", NAME: "DD_TYPE",
VARNUM: 3,
LABEL: "DD_TYPE", LABEL: "DD_TYPE",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -488,10 +485,9 @@ let webouts = {
}, },
{ {
NAME: "TX_FROM", NAME: "TX_FROM",
VARNUM: 1,
LABEL: "TX_FROM", LABEL: "TX_FROM",
FMTNAME: "datetime", FMTNAME: "datetime",
DDTYPE: "NUMERIC", DDTYPE: "N",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -499,10 +495,9 @@ let webouts = {
}, },
{ {
NAME: "TX_TO", NAME: "TX_TO",
VARNUM: 2,
LABEL: "TX_TO", LABEL: "TX_TO",
FMTNAME: "datetime", FMTNAME: "datetime",
DDTYPE: "NUMERIC", DDTYPE: "N",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -682,10 +677,9 @@ let webouts = {
cols: [ cols: [
{ {
NAME: "LAST_SEEN_DT", NAME: "LAST_SEEN_DT",
VARNUM: 2,
LABEL: "LAST_SEEN_DT", LABEL: "LAST_SEEN_DT",
FMTNAME: "date", FMTNAME: "date",
DDTYPE: "NUMERIC", DDTYPE: "N",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -693,10 +687,9 @@ let webouts = {
}, },
{ {
NAME: "REGISTERED_DT", NAME: "REGISTERED_DT",
VARNUM: 3,
LABEL: "REGISTERED_DT", LABEL: "REGISTERED_DT",
FMTNAME: "date", FMTNAME: "date",
DDTYPE: "NUMERIC", DDTYPE: "N",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -704,10 +697,9 @@ let webouts = {
}, },
{ {
NAME: "USER_ID", NAME: "USER_ID",
VARNUM: 1,
LABEL: "USER_ID", LABEL: "USER_ID",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -809,10 +801,9 @@ let webouts = {
cols: [ cols: [
{ {
NAME: "AUDIT_LIBDS", NAME: "AUDIT_LIBDS",
VARNUM: 22,
LABEL: "AUDIT_LIBDS", LABEL: "AUDIT_LIBDS",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -821,10 +812,9 @@ let webouts = {
}, },
{ {
NAME: "BUSKEY", NAME: "BUSKEY",
VARNUM: 7,
LABEL: "BUSKEY", LABEL: "BUSKEY",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -833,10 +823,9 @@ let webouts = {
}, },
{ {
NAME: "CLOSE_VARS", NAME: "CLOSE_VARS",
VARNUM: 13,
LABEL: "CLOSE_VARS", LABEL: "CLOSE_VARS",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -845,10 +834,9 @@ let webouts = {
}, },
{ {
NAME: "DSN", NAME: "DSN",
VARNUM: 4,
LABEL: "DSN", LABEL: "DSN",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -857,10 +845,9 @@ let webouts = {
}, },
{ {
NAME: "LIBREF", NAME: "LIBREF",
VARNUM: 3,
LABEL: "LIBREF", LABEL: "LIBREF",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -869,10 +856,9 @@ let webouts = {
}, },
{ {
NAME: "LOADTYPE", NAME: "LOADTYPE",
VARNUM: 6,
LABEL: "LOADTYPE", LABEL: "LOADTYPE",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -881,10 +867,9 @@ let webouts = {
}, },
{ {
NAME: "NOTES", NAME: "NOTES",
VARNUM: 20,
LABEL: "NOTES", LABEL: "NOTES",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -893,10 +878,9 @@ let webouts = {
}, },
{ {
NAME: "NUM_OF_APPROVALS_REQUIRED", NAME: "NUM_OF_APPROVALS_REQUIRED",
VARNUM: 5,
LABEL: "NUM_OF_APPROVALS_REQUIRED", LABEL: "NUM_OF_APPROVALS_REQUIRED",
FMTNAME: "", FMTNAME: "",
DDTYPE: "NUMERIC", DDTYPE: "N",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -905,10 +889,9 @@ let webouts = {
}, },
{ {
NAME: "POST_APPROVE_HOOK", NAME: "POST_APPROVE_HOOK",
VARNUM: 17,
LABEL: "POST_APPROVE_HOOK", LABEL: "POST_APPROVE_HOOK",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -917,10 +900,9 @@ let webouts = {
}, },
{ {
NAME: "POST_EDIT_HOOK", NAME: "POST_EDIT_HOOK",
VARNUM: 15,
LABEL: "POST_EDIT_HOOK", LABEL: "POST_EDIT_HOOK",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -929,10 +911,9 @@ let webouts = {
}, },
{ {
NAME: "PRE_APPROVE_HOOK", NAME: "PRE_APPROVE_HOOK",
VARNUM: 16,
LABEL: "PRE_APPROVE_HOOK", LABEL: "PRE_APPROVE_HOOK",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -941,10 +922,9 @@ let webouts = {
}, },
{ {
NAME: "PRE_EDIT_HOOK", NAME: "PRE_EDIT_HOOK",
VARNUM: 14,
LABEL: "PRE_EDIT_HOOK", LABEL: "PRE_EDIT_HOOK",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -953,10 +933,9 @@ let webouts = {
}, },
{ {
NAME: "RK_UNDERLYING", NAME: "RK_UNDERLYING",
VARNUM: 21,
LABEL: "RK_UNDERLYING", LABEL: "RK_UNDERLYING",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -965,10 +944,9 @@ let webouts = {
}, },
{ {
NAME: "SIGNOFF_COLS", NAME: "SIGNOFF_COLS",
VARNUM: 18,
LABEL: "SIGNOFF_COLS", LABEL: "SIGNOFF_COLS",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -977,10 +955,9 @@ let webouts = {
}, },
{ {
NAME: "SIGNOFF_HOOK", NAME: "SIGNOFF_HOOK",
VARNUM: 19,
LABEL: "SIGNOFF_HOOK", LABEL: "SIGNOFF_HOOK",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -989,7 +966,6 @@ let webouts = {
}, },
{ {
NAME: "TX_FROM", NAME: "TX_FROM",
VARNUM: 1,
LABEL: "TX_FROM", LABEL: "TX_FROM",
FMTNAME: "DATETIME", FMTNAME: "DATETIME",
DDTYPE: "DATETIME", DDTYPE: "DATETIME",
@@ -1000,7 +976,6 @@ let webouts = {
}, },
{ {
NAME: "TX_TO", NAME: "TX_TO",
VARNUM: 2,
LABEL: "TX_TO", LABEL: "TX_TO",
FMTNAME: "DATETIME", FMTNAME: "DATETIME",
DDTYPE: "DATETIME", DDTYPE: "DATETIME",
@@ -1011,10 +986,9 @@ let webouts = {
}, },
{ {
NAME: "VAR_BUSFROM", NAME: "VAR_BUSFROM",
VARNUM: 10,
LABEL: "VAR_BUSFROM", LABEL: "VAR_BUSFROM",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -1023,10 +997,9 @@ let webouts = {
}, },
{ {
NAME: "VAR_BUSTO", NAME: "VAR_BUSTO",
VARNUM: 11,
LABEL: "VAR_BUSTO", LABEL: "VAR_BUSTO",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -1035,10 +1008,9 @@ let webouts = {
}, },
{ {
NAME: "VAR_PROCESSED", NAME: "VAR_PROCESSED",
VARNUM: 12,
LABEL: "VAR_PROCESSED", LABEL: "VAR_PROCESSED",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -1047,10 +1019,9 @@ let webouts = {
}, },
{ {
NAME: "VAR_TXFROM", NAME: "VAR_TXFROM",
VARNUM: 8,
LABEL: "VAR_TXFROM", LABEL: "VAR_TXFROM",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -1059,10 +1030,9 @@ let webouts = {
}, },
{ {
NAME: "VAR_TXTO", NAME: "VAR_TXTO",
VARNUM: 9,
LABEL: "VAR_TXTO", LABEL: "VAR_TXTO",
FMTNAME: "", FMTNAME: "",
DDTYPE: "CHARACTER", DDTYPE: "C",
CLS_RULE: "READ", CLS_RULE: "READ",
MEMLABEL: "", MEMLABEL: "",
DESC: "", DESC: "",
@@ -1543,6 +1513,213 @@ let webouts = {
SYSWARNINGTEXT: "ENCODING option ignored for files opened with RECFM=N.", SYSWARNINGTEXT: "ENCODING option ignored for files opened with RECFM=N.",
END_DTTM: "2023-03-10T12:39:16.070656", END_DTTM: "2023-03-10T12:39:16.070656",
MEMSIZE: "2GB" MEMSIZE: "2GB"
},
// Single-row, HARDFORMULA-only table - isolates whether HyperFormula
// computes at all when the row count is nowhere near the license's
// editor_rows_allowed cap (see maxRows investigation: Handsontable's
// formulas plugin forwards its own maxRows setting straight into the
// HyperFormula engine's sheet-size limit).
MPE_X_FORMULA_TEST: {
SYSDATE: "28JUL26",
SYSTIME: "12:00",
approvers: [],
cols: [
{
NAME: "PRIMARY_KEY_FIELD",
LABEL: "PRIMARY_KEY_FIELD",
FMTNAME: "",
DDTYPE: "N",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: "",
COLTYPE: "{\"data\":\"PRIMARY_KEY_FIELD\",\"type\":\"numeric\",\"format\":\"0\"}"
},
{
NAME: "A_COL",
LABEL: "A_COL",
FMTNAME: "",
DDTYPE: "N",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: "",
COLTYPE: "{\"data\":\"A_COL\",\"type\":\"numeric\",\"format\":\"0\"}"
},
{
NAME: "B_COL",
LABEL: "B_COL",
FMTNAME: "",
DDTYPE: "N",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "",
LONGDESC: "",
COLTYPE: "{\"data\":\"B_COL\",\"type\":\"numeric\",\"format\":\"0\"}"
},
{
NAME: "FORMULA_HARD_COL",
LABEL: "FORMULA_HARD_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "HARDFORMULA: computed A_COL * B_COL, readonly",
LONGDESC: "",
COLTYPE: "{\"data\":\"FORMULA_HARD_COL\"}"
},
{
NAME: "FORMULA_SOFT_COL",
LABEL: "FORMULA_SOFT_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "SOFTFORMULA: computed A_COL + B_COL as a default, user can overwrite",
LONGDESC: "",
COLTYPE: "{\"data\":\"FORMULA_SOFT_COL\"}"
},
{
NAME: "ROW_STATUS_COL",
LABEL: "ROW_STATUS_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "SOFTFORMULA: DC.ROW_STATUS demo, reacts live to this row's edit status",
LONGDESC: "",
COLTYPE: "{\"data\":\"ROW_STATUS_COL\"}"
},
{
NAME: "USER_NAME_COL",
LABEL: "USER_NAME_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "SOFTFORMULA: DC.USER_NAME demo, shows the current logged-in user",
LONGDESC: "",
COLTYPE: "{\"data\":\"USER_NAME_COL\"}"
},
{
NAME: "ORIG_VALUE_COL",
LABEL: "ORIG_VALUE_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "SOFTFORMULA: DC.ORIG_VALUE demo, echoes this row's pre-edit value (blank for a newly-inserted row)",
LONGDESC: "",
COLTYPE: "{\"data\":\"ORIG_VALUE_COL\"}"
},
{
NAME: "CHANGE_SUMMARY_COL",
LABEL: "CHANGE_SUMMARY_COL",
FMTNAME: "",
DDTYPE: "C",
CLS_RULE: "READ",
MEMLABEL: "",
DESC: "SOFTFORMULA: combines DC.ROW_STATUS/DC.USER_NAME/DC.ORIG_VALUE - 'unedited' while unchanged, else '<user> changed from <original value>'",
LONGDESC: "",
COLTYPE: "{\"data\":\"CHANGE_SUMMARY_COL\"}"
}
],
dqdata: [],
dqrules: [
{ BASE_COL: "PRIMARY_KEY_FIELD", RULE_TYPE: "NOTNULL", RULE_VALUE: "" },
{ BASE_COL: "FORMULA_HARD_COL", RULE_TYPE: "HARDFORMULA", RULE_VALUE: "A_COL * B_COL" },
{ BASE_COL: "FORMULA_SOFT_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "A_COL + B_COL" },
{ BASE_COL: "ROW_STATUS_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "DC.ROW_STATUS" },
{ BASE_COL: "USER_NAME_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "DC.USER_NAME" },
{ BASE_COL: "ORIG_VALUE_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "DC.ORIG_VALUE" },
{ BASE_COL: "CHANGE_SUMMARY_COL", RULE_TYPE: "SOFTFORMULA", RULE_VALUE: "IF( DC.ROW_STATUS =\"U\",\"unedited\", DC.USER_NAME &\" changed from \"& DC.ORIG_VALUE )" }
],
dsmeta: [
{ ODS_TABLE: "ATTRIBUTES", NAME: "Data Set Name", VALUE: "MPE_X_FORMULA_TEST" },
{ ODS_TABLE: "ATTRIBUTES", NAME: "Member Type", VALUE: "DATA" },
{ ODS_TABLE: "ATTRIBUTES", NAME: "Engine", VALUE: "V9" },
{ ODS_TABLE: "ATTRIBUTES", NAME: "Observations", VALUE: "10" },
{ ODS_TABLE: "ATTRIBUTES", NAME: "Variables", VALUE: "9" }
],
maxvarlengths: [
{ NAME: "_____DELETE__THIS__RECORD_____", MAXLEN: 3 },
{ NAME: "primary_key_field", MAXLEN: 8 },
{ NAME: "a_col", MAXLEN: 8 },
{ NAME: "b_col", MAXLEN: 8 },
{ NAME: "formula_hard_col", MAXLEN: 128 },
{ NAME: "formula_soft_col", MAXLEN: 128 },
{ NAME: "row_status_col", MAXLEN: 128 },
{ NAME: "user_name_col", MAXLEN: 128 },
{ NAME: "orig_value_col", MAXLEN: 128 },
{ NAME: "change_summary_col", MAXLEN: 128 }
],
query: [],
// 10 rows, well under the editor_rows_allowed=15 cap, with both
// HARDFORMULA and SOFTFORMULA rules present together. ORIG_VALUE_COL
// and CHANGE_SUMMARY_COL are seeded with a distinctive raw value
// (not blank, unlike the other formula columns) so DC.ORIG_VALUE -
// which always echoes THIS SAME column's own pre-edit value, never
// another column's - has something meaningful to echo back.
sasdata: Array.from({ length: 10 }, (_, i) => ({
_____DELETE__THIS__RECORD_____: "No",
PRIMARY_KEY_FIELD: i + 1,
A_COL: i + 1,
B_COL: 10,
FORMULA_HARD_COL: "",
FORMULA_SOFT_COL: "",
ROW_STATUS_COL: "",
USER_NAME_COL: "",
ORIG_VALUE_COL: `orig-${i + 1}`,
CHANGE_SUMMARY_COL: `orig-${i + 1}`
})),
$sasdata: {
vars: {
_____DELETE__THIS__RECORD_____: { format: "$3.", label: "_____DELETE__THIS__RECORD_____", length: "3", type: "char" },
PRIMARY_KEY_FIELD: { format: "best.", label: "PRIMARY_KEY_FIELD", length: "8", type: "num" },
A_COL: { format: "best.", label: "A_COL", length: "8", type: "num" },
B_COL: { format: "best.", label: "B_COL", length: "8", type: "num" },
FORMULA_HARD_COL: { format: "$128.", label: "FORMULA_HARD_COL", length: "128", type: "char" },
FORMULA_SOFT_COL: { format: "$128.", label: "FORMULA_SOFT_COL", length: "128", type: "char" },
ROW_STATUS_COL: { format: "$128.", label: "ROW_STATUS_COL", length: "128", type: "char" },
USER_NAME_COL: { format: "$128.", label: "USER_NAME_COL", length: "128", type: "char" },
ORIG_VALUE_COL: { format: "$128.", label: "ORIG_VALUE_COL", length: "128", type: "char" },
CHANGE_SUMMARY_COL: { format: "$128.", label: "CHANGE_SUMMARY_COL", length: "128", type: "char" }
}
},
sasparams: [
{
COLHEADERS: "_____DELETE__THIS__RECORD_____,PRIMARY_KEY_FIELD,A_COL,B_COL,FORMULA_HARD_COL,FORMULA_SOFT_COL,ROW_STATUS_COL,USER_NAME_COL,ORIG_VALUE_COL,CHANGE_SUMMARY_COL",
FILTER_TEXT: "",
PKCNT: 1,
PK: "PRIMARY_KEY_FIELD",
DTVARS: "",
DTTMVARS: "",
TMVARS: "",
LOADTYPE: "UPDATE",
RK_FLAG: 0,
CLS_FLAG: 0
}
],
xl_rules: [],
_DEBUG: "",
_PROGRAM: "/Public/app/dc/services/editors/getdata",
AUTOEXEC: "",
MF_GETUSER: "sasdemo",
SYSCC: "0",
SYSENCODING: "utf-8",
SYSERRORTEXT: "",
SYSHOSTNAME: "SAS",
SYSPROCESSID: "0",
SYSPROCESSMODE: "SAS Batch Mode",
SYSPROCESSNAME: "",
SYSJOBID: "1",
SYSSCPL: "Linux",
SYSSITE: "123",
SYSUSERID: "sasjssrv",
SYSVLONG: "9.04.01M7P080520",
SYSWARNINGTEXT: "",
END_DTTM: "2026-07-28T12:00:00.000000",
MEMSIZE: "1MB"
} }
} }
@@ -1551,7 +1728,7 @@ let webouts = {
// MPE_X_TEST keeps matching the excel upload fixtures (which predate these // 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 // 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. // 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) { function stripRuleCols(t) {
const uc = new Set(RULE_DEMO_COLS) const uc = new Set(RULE_DEMO_COLS)
@@ -1581,6 +1758,8 @@ if (_WEBIN_FILEREF1) {
if (file1.includes('MPE_X_NEW')) { if (file1.includes('MPE_X_NEW')) {
table = 'MPE_X_NEW' table = 'MPE_X_NEW'
} else if (file1.includes('MPE_X_FORMULA_TEST')) {
table = 'MPE_X_FORMULA_TEST'
} else if (file1.includes('MPE_X_TEST')) { } else if (file1.includes('MPE_X_TEST')) {
table = 'MPE_X_TEST' table = 'MPE_X_TEST'
} else if (file1.includes('MPE_DATADICTIONARY')) { } else if (file1.includes('MPE_DATADICTIONARY')) {
@@ -55,6 +55,10 @@ _webout = `{"SYSDATE" : "26SEP22"
"LIBREF": "DC996664", "LIBREF": "DC996664",
"DSN": "MPE_X_NEW" "DSN": "MPE_X_NEW"
}, },
{
"LIBREF": "DC996664",
"DSN": "MPE_X_FORMULA_TEST"
},
{ {
"LIBREF": "DC996664", "LIBREF": "DC996664",
"DSN": "MPE_DATADICTIONARY" "DSN": "MPE_DATADICTIONARY"
+35 -66
View File
@@ -31,37 +31,33 @@ let webouts = {
{ {
"NAME": "PRIMARY_KEY_FIELD", "NAME": "PRIMARY_KEY_FIELD",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 1,
"LABEL": "PRIMARY_KEY_FIELD", "LABEL": "PRIMARY_KEY_FIELD",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "8.", "FORMAT": "8.",
"TYPE": "N", "TYPE": "N",
"DDTYPE": "NUMERIC" "DDTYPE": "N"
}, },
{ {
"NAME": "SOME_BESTNUM", "NAME": "SOME_BESTNUM",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 9,
"LABEL": "SOME_BESTNUM", "LABEL": "SOME_BESTNUM",
"FMTNAME": "BEST", "FMTNAME": "BEST",
"FORMAT": "BEST.", "FORMAT": "BEST.",
"TYPE": "N", "TYPE": "N",
"DDTYPE": "NUMERIC" "DDTYPE": "N"
}, },
{ {
"NAME": "SOME_CHAR", "NAME": "SOME_CHAR",
"LENGTH": 32767, "LENGTH": 32767,
"VARNUM": 2,
"LABEL": "Some Character Column", "LABEL": "Some Character Column",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$32767.", "FORMAT": "$32767.",
"TYPE": "C", "TYPE": "C",
"DDTYPE": "CHARACTER" "DDTYPE": "C"
}, },
{ {
"NAME": "SOME_DATE", "NAME": "SOME_DATE",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 5,
"LABEL": "Some Date", "LABEL": "Some Date",
"FMTNAME": "DATE", "FMTNAME": "DATE",
"FORMAT": "DATE9.", "FORMAT": "DATE9.",
@@ -71,7 +67,6 @@ let webouts = {
{ {
"NAME": "SOME_DATETIME", "NAME": "SOME_DATETIME",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 6,
"LABEL": "SOME_DATETIME", "LABEL": "SOME_DATETIME",
"FMTNAME": "DATETIME", "FMTNAME": "DATETIME",
"FORMAT": "DATETIME19.", "FORMAT": "DATETIME19.",
@@ -81,37 +76,33 @@ let webouts = {
{ {
"NAME": "SOME_DROPDOWN", "NAME": "SOME_DROPDOWN",
"LENGTH": 128, "LENGTH": 128,
"VARNUM": 3,
"LABEL": "SOME_DROPDOWN", "LABEL": "SOME_DROPDOWN",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$128.", "FORMAT": "$128.",
"TYPE": "C", "TYPE": "C",
"DDTYPE": "CHARACTER" "DDTYPE": "C"
}, },
{ {
"NAME": "SOME_NUM", "NAME": "SOME_NUM",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 4,
"LABEL": "", "LABEL": "",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "8.", "FORMAT": "8.",
"TYPE": "N", "TYPE": "N",
"DDTYPE": "NUMERIC" "DDTYPE": "N"
}, },
{ {
"NAME": "SOME_SHORTNUM", "NAME": "SOME_SHORTNUM",
"LENGTH": 4, "LENGTH": 4,
"VARNUM": 8,
"LABEL": "SOME_SHORTNUM", "LABEL": "SOME_SHORTNUM",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "4.", "FORMAT": "4.",
"TYPE": "N", "TYPE": "N",
"DDTYPE": "NUMERIC" "DDTYPE": "N"
}, },
{ {
"NAME": "SOME_TIME", "NAME": "SOME_TIME",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 7,
"LABEL": "SOME_TIME", "LABEL": "SOME_TIME",
"FMTNAME": "TIME", "FMTNAME": "TIME",
"FORMAT": "TIME8.", "FORMAT": "TIME8.",
@@ -3093,117 +3084,105 @@ let webouts = {
{ {
"NAME": "DSN", "NAME": "DSN",
"LENGTH": 32, "LENGTH": 32,
"VARNUM": 3,
"LABEL": "Dataset Name (32 chars)", "LABEL": "Dataset Name (32 chars)",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$32.", "FORMAT": "$32.",
"TYPE": "C", "TYPE": "C",
"DDTYPE": "CHARACTER" "DDTYPE": "C"
}, },
{ {
"NAME": "IS_DIFF", "NAME": "IS_DIFF",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 9,
"LABEL": "Did value change? (1/0/-1). Always -1 for appends and deletes.", "LABEL": "Did value change? (1/0/-1). Always -1 for appends and deletes.",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "8.", "FORMAT": "8.",
"TYPE": "N", "TYPE": "N",
"DDTYPE": "NUMERIC" "DDTYPE": "N"
}, },
{ {
"NAME": "IS_PK", "NAME": "IS_PK",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 8,
"LABEL": "Is Primary Key Field? (1/0)", "LABEL": "Is Primary Key Field? (1/0)",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "8.", "FORMAT": "8.",
"TYPE": "N", "TYPE": "N",
"DDTYPE": "NUMERIC" "DDTYPE": "N"
}, },
{ {
"NAME": "KEY_HASH", "NAME": "KEY_HASH",
"LENGTH": 32, "LENGTH": 32,
"VARNUM": 4,
"LABEL": "MD5 Hash of primary key values (pipe seperated)", "LABEL": "MD5 Hash of primary key values (pipe seperated)",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$32.", "FORMAT": "$32.",
"TYPE": "C", "TYPE": "C",
"DDTYPE": "CHARACTER" "DDTYPE": "C"
}, },
{ {
"NAME": "LIBREF", "NAME": "LIBREF",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 2,
"LABEL": "Library Reference (8 chars)", "LABEL": "Library Reference (8 chars)",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$8.", "FORMAT": "$8.",
"TYPE": "C", "TYPE": "C",
"DDTYPE": "CHARACTER" "DDTYPE": "C"
}, },
{ {
"NAME": "LOAD_REF", "NAME": "LOAD_REF",
"LENGTH": 36, "LENGTH": 36,
"VARNUM": 1,
"LABEL": "unique load reference", "LABEL": "unique load reference",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$36.", "FORMAT": "$36.",
"TYPE": "C", "TYPE": "C",
"DDTYPE": "CHARACTER" "DDTYPE": "C"
}, },
{ {
"NAME": "MOVE_TYPE", "NAME": "MOVE_TYPE",
"LENGTH": 1, "LENGTH": 1,
"VARNUM": 6,
"LABEL": "Either (A)ppended, (D)eleted or (M)odified", "LABEL": "Either (A)ppended, (D)eleted or (M)odified",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$1.", "FORMAT": "$1.",
"TYPE": "C", "TYPE": "C",
"DDTYPE": "CHARACTER" "DDTYPE": "C"
}, },
{ {
"NAME": "NEWVAL_CHAR", "NAME": "NEWVAL_CHAR",
"LENGTH": 32765, "LENGTH": 32765,
"VARNUM": 14,
"LABEL": "New (character) value", "LABEL": "New (character) value",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$32765.", "FORMAT": "$32765.",
"TYPE": "C", "TYPE": "C",
"DDTYPE": "CHARACTER" "DDTYPE": "C"
}, },
{ {
"NAME": "NEWVAL_NUM", "NAME": "NEWVAL_NUM",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 12,
"LABEL": "New (numeric) value", "LABEL": "New (numeric) value",
"FMTNAME": "BEST", "FMTNAME": "BEST",
"FORMAT": "BEST32.", "FORMAT": "BEST32.",
"TYPE": "N", "TYPE": "N",
"DDTYPE": "NUMERIC" "DDTYPE": "N"
}, },
{ {
"NAME": "OLDVAL_CHAR", "NAME": "OLDVAL_CHAR",
"LENGTH": 32765, "LENGTH": 32765,
"VARNUM": 13,
"LABEL": "Old (character) value", "LABEL": "Old (character) value",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$32765.", "FORMAT": "$32765.",
"TYPE": "C", "TYPE": "C",
"DDTYPE": "CHARACTER" "DDTYPE": "C"
}, },
{ {
"NAME": "OLDVAL_NUM", "NAME": "OLDVAL_NUM",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 11,
"LABEL": "Old (numeric) value", "LABEL": "Old (numeric) value",
"FMTNAME": "BEST", "FMTNAME": "BEST",
"FORMAT": "BEST32.", "FORMAT": "BEST32.",
"TYPE": "N", "TYPE": "N",
"DDTYPE": "NUMERIC" "DDTYPE": "N"
}, },
{ {
"NAME": "PROCESSED_DTTM", "NAME": "PROCESSED_DTTM",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 7,
"LABEL": "Processed at timestamp", "LABEL": "Processed at timestamp",
"FMTNAME": "E8601DT", "FMTNAME": "E8601DT",
"FORMAT": "E8601DT26.6", "FORMAT": "E8601DT26.6",
@@ -3213,22 +3192,20 @@ let webouts = {
{ {
"NAME": "TGTVAR_NM", "NAME": "TGTVAR_NM",
"LENGTH": 32, "LENGTH": 32,
"VARNUM": 5,
"LABEL": "Target variable name (32 chars)", "LABEL": "Target variable name (32 chars)",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$32.", "FORMAT": "$32.",
"TYPE": "C", "TYPE": "C",
"DDTYPE": "CHARACTER" "DDTYPE": "C"
}, },
{ {
"NAME": "TGTVAR_TYPE", "NAME": "TGTVAR_TYPE",
"LENGTH": 1, "LENGTH": 1,
"VARNUM": 10,
"LABEL": "Either (C)haracter or (N)umeric", "LABEL": "Either (C)haracter or (N)umeric",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$1.", "FORMAT": "$1.",
"TYPE": "C", "TYPE": "C",
"DDTYPE": "CHARACTER" "DDTYPE": "C"
} }
], ],
"dsmeta": [ "dsmeta": [
@@ -4426,12 +4403,12 @@ let webouts = {
] ]
, "cols": , "cols":
[ [
{"NAME":"ALERT_DS" ,"LENGTH":32 ,"VARNUM":4 ,"LABEL":"ALERT_DS" ,"FMTNAME":"" ,"FORMAT":"$32." ,"TYPE":"C" ,"DDTYPE":"CHARACTER" } {"NAME":"ALERT_DS" ,"LENGTH":32 ,"LABEL":"ALERT_DS" ,"FMTNAME":"" ,"FORMAT":"$32." ,"TYPE":"C" ,"DDTYPE":"C" }
,{"NAME":"ALERT_EVENT" ,"LENGTH":20 ,"VARNUM":2 ,"LABEL":"ALERT_EVENT" ,"FMTNAME":"" ,"FORMAT":"$20." ,"TYPE":"C" ,"DDTYPE":"CHARACTER" } ,{"NAME":"ALERT_EVENT" ,"LENGTH":20 ,"LABEL":"ALERT_EVENT" ,"FMTNAME":"" ,"FORMAT":"$20." ,"TYPE":"C" ,"DDTYPE":"C" }
,{"NAME":"ALERT_LIB" ,"LENGTH":8 ,"VARNUM":3 ,"LABEL":"ALERT_LIB" ,"FMTNAME":"" ,"FORMAT":"$8." ,"TYPE":"C" ,"DDTYPE":"CHARACTER" } ,{"NAME":"ALERT_LIB" ,"LENGTH":8 ,"LABEL":"ALERT_LIB" ,"FMTNAME":"" ,"FORMAT":"$8." ,"TYPE":"C" ,"DDTYPE":"C" }
,{"NAME":"ALERT_USER" ,"LENGTH":100 ,"VARNUM":5 ,"LABEL":"ALERT_USER" ,"FMTNAME":"" ,"FORMAT":"$100." ,"TYPE":"C" ,"DDTYPE":"CHARACTER" } ,{"NAME":"ALERT_USER" ,"LENGTH":100 ,"LABEL":"ALERT_USER" ,"FMTNAME":"" ,"FORMAT":"$100." ,"TYPE":"C" ,"DDTYPE":"C" }
,{"NAME":"TX_FROM" ,"LENGTH":8 ,"VARNUM":1 ,"LABEL":"TX_FROM" ,"FMTNAME":"DATETIME" ,"FORMAT":"DATETIME19.3" ,"TYPE":"N" ,"DDTYPE":"DATETIME" } ,{"NAME":"TX_FROM" ,"LENGTH":8 ,"LABEL":"TX_FROM" ,"FMTNAME":"DATETIME" ,"FORMAT":"DATETIME19.3" ,"TYPE":"N" ,"DDTYPE":"DATETIME" }
,{"NAME":"TX_TO" ,"LENGTH":8 ,"VARNUM":6 ,"LABEL":"TX_TO" ,"FMTNAME":"DATETIME" ,"FORMAT":"DATETIME19.3" ,"TYPE":"N" ,"DDTYPE":"DATETIME" } ,{"NAME":"TX_TO" ,"LENGTH":8 ,"LABEL":"TX_TO" ,"FMTNAME":"DATETIME" ,"FORMAT":"DATETIME19.3" ,"TYPE":"N" ,"DDTYPE":"DATETIME" }
] ]
, "dsmeta": , "dsmeta":
[ [
@@ -4520,67 +4497,60 @@ let webouts = {
{ {
"NAME": "BASE_COL", "NAME": "BASE_COL",
"LENGTH": 32, "LENGTH": 32,
"VARNUM": 4,
"LABEL": "BASE_COL", "LABEL": "BASE_COL",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$32.", "FORMAT": "$32.",
"TYPE": "C", "TYPE": "C",
"DDTYPE": "CHARACTER" "DDTYPE": "C"
}, },
{ {
"NAME": "BASE_DS", "NAME": "BASE_DS",
"LENGTH": 32, "LENGTH": 32,
"VARNUM": 3,
"LABEL": "BASE_DS", "LABEL": "BASE_DS",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$32.", "FORMAT": "$32.",
"TYPE": "C", "TYPE": "C",
"DDTYPE": "CHARACTER" "DDTYPE": "C"
}, },
{ {
"NAME": "BASE_LIB", "NAME": "BASE_LIB",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 2,
"LABEL": "BASE_LIB", "LABEL": "BASE_LIB",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$8.", "FORMAT": "$8.",
"TYPE": "C", "TYPE": "C",
"DDTYPE": "CHARACTER" "DDTYPE": "C"
}, },
{ {
"NAME": "RULE_ACTIVE", "NAME": "RULE_ACTIVE",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 7,
"LABEL": "RULE_ACTIVE", "LABEL": "RULE_ACTIVE",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "8.", "FORMAT": "8.",
"TYPE": "N", "TYPE": "N",
"DDTYPE": "NUMERIC" "DDTYPE": "N"
}, },
{ {
"NAME": "RULE_TYPE", "NAME": "RULE_TYPE",
"LENGTH": 32, "LENGTH": 32,
"VARNUM": 5,
"LABEL": "RULE_TYPE", "LABEL": "RULE_TYPE",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$32.", "FORMAT": "$32.",
"TYPE": "C", "TYPE": "C",
"DDTYPE": "CHARACTER" "DDTYPE": "C"
}, },
{ {
"NAME": "RULE_VALUE", "NAME": "RULE_VALUE",
"LENGTH": 128, "LENGTH": 128,
"VARNUM": 6,
"LABEL": "RULE_VALUE", "LABEL": "RULE_VALUE",
"FMTNAME": "", "FMTNAME": "",
"FORMAT": "$128.", "FORMAT": "$128.",
"TYPE": "C", "TYPE": "C",
"DDTYPE": "CHARACTER" "DDTYPE": "C"
}, },
{ {
"NAME": "TX_FROM", "NAME": "TX_FROM",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 1,
"LABEL": "TX_FROM", "LABEL": "TX_FROM",
"FMTNAME": "DATETIME", "FMTNAME": "DATETIME",
"FORMAT": "DATETIME19.3", "FORMAT": "DATETIME19.3",
@@ -4590,7 +4560,6 @@ let webouts = {
{ {
"NAME": "TX_TO", "NAME": "TX_TO",
"LENGTH": 8, "LENGTH": 8,
"VARNUM": 8,
"LABEL": "TX_TO", "LABEL": "TX_TO",
"FMTNAME": "DATETIME", "FMTNAME": "DATETIME",
"FORMAT": "DATETIME19.3", "FORMAT": "DATETIME19.3",
@@ -5395,10 +5364,10 @@ let webouts = {
const v = JSON.parse(webouts.MPE_X_TEST) const v = JSON.parse(webouts.MPE_X_TEST)
v.cols.push( v.cols.push(
{ NAME: "READONLY_COL", LENGTH: 200, VARNUM: 11, LABEL: "READONLY_COL", FMTNAME: "", FORMAT: "$200.", TYPE: "C", DDTYPE: "CHARACTER" }, { NAME: "READONLY_COL", LENGTH: 200, LABEL: "READONLY_COL", FMTNAME: "", FORMAT: "$200.", TYPE: "C", DDTYPE: "C" },
{ NAME: "HIDDEN_COL", LENGTH: 200, VARNUM: 12, LABEL: "HIDDEN_COL", FMTNAME: "", FORMAT: "$200.", TYPE: "C", DDTYPE: "CHARACTER" }, { NAME: "HIDDEN_COL", LENGTH: 200, LABEL: "HIDDEN_COL", FMTNAME: "", FORMAT: "$200.", TYPE: "C", DDTYPE: "C" },
{ NAME: "ROUND_COL", LENGTH: 8, VARNUM: 13, LABEL: "ROUND_COL", FMTNAME: "", FORMAT: "BEST.", TYPE: "N", DDTYPE: "NUMERIC" }, { NAME: "ROUND_COL", LENGTH: 8, LABEL: "ROUND_COL", FMTNAME: "", FORMAT: "BEST.", TYPE: "N", DDTYPE: "N" },
{ NAME: "NUMFMT_COL", LENGTH: 8, VARNUM: 14, LABEL: "NUMFMT_COL", FMTNAME: "", FORMAT: "BEST.", TYPE: "N", DDTYPE: "NUMERIC" } { NAME: "NUMFMT_COL", LENGTH: 8, LABEL: "NUMFMT_COL", FMTNAME: "", FORMAT: "BEST.", TYPE: "N", DDTYPE: "N" }
) )
v.viewdata = v.viewdata.map((row, i) => ({ v.viewdata = v.viewdata.map((row, i) => ({
+29 -6
View File
@@ -2012,6 +2012,29 @@ insert into &lib..MPE_VALIDATIONS set
,rule_value='/t/' ,rule_value='/t/'
,rule_active=1 ,rule_active=1
,tx_to='31DEC5999:23:59:59'dt; ,tx_to='31DEC5999:23:59:59'dt;
/* test soft regex - PRIMARY_KEY_FIELD should be an integer (yellow if it
contains a decimal point). All generated keys are integers, so no
warning shows until a demo user enters a decimal. */
insert into &lib..MPE_VALIDATIONS set
tx_from=0
,base_lib="&lib"
,base_ds="MPE_X_TEST"
,base_col="PRIMARY_KEY_FIELD"
,rule_type='SOFTREGEX'
,rule_value='/^\d+$/'
,rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
/* test hard regex - SOME_SHORTNUM must not be between 1 and 5. Generated
data starts at 6, so nothing is blocked until a demo user enters 1-5. */
insert into &lib..MPE_VALIDATIONS set
tx_from=0
,base_lib="&lib"
,base_ds="MPE_X_TEST"
,base_col="SOME_SHORTNUM"
,rule_type='HARDREGEX'
,rule_value='/^(?![1-5](\.\d+)?$).*/'
,rule_active=1
,tx_to='31DEC5999:23:59:59'dt;
insert into &lib..MPE_VALIDATIONS set insert into &lib..MPE_VALIDATIONS set
tx_from=0 tx_from=0
,base_lib="&lib" ,base_lib="&lib"
@@ -2061,7 +2084,7 @@ insert into &lib..MPE_VALIDATIONS set
,some_date=42 ,some_date=42
,some_datetime=42 ,some_datetime=42
,some_time=42 ,some_time=42
,some_shortnum=3 ,some_shortnum=8
,some_bestnum=44; ,some_bestnum=44;
insert into &lib..mpe_x_test insert into &lib..mpe_x_test
set primary_key_field=1 set primary_key_field=1
@@ -2071,7 +2094,7 @@ insert into &lib..MPE_VALIDATIONS set
,some_date=42 ,some_date=42
,some_datetime=42 ,some_datetime=42
,some_time=422 ,some_time=422
,some_shortnum=3 ,some_shortnum=8
,some_bestnum=44; ,some_bestnum=44;
insert into &lib..mpe_x_test insert into &lib..mpe_x_test
set primary_key_field=2 set primary_key_field=2
@@ -2081,7 +2104,7 @@ insert into &lib..MPE_VALIDATIONS set
,some_date=42 ,some_date=42
,some_datetime=42 ,some_datetime=42
,some_time=142 ,some_time=142
,some_shortnum=3 ,some_shortnum=8
,some_bestnum=44; ,some_bestnum=44;
insert into &lib..mpe_x_test insert into &lib..mpe_x_test
set primary_key_field=3 set primary_key_field=3
@@ -2093,7 +2116,7 @@ insert into &lib..MPE_VALIDATIONS set
,some_date=423 ,some_date=423
,some_datetime=423 ,some_datetime=423
,some_time=44 ,some_time=44
,some_shortnum=3 ,some_shortnum=8
,some_bestnum=44; ,some_bestnum=44;
insert into &lib..mpe_x_test insert into &lib..mpe_x_test
set primary_key_field=4 set primary_key_field=4
@@ -2103,7 +2126,7 @@ insert into &lib..MPE_VALIDATIONS set
,some_date=4231 ,some_date=4231
,some_datetime=423123123 ,some_datetime=423123123
,some_time=412 ,some_time=412
,some_shortnum=3 ,some_shortnum=8
,some_bestnum=44; ,some_bestnum=44;
%do x=10 %to 500; %do x=10 %to 500;
insert into &lib..mpe_x_test insert into &lib..mpe_x_test
@@ -2114,7 +2137,7 @@ insert into &lib..MPE_VALIDATIONS set
,some_date=round(ranuni(0)*1000,1) ,some_date=round(ranuni(0)*1000,1)
,some_datetime=round(ranuni(0)*50000,1) ,some_datetime=round(ranuni(0)*50000,1)
,some_time=round(ranuni(0)*100,1) ,some_time=round(ranuni(0)*100,1)
,some_shortnum=round(ranuni(0)*100,1) ,some_shortnum=6+round(ranuni(0)*94,1)
,some_bestnum=round(ranuni(0)*100,1); ,some_bestnum=round(ranuni(0)*100,1);
%end; %end;
+10 -6
View File
@@ -24,10 +24,10 @@
<h5> cols </h5> <h5> cols </h5>
Contains column level attributes. Contains column level attributes.
@li NAME - column name @li NAME - column name
@li VARNUM - var position. https://core.sasjs.io/mp__getcols_8sas.html
@li LABEL - var label. https://core.sasjs.io/mp__getcols_8sas.html @li LABEL - var label. https://core.sasjs.io/mp__getcols_8sas.html
@li FMTNAME - derived format. https://core.sasjs.io/mp__getcols_8sas.html @li FMTNAME - derived format. https://core.sasjs.io/mp__getcols_8sas.html
@li DDTYPE - derived dropdown. https://core.sasjs.io/mp__getcols_8sas.html @li DDTYPE - derived dropdown. C=CHARACTER, N=NUMERIC, else DATE / TIME /
DATETIME. https://core.sasjs.io/mp__getcols_8sas.html
@li CLS_RULE - values include: @li CLS_RULE - values include:
- EDIT - the column is editable - EDIT - the column is editable
- READ - the column should be readonly - READ - the column should be readonly
@@ -488,14 +488,18 @@ select upcase(loadtype)
/* extract col info */ /* extract col info */
%mp_getcols(&libds, outds=cols1) %mp_getcols(&libds, outds=cols1)
/* join with cls rules */ /* join with cls rules. Trim COLS payload in the SQL below - DDTYPE
C=CHARACTER N=NUMERIC (dates kept in full), VARNUM dropped (client
relies on column order, not position) */
proc sql; proc sql;
create table work.cols as create table work.cols as
select a.NAME select a.NAME
,a.VARNUM
,coalesce(c.desc,a.NAME) as LABEL ,coalesce(c.desc,a.NAME) as LABEL
,a.FMTNAME ,a.FMTNAME
,a.DDTYPE ,case a.DDTYPE
when 'CHARACTER' then 'C'
when 'NUMERIC' then 'N'
else a.DDTYPE end as DDTYPE
,case b.cls_hide ,case b.cls_hide
when 1 then 'HIDE' when 1 then 'HIDE'
when 0 then 'EDIT' when 0 then 'EDIT'
@@ -503,7 +507,7 @@ create table work.cols as
,c.memlabel ,c.memlabel
,c.longdesc ,c.longdesc
,d.colType ,d.colType
from work.cols1 a from work.cols1(drop=varnum) a
left join work.cls_rules b left join work.cls_rules b
on a.NAME=b.CLS_VARIABLE_NM on a.NAME=b.CLS_VARIABLE_NM
left join work.spec c left join work.spec c
+2 -2
View File
@@ -33,7 +33,7 @@ data cols(keep=name type length varnum format label);
else if formatl=0 then format=cats(format2,'.'); else if formatl=0 then format=cats(format2,'.');
else format=cats(format2,formatl,'.'); else format=cats(format2,formatl,'.');
type='C'; type='C';
ddtype='CHARACTER'; ddtype='C';
end; end;
else do; else do;
if format2='' then format=cats(length,'.'); if format2='' then format=cats(length,'.');
@@ -44,7 +44,7 @@ data cols(keep=name type length varnum format label);
if format=:'DATETIME' then ddtype='DATETIME'; if format=:'DATETIME' then ddtype='DATETIME';
else if format=:'DATE' then ddtype='DATE'; else if format=:'DATE' then ddtype='DATE';
else if format=:'TIME' then ddtype='TIME'; else if format=:'TIME' then ddtype='TIME';
else ddtype='NUMERIC'; else ddtype='N';
end; end;
if label='' then label=name; if label='' then label=name;
run; run;
+17 -6
View File
@@ -15,13 +15,12 @@
<h4> Service Outputs </h4> <h4> Service Outputs </h4>
<h5> cols </h5> <h5> cols </h5>
@li DDTYPE @li DDTYPE - C=CHARACTER, N=NUMERIC, else DATE / TIME / DATETIME
@li FORMAT @li FORMAT
@li LABEL @li LABEL
@li LENGTH @li LENGTH
@li NAME @li NAME
@li TYPE @li TYPE
@li VARNUM
<h5> sasparams </h5> <h5> sasparams </h5>
@li FILTER_TEXT @li FILTER_TEXT
@@ -361,11 +360,23 @@ run;
) )
%mp_getcols(&libds, outds=cols1) %mp_getcols(&libds, outds=cols1)
/* trim COLS payload in the SQL below - DDTYPE C=CHARACTER N=NUMERIC
(dates kept in full), VARNUM dropped (client relies on column order,
not position) */
proc sql; proc sql;
create table cols(drop=srclabel) as create table cols as
select a.* select a.name
,coalesce(b.dd_shortdesc,a.srclabel,a.name) as label ,a.type
from cols1(rename=(label=srclabel)) a ,a.length
,a.format
,a.fmtname
,case a.ddtype
when 'CHARACTER' then 'C'
when 'NUMERIC' then 'N'
else a.ddtype end as ddtype
,coalesce(b.dd_shortdesc,a.label,a.name) as label
from cols1 a
left join &mpelib..mpe_datadictionary left join &mpelib..mpe_datadictionary
(where=(&dc_dttmtfmt. < tx_to (where=(&dc_dttmtfmt. < tx_to
and dd_source ? %upcase("&orig_libds") and dd_source ? %upcase("&orig_libds")