Compare commits
21
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acb97f4bfb | ||
|
|
bb808617f4 | ||
|
|
4a8c39b4c0 | ||
|
|
42e02cdb05 | ||
|
|
347923900f | ||
|
|
a4c3989c26 | ||
|
|
0392a81cbd | ||
|
|
f9ea53cf78 | ||
|
|
8fb58eb36e | ||
|
|
180c2477ed | ||
|
|
69cfccd565 | ||
|
|
f171375899 | ||
|
|
57db1179a9 | ||
|
|
d13fab267f | ||
|
|
44bc7f7fea | ||
|
|
f60bcef583 | ||
|
|
e7abb0a08a | ||
|
|
cfb60e5e4b | ||
|
|
aaf406b386 | ||
|
|
39c8855f37 | ||
|
|
cbea04c8e1 |
@@ -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.
|
||||||
@@ -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 (`/^(??$).*/` - values 1-5 blocked; generated data starts at 6 so demos aren't blocked accidentally).
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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'
|
||||||
},
|
},
|
||||||
|
|||||||
Generated
+494
-304
File diff suppressed because it is too large
Load Diff
+5
-1
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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' }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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'
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
@@ -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.",
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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) => ({
|
||||||
|
|||||||
@@ -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='/^(??$).*/'
|
||||||
|
,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;
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user