Compare commits

..
8 Commits
Author SHA1 Message Date
YuryShkoda 1031ea7ed7 feat(editor): translate column names to cell references on formula paste
Build / Build-and-ng-test (pull_request) Successful in 5m48s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m28s
Build / Build-and-test-development (pull_request) Successful in 26m35s
- Paste a formula using column names (e.g. "=A_COL * B_COL") into any
  cell and have it translated to that row's cell references
  ("=B4 * C4") so HyperFormula can evaluate it - covers both a
  grid-level paste and pasting directly into an open cell editor
- Only applies on tables that already have formulas enabled (an
  existing HARDFORMULA/SOFTFORMULA column); otherwise the pasted text
  is left untouched rather than becoming inert translated text
- New substituteColumnReferences() reuses parseFormulaRule's
  boundary-matching helpers but deliberately skips DC.* variable
  substitution, which only makes sense for admin-defined rule values
2026-08-19 12:48:32 +03:00
allan bf54a589f5 Merge pull request 'fix: address hermes review feedback (formula quoting, cell revert, addRow guard)' (#305) from pr-feedback into version7-13
Build / Build-and-ng-test (pull_request) Successful in 5m11s
Lighthouse Checks / lighthouse (pull_request) Successful in 22m1s
Build / Build-and-test-development (pull_request) Successful in 25m9s
Reviewed-on: #305
2026-08-17 12:43:26 +00:00
YuryShkoda 36963aa746 fix: address hermes review feedback (formula quoting, cell revert, addRow guard)
Build / Build-and-ng-test (pull_request) Successful in 5m41s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m42s
Build / Build-and-test-development (pull_request) Successful in 24m59s
- Escape embedded double quotes in DC.USER_NAME/DC.ORIG_VALUE formula
  literals, preventing malformed HyperFormula expressions
- Extract cell-revert numeric parsing into resolveRevertedCellValue,
  falling back to the raw text instead of writing NaN
- Guard addRow() against an empty dataSource before indexing into it
- Document the cross-repo assumption behind licence protocol-mismatch
  detection
2026-08-17 13:11:25 +03:00
allan 9b2e0df2b0 Merge pull request 'fix(core): bump to v5 (breaking change)' (#304) from corebump into version7-13
Build / Build-and-ng-test (pull_request) Successful in 5m6s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m22s
Build / Build-and-test-development (pull_request) Successful in 24m38s
Reviewed-on: #304
2026-08-14 11:05:18 +00:00
4gl 023c29f00f fix: mp_execute dep
Build / Build-and-ng-test (pull_request) Successful in 5m6s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m24s
Build / Build-and-test-development (pull_request) Successful in 25m2s
2026-08-14 10:34:59 +01:00
4gl 59e9e96f5a fix(core): bump to v5 (breaking change)
Build / Build-and-ng-test (pull_request) Successful in 5m12s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m15s
Build / Build-and-test-development (pull_request) Successful in 24m56s
2026-08-14 08:53:28 +01:00
4gl 067c08765b chore: adding additional mp_abort per per review
Build / Build-and-ng-test (pull_request) Successful in 5m43s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m38s
Build / Build-and-test-development (pull_request) Successful in 24m54s
2026-08-14 08:19:10 +01:00
4gl 9cd976495b chore(tidy up): remove trailing space and deprecated logic
Build / Build-and-ng-test (pull_request) Successful in 5m2s
Lighthouse Checks / lighthouse (pull_request) Successful in 21m22s
Build / Build-and-test-development (pull_request) Successful in 24m50s
2026-08-12 21:39:28 +01:00
59 changed files with 686 additions and 1146 deletions
+106
View File
@@ -1291,6 +1291,91 @@ context('editor tests: ', function () {
})
})
})
// Row 1 (0-indexed): A_COL=2, B_COL=10 - see the HARDFORMULA/SOFTFORMULA
// tests' own comment above.
it("40 | Pasting a formula with column names translates them to this row's cell references and evaluates it, on a table with formulas enabled", () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL').click()
pasteTextIntoFocusedCell('=A_COL * B_COL')
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL').should('have.text', '20')
})
})
})
it('41 | Pasting the same formula text on a table without formulas enabled leaves it as literal, untranslated text', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
// force: true - same overlay-clone-layer reason as sortByColumn's
// own comment below; SOME_CHAR's header sort icon covers the cell.
getCellByHeaderAndRow(1, 'SOME_CHAR').click({ force: true })
pasteTextIntoFocusedCell('=SOME_BESTNUM * SOME_BESTNUM')
getCellByHeaderAndRow(1, 'SOME_CHAR').should(
'have.text',
'=SOME_BESTNUM * SOME_BESTNUM'
)
})
})
})
it('42 | Pasting a formula referencing a column name inside a quoted string leaves the quoted text untouched', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL').click()
// A_COL needs a leading/trailing blank to be recognised as a
// column-name token (see substituteColumnReferences' own "no
// surrounding blanks" test) - matches the space-after-paren
// convention already used by this fixture's own CHANGE_SUMMARY_COL
// rule value ('=IF( DC.ROW_STATUS ="U",...)').
pasteTextIntoFocusedCell(
'=IF( A_COL > 0, "A_COL is positive", "negative")'
)
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL').should(
'have.text',
'A_COL is positive'
)
})
})
})
it('43 | Pasting a formula directly into an open cell editor (double-click, then paste) also translates and evaluates it', () => {
openTableFromTree(libraryToOpenIncludes, 'mpe_x_formula_test')
clickOnEdit(() => {
cy.get('.btn.btn-sm.btn-icon.btn-outline-danger', {
timeout: longerCommandTimeout
}).then(() => {
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL')
.dblclick({ force: true })
.then(() => {
pasteTextIntoFocusedCell('=A_COL * B_COL')
cy.focused().type('{enter}')
getCellByHeaderAndRow(1, 'PLAIN_TEXT_COL').should('have.text', '20')
})
})
})
})
})
// Handsontable virtualizes columns — with 18 columns on MPE_X_NEW, only
@@ -1352,6 +1437,27 @@ const openColumnDropdown = (headerText: string) => {
// re-renders virtualized columns asynchronously after a scroll event, on
// its own schedule outside Cypress's command queue, so scrollTo() settling
// does not mean the target column has been rendered yet.
// Simulates a real clipboard paste into whichever element currently has
// DOM focus (Handsontable moves focus to its own internal textarea once a
// cell is selected/clicked) - Cypress has no built-in clipboard simulation,
// and Handsontable's CopyPaste plugin only listens for a native 'paste'
// DOM event with clipboardData, so a synthetic ClipboardEvent is
// dispatched directly rather than trying to drive the OS clipboard.
const pasteTextIntoFocusedCell = (text: string) => {
cy.focused().then(($el) => {
const dataTransfer = new DataTransfer()
dataTransfer.setData('text/plain', text)
const pasteEvent = new ClipboardEvent('paste', {
clipboardData: dataTransfer,
bubbles: true,
cancelable: true
})
$el[0].dispatchEvent(pasteEvent)
})
}
const getCellByHeaderAndRow = (rowIndex: number, headerText: string) => {
return cy
.get('.ht_clone_top .htCore thead tr th')
+1 -344
View File
@@ -249,10 +249,6 @@ context('licensing tests: ', function () {
cy.get('button').contains('Paste licence').click()
// Defaults to the combined single-field form - switch to the legacy
// two-field layout, since this test targets that path specifically.
cy.get('button').contains('Paste as two separate keys instead').click()
// HTTP-format keys are generated as the exact same string for both
// fields (see detectLicenceKeyProtocolMismatch's own doc comment) -
// any non-empty matching pair triggers the warning, no real key needed.
@@ -277,261 +273,6 @@ context('licensing tests: ', function () {
})
})
it('7 | Combined single-string key (default paste format) activates successfully', (done) => {
let keyData = {
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
users_allowed: 4,
hot_license_key: '',
demo: false,
site_id: site_id
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on
// the licensing page - earlier tests may have left the app on it (a
// bad key) or already activated (a good one), and this test should
// pass either way rather than silently no-op when it's the latter.
isLicensingPage((result: boolean) => {
const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
inputCombinedKeyPage(combinedKey)
cy.wait(2000)
acceptTermsIfPresented((termsResult: boolean) => {
if (termsResult) {
cy.wait(10000)
}
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then(() => {
done()
})
})
}
)
if (!result) {
visitPage('licensing/update')
// Chained via .then() rather than called as the next plain
// statement - generateCombinedKey is a raw async function that
// itself invokes cy commands (cy.log), and Cypress can't
// reconcile that promise with the preceding cy.visit()/cy.wait()
// unless it's explicitly handed off through a real command chain.
cy.wait(2000).then(() => proceed())
} else {
proceed()
}
})
})
})
it('8 | Pasting a combined key into the legacy licence-key field alone still auto-detects and activates', (done) => {
let keyData = {
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
users_allowed: 4,
hot_license_key: '',
demo: false,
site_id: site_id
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on
// the licensing page - see test 7's own comment for why.
isLicensingPage((result: boolean) => {
const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
cy.get('button').contains('Paste licence').click()
cy.get('button')
.contains('Paste as two separate keys instead')
.click()
// Only the licence-key field gets the combined string - the
// activation-key field is left empty, proving the split
// populates both fields from this one paste.
cy.get('.license-key-form textarea', {
timeout: longerCommandTimeout
})
.invoke('val', combinedKey)
.trigger('input')
.trigger('mouseleave')
cy.get('.activation-key-form textarea', {
timeout: longerCommandTimeout
}).should(($textarea) => {
expect($textarea.val()).to.equal(keysGen.activationKey)
})
cy.get('button.apply-keys').click()
cy.wait(2000)
acceptTermsIfPresented((termsResult: boolean) => {
if (termsResult) {
cy.wait(10000)
}
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then(() => {
done()
})
})
}
)
// See test 7's own comment for why this is chained via .then()
// rather than called as the next plain statement.
if (!result) {
visitPage('licensing/update')
cy.wait(2000).then(() => proceed())
} else {
proceed()
}
})
})
})
it('9 | Uploading a single-line combined-format file activates successfully', (done) => {
let keyData = {
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
users_allowed: 4,
hot_license_key: '',
demo: false,
site_id: site_id
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on
// the licensing page - see test 7's own comment for why.
isLicensingPage((result: boolean) => {
const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
cy.get('input[type="file"]').attachFile({
fileContent: new Blob([combinedKey], { type: 'text/plain' }),
fileName: 'datacontroller-licence-combined.txt',
mimeType: 'text/plain'
})
cy.get('button.apply-keys', {
timeout: longerCommandTimeout
}).click()
cy.wait(2000)
acceptTermsIfPresented((termsResult: boolean) => {
if (termsResult) {
cy.wait(10000)
}
visitPage('home')
cy.get('.nav-tree clr-tree > clr-tree-node', {
timeout: longerCommandTimeout
}).then(() => {
done()
})
})
}
)
// See test 7's own comment for why this is chained via .then()
// rather than called as the next plain statement.
if (!result) {
visitPage('licensing/update')
cy.wait(2000).then(() => proceed())
} else {
proceed()
}
})
})
})
it('10 | Key details preview shows for a validly-pasted key and disappears once the key input is cleared', (done) => {
let keyData = {
valid_until: moment().add(1, 'year').format('YYYY-MM-DD'),
users_allowed: 4,
hot_license_key: '',
demo: false,
site_id: site_id,
// Kept to just the two enabled toggles (relying on
// decodeLicenceFeatures' fallback for the rest) to keep the
// encrypted payload well under this suite's RSA-OAEP plaintext
// ceiling - see generateKeys' modulusLength below.
features: { vb: true, fu: true }
}
generateKeys(keyData, (keysGen: any) => {
cy.wait(2000)
// Navigate there explicitly rather than only acting when already on
// the licensing page - see test 7's own comment for why.
isLicensingPage((result: boolean) => {
const proceed = () =>
generateCombinedKey(keysGen.licenseKey, keysGen.activationKey).then(
(combinedKey) => {
cy.get('button').contains('Paste licence').click()
cy.get('.combined-key-form textarea', {
timeout: longerCommandTimeout
})
.invoke('val', combinedKey)
.trigger('input')
.trigger('mouseleave')
cy.get('.key-details')
.should('contain', 'Valid until:')
.and('contain', 'Allowed users:')
.and('contain', '4')
.and('contain', 'Site ID(s) in this key:')
.and('contain', 'Enabled features:')
.and('contain', 'Viewbox')
.and('contain', 'File Upload')
.invoke('text')
.then((text) => {
expect(text).to.not.contain('Edit Record')
expect(text).to.not.contain('Add Record')
})
cy.get('.combined-key-form textarea')
.invoke('val', '')
.trigger('input')
.trigger('mouseleave')
cy.get('.key-details')
.should('not.exist')
.then(() => {
done()
})
}
)
// See test 7's own comment for why this is chained via .then()
// rather than called as the next plain statement.
if (!result) {
visitPage('licensing/update')
cy.wait(2000).then(() => proceed())
} else {
proceed()
}
})
})
})
if (testLicenceUserLimits) {
it('4 | User try to register when limit is reached', (done) => {
let keyData = {
@@ -705,7 +446,7 @@ const verifyLicensingPage = (text: string, callback: any) => {
cy.wait(1000)
isLicensingPage((result: boolean) => {
if (result) {
cy.get('.key-error')
cy.get('p.key-error')
.should('contain', text)
.then((treeNodes: any) => {
callback(true)
@@ -729,10 +470,6 @@ const verifyLicensingWarning = (text: string, callback: any) => {
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
cy.get('button').contains('Paste licence').click()
// Defaults to the combined single-field form - this helper exercises the
// two-part path specifically, so switch to it first.
cy.get('button').contains('Paste as two separate keys instead').click()
cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
.invoke('val', licenseKey)
.trigger('input')
@@ -744,30 +481,6 @@ const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
cy.get('button.apply-keys').click()
}
const inputCombinedKeyPage = (combinedKey: string) => {
cy.get('button').contains('Paste licence').click()
// Combined is the default paste format - no toggle click needed here,
// unlike inputLicenseKeyPage's legacy two-field path.
cy.get('.combined-key-form textarea', { timeout: longerCommandTimeout })
.invoke('val', combinedKey)
.trigger('input')
.trigger('mouseleave')
.should('not.be.undefined')
// mouseleave's handler (onCombinedKeyInput) does async work - gzip
// decompress the split, then decrypt for the key-details preview -
// before licenceKeyValue/activationKeyValue are ready to submit.
// Wait for that preview to land rather than racing "Apply licence
// keys" against the still-in-flight promise.
cy.get('.key-details', { timeout: longerCommandTimeout }).should(
'contain',
'Valid until:'
)
cy.get('button.apply-keys').click()
}
const updateUsersTable = (options: any, callback?: any) => {
visitPage('home')
openTableFromTree(libraryToOpenIncludes, 'mpe_users')
@@ -912,62 +625,6 @@ const generateKeys = async (licenseData: any, resultCallback?: any) => {
})
}
// Mirrors dckey's own encodeCombinedKey()/gzipCompress() (main.js) and
// DC's own splitCombinedLicenceKey() decode side, so this stays a genuine
// end-to-end check of the real format rather than a fixture the app
// happens to accept.
const COMBINED_KEY_PREFIX = 'DCKEY1:'
// Reads a ReadableStream directly via its own reader, rather than through
// `new Response(readable).arrayBuffer()` - Cypress patches fetch/Response
// globally for its network-interception features, which appears to hang
// a Response built locally around a stream that never touches the network
// (writer.write()/close() resolve fine; only the Response-based read never
// settles).
const streamToArrayBuffer = async (
readable: ReadableStream<Uint8Array>
): Promise<ArrayBuffer> => {
const reader = readable.getReader()
const chunks: Uint8Array[] = []
let totalLength = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
totalLength += value.length
}
const result = new Uint8Array(totalLength)
let offset = 0
for (const chunk of chunks) {
result.set(chunk, offset)
offset += chunk.length
}
return result.buffer
}
const generateCombinedKey = async (
licenseKey: string,
activationKey: string
): Promise<string> => {
const payloadBytes = new TextEncoder().encode(
`${licenseKey} ${activationKey}`
)
const compressionStream = new CompressionStream('gzip')
const writer = compressionStream.writable.getWriter()
const [, compressedBuffer] = await Promise.all([
writer.write(payloadBytes).then(() => writer.close()),
streamToArrayBuffer(compressionStream.readable)
])
const compressedBase64 = await arrayBufferToBase64(compressedBuffer)
return COMBINED_KEY_PREFIX + compressedBase64
}
const editTableField = (edits: EditConfigTableCells[], callback?: any) => {
cy.get('td').then((tdNodes: any) => {
for (let edit of edits) {
-6
View File
@@ -183,12 +183,6 @@ const isLicensingPage = (callback: any) => {
const inputLicenseKeyPage = (licenseKey: string, activationKey: string) => {
cy.get('button').contains('Paste licence').click()
// Combined single-string paste is the default format now - switch to
// the legacy two-field layout, since this helper fills licenseKey and
// activationKey as two separate values.
cy.get('button').contains('Paste as two separate keys instead').click()
cy.get('.license-key-form textarea', { timeout: longerCommandTimeout })
.invoke('val', licenseKey)
.trigger('input')
+23 -21
View File
@@ -1,30 +1,32 @@
import * as base64Converter from 'base64-arraybuffer'
export const base64ToArrayBuffer = (base64: string) => {
return new Promise(async (resolve, reject) => {
const dataUrl = 'data:application/octet-binary;base64,' + base64
const dataUrl = "data:application/octet-binary;base64," + base64;
fetch(dataUrl)
.then((res) => res.arrayBuffer())
.then((buffer) => {
.then(res => res.arrayBuffer())
.then(buffer => {
resolve(new Uint8Array(buffer))
})
.catch((err) => {
}).catch((err) => {
reject(err)
})
})
}
// Blob + FileReader.readAsDataURL (a natural alternative here) is
// unreliable in the Cypress runner's iframe - onload/onerror can simply
// never fire, leaving the promise permanently unsettled (a silent hang,
// surfacing only as Mocha's "done() was never invoked" 30s later with no
// clue why). base64-arraybuffer's encode() is synchronous and
// dependency-free - no browser API involved, so this class of hang isn't
// possible - and it's the same library the app itself uses for this exact
// conversion.
export const arrayBufferToBase64 = (
arrayBuffer: ArrayBuffer
): Promise<string> => {
return Promise.resolve(base64Converter.encode(arrayBuffer))
}
export const arrayBufferToBase64 = (arrayBuffer: any) => {
return new Promise((resolve, reject) => {
const blob = new Blob([arrayBuffer])
const reader = new FileReader();
reader.onload = async function(event){
if (event.target) {
var base64: any = event.target.result
base64 = base64.substring(37, base64.length)
resolve(base64)
}
};
reader.readAsDataURL(blob);
})
}
+62 -2
View File
@@ -46,9 +46,11 @@ import { preventMenuItemAutoClose } from './utils/preventMenuItemAutoClose'
import { findOverwrittenCells } from '../shared/dc-validator/utils/findOverwrittenCells'
import { getFormulaCellsToPreserveOnCancel } from '../shared/dc-validator/utils/getFormulaCellsToPreserveOnCancel'
import { getRevertableCols } from '../shared/dc-validator/utils/getRevertableCols'
import { resolveRevertedCellValue } from '../shared/dc-validator/utils/resolveRevertedCellValue'
import { getStableFormulaBaseCols } from '../shared/dc-validator/utils/getStableFormulaBaseCols'
import { syncOverwrittenCellComment } from '../shared/dc-validator/utils/syncOverwrittenCellComment'
import { parseFormulaRule } from '../shared/dc-validator/utils/parseFormulaRule'
import { substituteColumnReferences } from '../shared/dc-validator/utils/substituteColumnReferences'
import { DcValidator } from '../shared/dc-validator/dc-validator'
import { Col } from '../shared/dc-validator/models/col.model'
import { DcValidation } from '../shared/dc-validator/models/dc-validation.model'
@@ -262,7 +264,7 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.setDataAtRowProp(
row,
prop,
isNumericCol ? Number(rawValueText) : rawValueText
resolveRevertedCellValue(rawValueText, isNumericCol)
)
commentsPlugin.removeCommentAtCell(row, col)
}
@@ -1449,7 +1451,14 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
// about the new row and HARDFORMULA/SOFTFORMULA columns silently
// fall out of sync with the grid's own data.
hot.alter('insert_row_below', newIndex - 1, 1)
this.dataSource[newIndex].noLinkOption = true
// alter() is expected to splice synchronously into dataSource, but
// this guards against relying on that if the data binding ever
// becomes async.
if (this.dataSource[newIndex]) {
this.dataSource[newIndex].noLinkOption = true
}
this.seedFormulaValuesForRow(newIndex)
this.updateEditStatusForRow(newIndex)
@@ -4199,11 +4208,62 @@ export class EditorComponent implements OnInit, AfterViewInit, OnDestroy {
hot.addHook('beforePaste', (data: any, cords: any) => {
const startCol = cords[0].startCol
const startRow = cords[0].startRow
for (let r = 0; r < data.length; r++) {
data[r] = coerceNumericRow(data[r], startCol)
// Translate column names in a pasted formula (e.g. "=PRICE * VOLUME")
// into this row's cell references ("=B4 * C4") - only meaningful
// when formulas are enabled for this table (see hotTable.formulas
// above), otherwise the translated string would just sit as inert
// literal text.
if (this.hotTable.formulas) {
data[r] = data[r].map((value: any) =>
typeof value === 'string' && value.trim().startsWith('=')
? substituteColumnReferences(
value.trim(),
this.headerColumns,
startRow + r
)
: value
)
}
}
})
// Same column-name -> cell-reference translation as beforePaste above,
// but for pasting directly into an open cell editor (double-click,
// then Cmd+V/Ctrl+V or right-click > Paste) - Handsontable's CopyPaste
// plugin only intercepts a grid-level paste (cell selected but not
// being edited); pasting into the editor's own textarea is a native
// browser paste event the plugin never sees. Listens once on the root
// element (paste events bubble) rather than attaching a new listener
// per edit session, to avoid accumulating listeners across repeated
// edits.
hot.rootElement.addEventListener('paste', (event: ClipboardEvent) => {
if (!this.hotTable.formulas) return
const editor: any = hot.getActiveEditor()
if (!editor || editor.row === null || event.target !== editor.TEXTAREA)
return
const pastedText = event.clipboardData?.getData('text/plain') ?? ''
if (!pastedText.trim().startsWith('=')) return
event.preventDefault()
editor.TEXTAREA.value = substituteColumnReferences(
pastedText.trim(),
this.headerColumns,
editor.row
)
// A real paste's default action would fire this itself once the
// text landed - since preventDefault() above skips that, dispatch it
// manually so the editor's own input-tracking (autosize, its
// internal notion of the current value) picks up the change.
editor.TEXTAREA.dispatchEvent(new Event('input', { bubbles: true }))
})
hot.addHook(
'beforeAutofill',
(selectionData: any[][], sourceRange: any) => {
+51 -134
View File
@@ -5,11 +5,6 @@
<div class="card-text">
<ng-container *ngSwitchCase="'key'">
<p class="key-error" *ngIf="!keyError">
<clr-icon
class="is-error"
shape="exclamation-circle"
size="26"
></clr-icon>
Licence key is invalid. We can't provide you more details at the
moment
</p>
@@ -25,11 +20,6 @@
<ng-container *ngSwitchCase="'limit'">
<p class="key-error">
<clr-icon
class="is-error"
shape="exclamation-circle"
size="26"
></clr-icon>
The registered number of users reached the limit specified for your
licence. Please contact
<contact-link classes="color-green" />
@@ -44,9 +34,9 @@
</p>
</ng-container>
<p class="m-0 mt-10"><strong>Protocol:</strong> {{ protocol }}</p>
<p><strong>Protocol:</strong> {{ protocol }}</p>
<p class="m-0">
<p>
<strong>SYSSITE:</strong>
<span
*ngFor="let id of syssite.value; let i = index"
@@ -69,6 +59,11 @@
</a>
</p>
<p *ngIf="licenseKeyData && userCountLimitation" class="m-0">
<strong>Allowed users:</strong>
{{ licenseKeyData.users_allowed }}
</p>
<clr-tabs>
<clr-tab>
<button clrTabLink>Upload licence</button>
@@ -107,102 +102,56 @@
<clr-tab>
<button clrTabLink>Paste licence</button>
<clr-tab-content>
<ng-container *ngIf="keyFormat === 'combined'">
<form class="clr-form combined-key-form">
<p>Licence key:</p>
<div class="clr-control-container">
<textarea
[(ngModel)]="combinedKeyValue"
(mouseleave)="onCombinedKeyInput()"
name="combined-key-area"
placeholder="Paste licence key here"
class="clr-textarea"
></textarea>
</div>
</form>
<form class="clr-form license-key-form">
<p>Licence key:</p>
<div class="clr-control-container">
<textarea
[(ngModel)]="licenceKeyValue"
(mouseleave)="trimKeys()"
name="license-key-area"
placeholder="Paste licence key here"
class="clr-textarea"
></textarea>
</div>
</form>
<button
type="button"
class="btn btn-sm btn-link p-0"
(click)="toggleKeyFormat()"
>
Paste as two separate keys instead
</button>
</ng-container>
<ng-container *ngIf="keyFormat === 'legacy'">
<form class="clr-form license-key-form">
<p>Licence key:</p>
<div class="clr-control-container">
<textarea
[(ngModel)]="licenceKeyValue"
(mouseleave)="trimKeys()"
name="license-key-area"
placeholder="Paste licence key here"
class="clr-textarea"
></textarea>
</div>
</form>
<form class="clr-form activation-key-form">
<p>Activation key:</p>
<div class="clr-control-container">
<textarea
[(ngModel)]="activationKeyValue"
(mouseleave)="trimKeys()"
name="activation-key-area"
placeholder="Paste activation key here"
class="clr-textarea"
></textarea>
</div>
</form>
<button
type="button"
class="btn btn-sm btn-link p-0"
(click)="toggleKeyFormat()"
>
Paste as a single key instead
</button>
</ng-container>
<form class="clr-form activation-key-form">
<p>Activation key:</p>
<div class="clr-control-container">
<textarea
[(ngModel)]="activationKeyValue"
(mouseleave)="trimKeys()"
name="activation-key-area"
placeholder="Paste activation key here"
class="clr-textarea"
></textarea>
</div>
</form>
</clr-tab-content>
</clr-tab>
</clr-tabs>
<!--
licenseKeyData starts as whatever key is currently active (set once
in ngOnInit), then live-updates to preview a pasted-but-not-yet-
applied key's own details instead - see refreshKeyPreview().
-->
<div *ngIf="licenseKeyData" class="key-details">
<h6 cds-text="subsection" class="mt-15 mb-10">Key Details</h6>
<p
class="key-error protocol-mismatch-warning"
*ngIf="protocolMismatch === 'requiresHttps'"
>
This key was generated for a secure (HTTPS) connection, but
DataController is currently running over HTTP - it will not activate
here. Access DataController via HTTPS, or contact
<contact-link classes="color-green" />
for an HTTP-compatible key.
</p>
<p class="m-0">
<strong>Valid until:</strong>
{{ licenseKeyData.valid_until }}
<span *ngIf="licenseKeyData.demo">(demo/free tier key)</span>
</p>
<p class="m-0">
<strong>Allowed users:</strong>
{{ licenseKeyData.users_allowed }}
</p>
<p class="m-0">
<strong>Site ID(s) in this key:</strong>
{{
(licenseKeyData.site_id_multiple?.length
? licenseKeyData.site_id_multiple
: [licenseKeyData.site_id]
).join(', ')
}}
</p>
<p *ngIf="enabledFeatures.length" class="m-0">
<strong>Enabled features:</strong>
{{ enabledFeatures.join(', ') }}
</p>
</div>
<p
class="key-error protocol-mismatch-warning"
*ngIf="protocolMismatch === 'requiresHttp'"
>
This key was generated for an insecure (HTTP) connection, but this page
is running in a secure browsing context (HTTPS, or localhost) - it will
not activate here. Contact
<contact-link classes="color-green" />
for an HTTPS-compatible key.
</p>
</div>
<div class="card-footer d-flex clr-align-items-center">
@@ -223,38 +172,6 @@
Continue with free tier
</button>
</div>
<p
class="key-error protocol-mismatch-warning"
*ngIf="protocolMismatch === 'requiresHttps'"
>
<clr-icon
class="color-orange"
shape="exclamation-triangle"
size="26"
></clr-icon>
This key was generated for a secure (HTTPS) connection, but DataController
is currently running over HTTP - it will not activate here. Access
DataController via HTTPS, or contact
<contact-link classes="color-green" />
for an HTTP-compatible key.
</p>
<p
class="key-error protocol-mismatch-warning"
*ngIf="protocolMismatch === 'requiresHttp'"
>
<clr-icon
class="color-orange"
shape="exclamation-triangle"
size="26"
></clr-icon>
This key was generated for an insecure (HTTP) connection, but this page is
running in a secure browsing context (HTTPS, or localhost) - it will not
activate here. Contact
<contact-link classes="color-green" />
for an HTTPS-compatible key.
</p>
</div>
</div>
@@ -1,7 +0,0 @@
.key-error clr-icon {
// clr-icon defaults to vertical-align: middle, which sits it noticeably
// above the text baseline next to it - align its bottom edge with the
// text's instead.
vertical-align: bottom;
margin-right: 10px;
}
+14 -127
View File
@@ -1,5 +1,4 @@
import { Component, OnInit, ViewEncapsulation } from '@angular/core'
import { DomSanitizer, SafeHtml } from '@angular/platform-browser'
import { ActivatedRoute, Router } from '@angular/router'
import { AppService, LicenceService, SasService } from '../services'
import { LicenseKeyData } from '../models/LicenseKeyData'
@@ -8,11 +7,6 @@ import {
detectLicenceKeyProtocolMismatch,
LicenceKeyProtocolMismatch
} from './utils/detectLicenceKeyProtocolMismatch'
import {
isCombinedLicenceKey,
splitCombinedLicenceKey
} from './utils/combinedLicenceKey'
import { getEnabledFeatureLabels } from './utils/getEnabledFeatureLabels'
enum LicenseActions {
key = 'key',
@@ -31,19 +25,12 @@ enum LicenseActions {
export class LicensingComponent implements OnInit {
public action: LicenseActions | null = null
// Each message carries its own icon markup, rather than relying on a
// sibling element in the template - these render via [innerHTML], which
// replaces all of the host element's children, so a real <clr-icon>
// placed in the template around the binding would never survive.
private readonly errorIcon = `<clr-icon class="is-error" shape="exclamation-circle" size="26"></clr-icon>`
// SafeHtml, built via bypassSecurityTrustHtml in the constructor (needs
// DomSanitizer, not yet available in a field initializer) - Angular's
// default [innerHTML] sanitizer strips unrecognised tags like <clr-icon>
// (a custom element, not on its standard-tags allowlist), even though it
// leaves the plain <a> in these same strings alone. Safe to bypass since
// this content is entirely hardcoded here, never derived from user input.
public licenseErrors: { [key: string]: SafeHtml }
public licenseErrors: { [key: string]: string } = {
missing: `Licence key is missing - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`,
expired: `Licence key is expired - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`,
invalid: `Licence key is invalid - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`,
missmatch: `Your SYSSITE (below) is not found in the licence key - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
}
public keyError: string | undefined
public errorDetails: string | undefined
@@ -51,12 +38,6 @@ export class LicensingComponent implements OnInit {
public licenceKeyValue: string = ''
public activationKeyValue: string = ''
// Purely which INPUT LAYOUT is shown (one field vs two) - not a strict
// parsing gate. Recognising a combined key doesn't depend on this: see
// maybeSplitCombinedKey(), called from both layouts' own input handlers.
public keyFormat: 'combined' | 'legacy' = 'combined'
public combinedKeyValue: string = ''
public applyingKeys: boolean = false
public protocol: string =
location.protocol === 'https:'
@@ -67,6 +48,7 @@ export class LicensingComponent implements OnInit {
public currentLicenceKey = this.licenceService.licenceKey
public currentActivationKey = this.licenceService.activationKey
public isAppFreeTier = this.licenceService.isAppFreeTier
public userCountLimitation = this.licenceService.userCountLimitation
public licenseKeyData: LicenseKeyData | null = null
@@ -82,24 +64,8 @@ export class LicensingComponent implements OnInit {
private router: Router,
private licenceService: LicenceService,
private sasService: SasService,
private appService: AppService,
private sanitizer: DomSanitizer
) {
this.licenseErrors = {
missing: this.sanitizer.bypassSecurityTrustHtml(
`${this.errorIcon} Licence key is missing - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
),
expired: this.sanitizer.bypassSecurityTrustHtml(
`${this.errorIcon} Licence key is expired - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
),
invalid: this.sanitizer.bypassSecurityTrustHtml(
`${this.errorIcon} Licence key is invalid - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
),
missmatch: this.sanitizer.bypassSecurityTrustHtml(
`${this.errorIcon} Your SYSSITE (below) is not found in the licence key - please contact <a class="color-green" href="mailto: support@datacontroller.io">support@datacontroller.io</a> and enter valid keys below.`
)
}
}
private appService: AppService
) {}
ngOnInit(): void {
this.licenceKeyValue = this.currentLicenceKey || ''
@@ -127,73 +93,9 @@ export class LicensingComponent implements OnInit {
this.licenseKeyData = this.licenceService.getLicenseKeyData()
}
public async trimKeys() {
public trimKeys() {
this.licenceKeyValue = this.licenceKeyValue.trim()
this.activationKeyValue = this.activationKeyValue.trim()
// Auto-detect regardless of keyFormat - a combined key pasted into the
// legacy "Licence key" field on its own still gets recognised and
// splits into both fields, rather than requiring the toggle to match.
await this.maybeSplitCombinedKey(this.licenceKeyValue)
await this.refreshKeyPreview()
}
public async onCombinedKeyInput() {
this.combinedKeyValue = this.combinedKeyValue.trim()
// The combined field is the sole source of truth for
// licenceKeyValue/activationKeyValue in this layout - if it's been
// cleared (or isn't a recognisable combined key), there's no key data
// to preview, so clear rather than leave a stale split from an earlier
// paste (which would otherwise keep decrypting to that old key).
const wasSplit = await this.maybeSplitCombinedKey(this.combinedKeyValue)
if (!wasSplit) {
this.licenceKeyValue = ''
this.activationKeyValue = ''
}
await this.refreshKeyPreview()
}
// Decrypts whatever's currently in licenceKeyValue/activationKeyValue
// purely to preview its details (valid_until, users_allowed, ...) before
// the user ever clicks Apply - decryptLicenseKey() has no side effects,
// so this is safe to call speculatively on every input change. Clears
// rather than leaves stale data on failure/incomplete input, since
// showing a previous key's details next to a since-changed paste would
// be actively misleading, not just uninformative.
private async refreshKeyPreview(): Promise<void> {
if (!this.licenceKeyValue || !this.activationKeyValue) {
this.licenseKeyData = null
return
}
try {
this.licenseKeyData = await this.licenceService.decryptLicenseKey(
this.licenceKeyValue,
this.activationKeyValue
)
} catch {
this.licenseKeyData = null
}
}
public toggleKeyFormat() {
this.keyFormat = this.keyFormat === 'combined' ? 'legacy' : 'combined'
}
// Shared by both input layouts' own handlers above - populates
// licenceKeyValue/activationKeyValue when value is recognisably a
// combined key, regardless of which field it was typed/pasted into.
private async maybeSplitCombinedKey(value: string): Promise<boolean> {
if (!isCombinedLicenceKey(value)) return false
const split = await splitCombinedLicenceKey(value)
if (!split) return false
this.licenceKeyValue = split.licenceKey
this.activationKeyValue = split.activationKey
return true
}
public copySyssite(copyIconRef: any, copyTooltip: any, syssite: string[]) {
@@ -252,7 +154,7 @@ export class LicensingComponent implements OnInit {
const reader = new FileReader()
reader.onload = async (evt) => {
reader.onload = (evt) => {
this.licenceFileError = 'Error reading file.'
if (!evt || !evt.target) return
@@ -262,20 +164,9 @@ export class LicensingComponent implements OnInit {
this.licenceFileLoading = false
this.licenceFileError = undefined
const fileArr = evt.target.result.toString().trim().split('\n')
// A combined-key file is a single line - splits into both fields.
// Anything else keeps the existing 2-line legacy file behaviour.
if (
fileArr.length !== 1 ||
!(await this.maybeSplitCombinedKey(fileArr[0]))
) {
this.activationKeyValue = fileArr[1]
this.licenceKeyValue = fileArr[0]
}
await this.refreshKeyPreview()
const fileArr = evt.target.result.toString().split('\n')
this.activationKeyValue = fileArr[1]
this.licenceKeyValue = fileArr[0]
}
reader.readAsText(file)
@@ -314,8 +205,4 @@ export class LicensingComponent implements OnInit {
!!(window.crypto && window.crypto.subtle)
)
}
get enabledFeatures(): string[] {
return getEnabledFeatureLabels(this.licenseKeyData?.features)
}
}
@@ -1,90 +0,0 @@
import * as base64Converter from 'base64-arraybuffer'
import {
COMBINED_KEY_PREFIX,
isCombinedLicenceKey,
splitCombinedLicenceKey
} from './combinedLicenceKey'
// Mirrors dckey's own encodeCombinedKey() (main.js) exactly, so these tests
// build fixtures the same way a real generated key would be produced,
// rather than relying on a hardcoded string that could go stale if either
// side's algorithm ever changes.
const gzipCompress = async (bytes: Uint8Array): Promise<ArrayBuffer> => {
const compressionStream = new CompressionStream('gzip')
const writer = compressionStream.writable.getWriter()
writer.write(new Uint8Array(bytes))
writer.close()
return new Response(compressionStream.readable).arrayBuffer()
}
const encodeCombinedLicenceKeyFixture = async (
licenceKey: string,
activationKey: string
): Promise<string> => {
const payloadBytes = new TextEncoder().encode(
`${licenceKey} ${activationKey}`
)
const compressedBytes = await gzipCompress(payloadBytes)
return COMBINED_KEY_PREFIX + base64Converter.encode(compressedBytes)
}
describe('isCombinedLicenceKey', () => {
it('is true for prefixed text', () => {
expect(isCombinedLicenceKey('DCKEY1:abc123')).toBeTrue()
})
it('is false for a plain legacy key', () => {
expect(isCombinedLicenceKey('some-legacy-licence-key')).toBeFalse()
})
it('is false for an empty string', () => {
expect(isCombinedLicenceKey('')).toBeFalse()
})
it('is true even with leading/trailing whitespace around the prefix', () => {
expect(isCombinedLicenceKey(' DCKEY1:abc123 ')).toBeTrue()
})
})
describe('splitCombinedLicenceKey', () => {
it('round-trips a fixture built the same way dckey encodes one', async () => {
const combined = await encodeCombinedLicenceKeyFixture(
'licence-key-value',
'activation-key-value'
)
expect(await splitCombinedLicenceKey(combined)).toEqual({
licenceKey: 'licence-key-value',
activationKey: 'activation-key-value'
})
})
it('returns null for text without the prefix', async () => {
expect(
await splitCombinedLicenceKey('a-plain-legacy-licence-key')
).toBeNull()
})
it('rejects for prefixed text that is not valid compressed data', async () => {
await expectAsync(
splitCombinedLicenceKey('DCKEY1:not-valid-base64-gzip-data')
).toBeRejected()
})
it('splits on the first space only, not every space', async () => {
// Not a real-world value today (licence/activation keys are base64,
// which never contains a space) - guards the split logic itself rather
// than that assumption.
const combined = await encodeCombinedLicenceKeyFixture(
'licence key with spaces',
'activation-key-value'
)
expect(await splitCombinedLicenceKey(combined)).toEqual({
licenceKey: 'licence',
activationKey: 'key with spaces activation-key-value'
})
})
})
@@ -1,59 +0,0 @@
import * as base64Converter from 'base64-arraybuffer'
/**
* Format produced by dckey's own encodeCombinedKey() (main.js):
* "DCKEY1:" + base64(gzip(licenceKey + " " + activationKey)). The prefix is
* plain ASCII, not itself compressed/encoded - base64's alphabet never
* contains ":", so it can never collide with the start of a legacy licence
* key or activation key, making format detection unambiguous.
*/
export const COMBINED_KEY_PREFIX = 'DCKEY1:'
export const isCombinedLicenceKey = (text: string): boolean =>
text.trim().startsWith(COMBINED_KEY_PREFIX)
const gzipDecompress = async (bytes: ArrayBuffer): Promise<ArrayBuffer> => {
const decompressionStream = new DecompressionStream('gzip')
const writer = decompressionStream.writable.getWriter()
// Awaited (unlike a fire-and-forget write) so invalid gzip data rejects
// through this function's own returned promise, not as a separate
// unhandled rejection racing the readable side below.
const result = await Promise.all([
writer.write(new Uint8Array(bytes)).then(() => writer.close()),
new Response(decompressionStream.readable).arrayBuffer()
])
return result[1]
}
/**
* Splits a combined licence key back into its two parts. Returns null (not
* a rejected promise) when text isn't a combined key at all, so callers can
* use it as a "try this, then fall back to the legacy two-field input" check
* without a try/catch for that common case - a prefixed-but-corrupted
* string still rejects, since that's a real error, not a format mismatch.
*/
export const splitCombinedLicenceKey = async (
text: string
): Promise<{ licenceKey: string; activationKey: string } | null> => {
const trimmed = text.trim()
if (!isCombinedLicenceKey(trimmed)) return null
const compressedBytes = base64Converter.decode(
trimmed.slice(COMBINED_KEY_PREFIX.length)
)
const decompressedBytes = await gzipDecompress(compressedBytes)
const payload = new TextDecoder().decode(decompressedBytes)
const separatorIndex = payload.indexOf(' ')
if (separatorIndex === -1) {
throw new Error('Invalid combined licence key: missing separator')
}
return {
licenceKey: payload.slice(0, separatorIndex),
activationKey: payload.slice(separatorIndex + 1)
}
}
@@ -11,6 +11,10 @@ export type LicenceKeyProtocolMismatch = 'requiresHttps' | 'requiresHttp' | null
* alone tells us which format a pasted key is, without needing to attempt
* decryption first.
*
* This is a structural assumption about key-generation logic
* (a separate repo) - if that ever changes to generate distinct values for
* HTTP-format keys too, this detection would silently misclassify keys.
*
* isSecureContext must reflect whether this browsing context can actually
* decrypt a secure-connection key (mirrors the check licence.service.ts's
* own decryptLicenseKey() makes) - not simply `location.protocol ===
@@ -1,73 +0,0 @@
import { getEnabledFeatureLabels } from './getEnabledFeatureLabels'
describe('getEnabledFeatureLabels', () => {
it('returns an empty list when features is undefined', () => {
expect(getEnabledFeatureLabels(undefined)).toEqual([])
})
it('lists only the enabled toggle features from a new-format object', () => {
expect(
getEnabledFeatureLabels({
vra: null,
era: null,
sra: null,
hra: null,
srl: null,
till: null,
vb: true,
vbl: null,
ldl: null,
fu: false,
er: true,
ar: false
})
).toEqual(['Viewbox', 'Edit Record'])
})
it('lists all four when every toggle is enabled', () => {
expect(
getEnabledFeatureLabels({
vra: null,
era: null,
sra: null,
hra: null,
srl: null,
till: null,
vb: true,
vbl: null,
ldl: null,
fu: true,
er: true,
ar: true
})
).toEqual(['Viewbox', 'File Upload', 'Edit Record', 'Add Record'])
})
it('returns an empty list when every toggle is disabled', () => {
expect(
getEnabledFeatureLabels({
vra: null,
era: null,
sra: null,
hra: null,
srl: null,
till: null,
vb: false,
vbl: null,
ldl: null,
fu: false,
er: false,
ar: false
})
).toEqual([])
})
it('decodes a legacy positional string the same way', () => {
// viewbox=1 (position 6), fileUpload=0 (position 9), editRecord=1
// (position 10), addRecord=0 (position 11) - see LicenceFeaturesMap.
expect(getEnabledFeatureLabels('-,-,-,-,-,-,1,-,-,0,1,0')).toEqual([
'Viewbox',
'Edit Record'
])
})
})
@@ -1,24 +0,0 @@
import {
decodeLicenceFeatures,
LicenceFeaturesObject
} from '../../services/utils/decodeLicenceFeatures'
import { LicenceState } from '../../models/LicenceState'
const TOGGLE_FEATURE_LABELS: { key: keyof LicenceState; label: string }[] = [
{ key: 'viewbox', label: 'Viewbox' },
{ key: 'fileUpload', label: 'File Upload' },
{ key: 'editRecord', label: 'Edit Record' },
{ key: 'addRecord', label: 'Add Record' }
]
export const getEnabledFeatureLabels = (
features: string | LicenceFeaturesObject | undefined
): string[] => {
if (!features) return []
const decoded = decodeLicenceFeatures(features)
return TOGGLE_FEATURE_LABELS.filter(({ key }) => decoded[key]).map(
({ label }) => label
)
}
-5
View File
@@ -15,11 +15,6 @@ export interface LicenceState {
}
/**
* Legacy-format decode table only - newly-issued keys use a named
* LicenceFeaturesObject instead (see decodeLicenceFeatures.ts). Kept
* unchanged and permanently, since every already-issued key still relies
* on these ordinal positions to decode.
*
* '-' means unset
* '0' disabled
* '1' enabled
+1 -3
View File
@@ -1,5 +1,3 @@
import { LicenceFeaturesObject } from '../services/utils/decodeLicenceFeatures'
export interface LicenseKeyData {
valid_until: string
users_allowed: number
@@ -7,5 +5,5 @@ export interface LicenseKeyData {
site_id_multiple: string[]
demo: boolean
hot_license_key: string | undefined
features?: string | LicenceFeaturesObject
features?: string
}
+61 -11
View File
@@ -10,8 +10,7 @@ import { Globvar } from '../models/sas/public-startupservice.model'
import { freeTierConfig } from '../free-tier.config'
import { AppStoreService } from './app-store.service'
import { HelperService } from './helper.service'
import { LicenceState } from '../models/LicenceState'
import { decodeLicenceFeatures as decodeFeatures } from './utils/decodeLicenceFeatures'
import { LicenceFeaturesMap, LicenceState } from '../models/LicenceState'
import { LoggerService } from './logger.service'
import { EventService } from './event.service'
@@ -256,9 +255,9 @@ export class LicenceService {
}
/**
* Decode and set features that are encoded in the key - accepts either
* an already-issued key's legacy positional string or a newly-issued
* key's named object, see decodeLicenceFeatures for the format contract.
* Decode and set features that are encoded in the key
* featureValue - used as number/Infinity for limit set
* featureToggle - used as boolean, based on 1 or 0 encoded in the key
* @param licenseData from the licence key
*/
private decodeLicenceFeatures(licenseData: LicenseKeyData) {
@@ -271,21 +270,72 @@ export class LicenceService {
}
}
const featuresMap = licenseData.features.split(',')
this._licenceState = {
...this._licenceState,
...decodeFeatures(licenseData.features)
viewer_rows_allowed: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.viewer_rows_allowed]
),
editor_rows_allowed: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.editor_rows_allowed]
),
stage_rows_allowed: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.stage_rows_allowed]
),
history_rows_allowed: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.history_rows_allowed]
),
submit_rows_limit: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.submit_rows_limit]
),
tables_in_library_limit: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.tables_in_library_limit]
),
viewbox_limit: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.viewbox_limit]
),
lineage_daily_limit: this.parseFeatureValue(
featuresMap[LicenceFeaturesMap.lineage_daily_limit]
),
viewbox: this.parseFeatureToggle(featuresMap[LicenceFeaturesMap.viewbox]),
fileUpload: this.parseFeatureToggle(
featuresMap[LicenceFeaturesMap.fileUpload]
),
editRecord: this.parseFeatureToggle(
featuresMap[LicenceFeaturesMap.editRecord]
),
addRecord: this.parseFeatureToggle(
featuresMap[LicenceFeaturesMap.addRecord]
)
}
this.loggerService.log('Licence state:', this._licenceState)
}
/**
* If decryption fails, key will be marked as invalid and app not activated.
* Public (not just used internally by licensing()) since it's a pure
* decrypt with no side effects - also used to preview a pasted key's
* details before it's actually applied.
* Converts licence key feature code value to the number or Infinity
* Used for limiting rows, submits etc.
* @param codeBit from licence key encoded features. Every bit is separated by comma(,) Eg. 5,10,15
* @returns number
*/
public decryptLicenseKey(
private parseFeatureValue(codeBit: string): number {
if (codeBit === '-') return Infinity
return parseInt(codeBit)
}
/**
* Converts licence key feature code value to the boolean. Depending on if it's 0 or 1
* @param codeBit from licence key encoded features. Every bit is separated by comma(,) Eg. 1,1,0
* @returns boolean to turn on or off the value
*/
private parseFeatureToggle(codeBit: string): boolean {
return !!parseInt(codeBit)
}
/**
* If decryption fails, key will be marked as invalid and app not activated.
*/
private decryptLicenseKey(
licenseKey: string,
activationKey: string
): Promise<LicenseKeyData> {
@@ -1,79 +0,0 @@
import { decodeLicenceFeatures } from './decodeLicenceFeatures'
describe('decodeLicenceFeatures', () => {
// Positional order per LicenceFeaturesMap: viewer_rows_allowed,
// editor_rows_allowed, stage_rows_allowed, history_rows_allowed,
// submit_rows_limit, tables_in_library_limit, viewbox, viewbox_limit,
// lineage_daily_limit, fileUpload, editRecord, addRecord.
const legacyFeaturesString = '10,15,20,-,5,35,1,3,7,1,0,1'
const newFeaturesObject = {
vra: 10,
era: 15,
sra: 20,
hra: null,
srl: 5,
till: 35,
vb: true,
vbl: 3,
ldl: 7,
fu: true,
er: false,
ar: true
}
const expectedDecodedState = {
viewer_rows_allowed: 10,
editor_rows_allowed: 15,
stage_rows_allowed: 20,
history_rows_allowed: Infinity,
submit_rows_limit: 5,
tables_in_library_limit: 35,
viewbox: true,
viewbox_limit: 3,
lineage_daily_limit: 7,
fileUpload: true,
editRecord: false,
addRecord: true
}
it('decodes a legacy positional string exactly as before', () => {
expect(decodeLicenceFeatures(legacyFeaturesString)).toEqual(
expectedDecodedState
)
})
it('decodes a new named object', () => {
expect(decodeLicenceFeatures(newFeaturesObject)).toEqual(
expectedDecodedState
)
})
it('produces an identical result for the same licence expressed in either format', () => {
expect(decodeLicenceFeatures(legacyFeaturesString)).toEqual(
decodeLicenceFeatures(newFeaturesObject)
)
})
it('maps "-" in a legacy limit position to Infinity', () => {
const result = decodeLicenceFeatures(legacyFeaturesString)
expect(result.history_rows_allowed).toBe(Infinity)
})
it('maps null in a new-format limit field to Infinity', () => {
const result = decodeLicenceFeatures(newFeaturesObject)
expect(result.history_rows_allowed).toBe(Infinity)
})
it('maps "1"/"0" in legacy toggle positions to true/false', () => {
const result = decodeLicenceFeatures(legacyFeaturesString)
expect(result.viewbox).toBe(true)
expect(result.editRecord).toBe(false)
})
it('passes new-format boolean toggle fields straight through', () => {
const result = decodeLicenceFeatures(newFeaturesObject)
expect(result.viewbox).toBe(true)
expect(result.editRecord).toBe(false)
})
})
@@ -1,91 +0,0 @@
import { LicenceFeaturesMap, LicenceState } from '../../models/LicenceState'
// Field names are abbreviated on the wire to keep the RSA-OAEP-encrypted
// payload small - see "Why abbreviated field names" in
// licence-features-object-plan.md for the full name each one stands for
// and why this is a fixed, append-only contract (same invariant as
// LicenceFeaturesMap's ordinals).
export type LicenceFeaturesObject = {
vra: number | null // viewer_rows_allowed
era: number | null // editor_rows_allowed
sra: number | null // stage_rows_allowed
hra: number | null // history_rows_allowed
srl: number | null // submit_rows_limit
till: number | null // tables_in_library_limit
vb: boolean // viewbox
vbl: number | null // viewbox_limit
ldl: number | null // lineage_daily_limit
fu: boolean // fileUpload
er: boolean // editRecord
ar: boolean // addRecord
}
// Accepts either an already-issued key's positional string
// ("-,-,-,-,-,-,1,-,-,1,1,1", decoded via LicenceFeaturesMap) or a
// newly-issued key's named object - see licence-features-object-plan.md
// for why these two shapes are how format versioning is done here (no
// separate version field: the shapes are already distinguishable).
export const decodeLicenceFeatures = (
features: string | LicenceFeaturesObject
): Partial<LicenceState> => {
if (typeof features === 'object') {
return decodeFeaturesObject(features)
}
return decodeFeaturesString(features)
}
const decodeFeaturesObject = (
features: LicenceFeaturesObject
): Partial<LicenceState> => ({
viewer_rows_allowed: features.vra ?? Infinity,
editor_rows_allowed: features.era ?? Infinity,
stage_rows_allowed: features.sra ?? Infinity,
history_rows_allowed: features.hra ?? Infinity,
submit_rows_limit: features.srl ?? Infinity,
tables_in_library_limit: features.till ?? Infinity,
viewbox: features.vb,
viewbox_limit: features.vbl ?? Infinity,
lineage_daily_limit: features.ldl ?? Infinity,
fileUpload: features.fu,
editRecord: features.er,
addRecord: features.ar
})
// Existing positional-string decode, moved here unchanged from
// LicenceService.decodeLicenceFeatures/parseFeatureValue/parseFeatureToggle.
const decodeFeaturesString = (features: string): Partial<LicenceState> => {
const featuresMap = features.split(',')
const parseValue = (codeBit: string) =>
codeBit === '-' ? Infinity : parseInt(codeBit)
const parseToggle = (codeBit: string) => !!parseInt(codeBit)
return {
viewer_rows_allowed: parseValue(
featuresMap[LicenceFeaturesMap.viewer_rows_allowed]
),
editor_rows_allowed: parseValue(
featuresMap[LicenceFeaturesMap.editor_rows_allowed]
),
stage_rows_allowed: parseValue(
featuresMap[LicenceFeaturesMap.stage_rows_allowed]
),
history_rows_allowed: parseValue(
featuresMap[LicenceFeaturesMap.history_rows_allowed]
),
submit_rows_limit: parseValue(
featuresMap[LicenceFeaturesMap.submit_rows_limit]
),
tables_in_library_limit: parseValue(
featuresMap[LicenceFeaturesMap.tables_in_library_limit]
),
viewbox_limit: parseValue(featuresMap[LicenceFeaturesMap.viewbox_limit]),
lineage_daily_limit: parseValue(
featuresMap[LicenceFeaturesMap.lineage_daily_limit]
),
viewbox: parseToggle(featuresMap[LicenceFeaturesMap.viewbox]),
fileUpload: parseToggle(featuresMap[LicenceFeaturesMap.fileUpload]),
editRecord: parseToggle(featuresMap[LicenceFeaturesMap.editRecord]),
addRecord: parseToggle(featuresMap[LicenceFeaturesMap.addRecord])
}
}
@@ -56,6 +56,21 @@ describe('parseFormulaRule', () => {
).toEqual('=\"sasinstaller\"')
})
it('escapes embedded double quotes in DC.USER_NAME so the resulting literal stays valid', () => {
expect(
parseFormulaRule('=DC.USER_NAME', context({ userName: 'john"doe' }))
).toEqual('="john""doe"')
})
it('escapes embedded double quotes in DC.ORIG_VALUE so the resulting literal stays valid', () => {
expect(
parseFormulaRule(
'=DC.ORIG_VALUE',
context({ origValue: 'a "quoted" value' })
)
).toEqual('="a ""quoted"" value"')
})
it('does not substitute DC.USER_NAME/DC.ORIG_VALUE inside quoted strings either', () => {
expect(parseFormulaRule('="DC.USER_NAME"', context())).toEqual(
'=\"DC.USER_NAME\"'
@@ -12,7 +12,7 @@ export interface FormulaVariableContext {
origValue: string | number | undefined
}
const escapeRegExpMetacharacters = (text: string): string =>
export const escapeRegExpMetacharacters = (text: string): string =>
text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
/**
@@ -22,7 +22,7 @@ const escapeRegExpMetacharacters = (text: string): string =>
* surrounding blanks) does not, without needing to know anything about
* function names.
*/
const substituteBoundedToken = (
export const substituteBoundedToken = (
text: string,
token: string,
replacement: string
@@ -36,7 +36,7 @@ const substituteBoundedToken = (
}
const quoteLiteral = (value: string | number | undefined): string =>
`"${value ?? ''}"`
`"${String(value ?? '').replace(/"/g, '""')}"`
export const parseFormulaRule = (
ruleValue: string,
@@ -0,0 +1,15 @@
import { resolveRevertedCellValue } from './resolveRevertedCellValue'
describe('resolveRevertedCellValue', () => {
it('returns the raw text unchanged for a non-numeric column', () => {
expect(resolveRevertedCellValue('sasdemo', false)).toEqual('sasdemo')
})
it('converts a valid numeric string to a number for a numeric column', () => {
expect(resolveRevertedCellValue('42.5', true)).toEqual(42.5)
})
it("falls back to the raw text instead of NaN when a numeric column's stored value is not a valid number", () => {
expect(resolveRevertedCellValue('.S', true)).toEqual('.S')
})
})
@@ -0,0 +1,16 @@
// A numeric column's original value is stored as plain text in a
// Handsontable comment - if it isn't a valid number (e.g. a SAS special
// missing like ".S"), Number() returns NaN, which would silently corrupt
// the revert instead of restoring the original value. Falling back to the
// raw text keeps the revert visibly correct (the original text) rather
// than writing NaN into the cell.
export const resolveRevertedCellValue = (
rawValueText: string,
isNumericCol: boolean
): string | number => {
if (!isNumericCol) return rawValueText
const numericValue = Number(rawValueText)
return Number.isNaN(numericValue) ? rawValueText : numericValue
}
@@ -0,0 +1,57 @@
import { substituteColumnReferences } from './substituteColumnReferences'
describe('substituteColumnReferences', () => {
const columnNames = ['ITEM', 'PRICE', 'VOLUME', 'REVENUE']
it("substitutes column-name variables with this row's cell references (the issue's own example)", () => {
expect(
substituteColumnReferences(
'=SOME_NUM * SOME_BESTNUM',
['ID', 'SOME_NUM', 'SOME_BESTNUM'],
3
)
).toEqual('=B4 * C4')
})
it('uses the row-relative reference for a later row', () => {
expect(
substituteColumnReferences('=PRICE * VOLUME', columnNames, 1)
).toEqual('=B2 * C2')
})
it('requires a leading/trailing blank (or string edge) around a variable - no match without it', () => {
expect(substituteColumnReferences('=PRICE*VOLUME', columnNames, 0)).toEqual(
'=PRICE*VOLUME'
)
})
it('does not substitute a variable name matched inside a function call with no surrounding blanks', () => {
expect(substituteColumnReferences('=MATCH(PRICE)', columnNames, 0)).toEqual(
'=MATCH(PRICE)'
)
})
it('leaves variable occurrences inside quoted strings untouched', () => {
expect(
substituteColumnReferences('=ITEM & " string ITEM "', columnNames, 0)
).toEqual('=A1 & " string ITEM "')
})
it('does not insert a leading = when the input does not have one', () => {
expect(
substituteColumnReferences('PRICE * VOLUME', columnNames, 0)
).toEqual('B1 * C1')
})
it('does not perform any DC.* variable substitution - only column names', () => {
expect(substituteColumnReferences('=DC.USER_NAME', columnNames, 0)).toEqual(
'=DC.USER_NAME'
)
})
it('leaves a plain (non-formula) pasted value pass through unchanged aside from column tokens it happens to contain', () => {
expect(
substituteColumnReferences('just a note about PRICE', columnNames, 0)
).toEqual('just a note about B1')
})
})
@@ -0,0 +1,42 @@
import Handsontable from 'handsontable'
import { substituteBoundedToken } from './parseFormulaRule'
/**
* Translates column names in a formula-like string into this row's cell
* references, e.g. "=PRICE * VOLUME" -> "=B4 * C4" for row index 3.
* Column-name substitution only - no DC.* variable support (that's
* specific to admin-defined rule values re-evaluated per row - see
* parseFormulaRule.ts, which this deliberately does not delegate to, to
* keep this scoped to exactly column names).
*/
export const substituteColumnReferences = (
formulaText: string,
columnNames: string[],
rowIndex: number
): string => {
const hasLeadingEquals = formulaText.startsWith('=')
const formulaBody = hasLeadingEquals ? formulaText.slice(1) : formulaText
const quotedSpanPattern = /"[^"]*"|'[^']*'/g
let result = ''
let lastIndex = 0
let match: RegExpExecArray | null
const substituteUnquotedSpan = (span: string): string => {
let substituted = span
columnNames.forEach((columnName, columnIndex) => {
const cellRef = `${Handsontable.helper.spreadsheetColumnLabel(columnIndex)}${rowIndex + 1}`
substituted = substituteBoundedToken(substituted, columnName, cellRef)
})
return substituted
}
while ((match = quotedSpanPattern.exec(formulaBody))) {
result += substituteUnquotedSpan(formulaBody.slice(lastIndex, match.index))
result += match[0]
lastIndex = match.index + match[0].length
}
result += substituteUnquotedSpan(formulaBody.slice(lastIndex))
return hasLeadingEquals ? `=${result}` : result
}
+1 -2
View File
@@ -2544,8 +2544,7 @@ app-licensing {
}
.license-key-form,
.activation-key-form,
.combined-key-form {
.activation-key-form {
padding: 0;
.clr-control-container {
+4 -4
View File
@@ -7,7 +7,7 @@
"name": "dc-sas",
"dependencies": {
"@sasjs/cli": "4.18.5",
"@sasjs/core": "4.68.3"
"@sasjs/core": "5.0.0"
}
},
"node_modules/@asamuzakjp/css-color": {
@@ -251,9 +251,9 @@
"license": "MIT"
},
"node_modules/@sasjs/core": {
"version": "4.68.3",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-4.68.3.tgz",
"integrity": "sha512-2xBZyp8XXnZqR00fg0khsGIYucv4UKUOYWtyzb6mPWfI4mrtJLv6uox5aelVB3h9X/4pd7ZqafDg/aXK+6ylAg==",
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@sasjs/core/-/core-5.0.0.tgz",
"integrity": "sha512-TUOJhA80dh3Bo6hTWogAbOi8EdzPoEWrOG6keIpbcEd3cbY569Fpa4W67zotLPqiHtJy2lEHkkJkc3u7MfSvTg==",
"license": "MIT"
},
"node_modules/@sasjs/lint": {
+1 -1
View File
@@ -29,7 +29,7 @@
"private": true,
"dependencies": {
"@sasjs/cli": "4.18.5",
"@sasjs/core": "4.68.3"
"@sasjs/core": "5.0.0"
},
"overrides": {
"nanoid": "3.3.18"
@@ -1,6 +1,6 @@
/**
@file
@brief migration script
@brief migration script
**/
+4 -4
View File
@@ -323,8 +323,8 @@ select name into: cols separated by ','
,"&tech_from","&tech_to"
,"&processed","&delete_col")) ;
/* Character variables are hashed separately from numerics so that the
iterative hash can be built with arrays rather than by concatenating
hundreds of 32-byte hex strings. */
iterative hash can be built with arrays rather than by concatenating
hundreds of 32-byte hex strings. */
select name into: hash_char_vars separated by ' '
from work.bitemp_cols
where type in (2,6)
@@ -1077,8 +1077,8 @@ run;
%if &loadtype=BITEMPORAL %then %do;
/* For bitemporal we also include the business dates in the comparison hash.
The business dates are passed as prefix numerics so that both lookup and
update tables use exactly the same hashing order. */
The business dates are passed as prefix numerics so that both lookup and
update tables use exactly the same hashing order. */
data work.bitemp5a_lkp (keep=&md5_col)
%if "%substr(&sysver,1,1)" ne "4" & "%substr(&sysver,1,1)" ne "5" %then %do;
+2 -2
View File
@@ -8,7 +8,7 @@
@li mpe_checkrestore.sas
@li mp_assert.sas
@li mp_assertscope.sas
@li mp_testservice.sas
@li mp_execute.sas
@author 4GL Apps Ltd
@@ -19,7 +19,7 @@
**/
/* first, run a data update */
%mp_testservice(&appLoc/tests/services/auditors/postdata.test.1,
%mp_execute(&appLoc/tests/services/auditors/postdata.test.1,
viyacontext=&defaultcontext
)
@@ -4,11 +4,13 @@
@details Checking functionality of mpe_getversions.sas macro
<h4> SAS Macros </h4>
@li mf_getuniquefileref.sas
@li mf_nobs.sas
@li mp_assert.sas
@li mp_assertscope.sas
@li mpe_getversions.sas
@li mpe_targetloader.sas
@li mx_testservice.sas
**/
+3 -2
View File
@@ -10,7 +10,7 @@
<h4> SAS Macros </h4>
@li mf_getuser.sas
@li mp_abort.sas
@li mp_getddl.sas
@li mp_ds2ddl.sas
@li mp_lib2inserts.sas
@li mp_streamfile.sas
@li mpe_getgroups.sas
@@ -51,10 +51,11 @@ select count(*) into:cnt
,msg=%str(The &DC_LIBREF library can only be exported by &mpeadmins members)
)
%mp_getddl(&DC_LIBREF
%mp_ds2ddl(&DC_LIBREF
,flavour=&flavour
,schema=&schema
,applydttm=YES
,showlog=NO
,fref=tmpref
)
@@ -0,0 +1,74 @@
/**
@file
@brief testing exportdb service
<h4> SAS Macros </h4>
@li mp_assertdsobs.sas
@li mx_testservice.sas
**/
%let _program=&appLoc/services/admin/exportdb;
/* test 1 - PGSQL flavour */
data work.params;
length name $32 value $1000;
name='flavour';value='PGSQL';output;
name='schema';value='public';output;
run;
%mx_testservice(&_program,
viyacontext=&defaultcontext,
inputparams=work.params,
outref=webout,
viyaresult=WEBOUT_TXT,
mdebug=&sasjs_mdebug
)
data work.results;
infile webout;
input;
putlog _infile_;
if index(upcase(_infile_),'CREATE TABLE') then do;
putlog 'test passed';
output;
stop;
end;
run;
%mp_assertdsobs(work.results,
desc=PGSQL flavour DDL file is successfully returned,
test=EQUALS 1,
outds=work.test_results
)
/* test 2 - default (SAS) flavour with inserts */
data work.params;
length name $32 value $1000;
name='flavour';value='';output;
name='schema';value='';output;
run;
%mx_testservice(&_program,
viyacontext=&defaultcontext,
inputparams=work.params,
outref=web2,
viyaresult=WEBOUT_TXT,
mdebug=&sasjs_mdebug
)
data work.results2;
infile web2;
input;
putlog _infile_;
if index(upcase(_infile_),'INSERT INTO') then do;
putlog 'test passed';
output;
stop;
end;
run;
%mp_assertdsobs(work.results2,
desc=SAS flavour DDL file with inserts is successfully returned,
test=EQUALS 1,
outds=work.test_results
)
@@ -0,0 +1,58 @@
/**
@file
@brief testing refreshcatalog service
<h4> SAS Macros </h4>
@li mp_assert.sas
@li mp_assertdsobs.sas
@li mx_testservice.sas
**/
%let _program=&appLoc/services/admin/refreshcatalog;
/* test 1 - refresh a specific libref */
data work.params;
length name $32 value $1000;
name='libref';value='DCTEST';output;
run;
%mx_testservice(&_program,
viyacontext=&defaultcontext,
inputparams=work.params,
outref=webout,
viyaresult=WEBOUT_TXT,
mdebug=&sasjs_mdebug
)
data work.results;
infile webout;
input;
putlog _infile_;
if index(upcase(_infile_),'CATALOG REFRESH COMPLETE') then do;
putlog 'test passed';
output;
stop;
end;
run;
%mp_assertdsobs(work.results,
desc=Refresh catalog confirmation message is returned,
test=EQUALS 1,
outds=work.test_results
)
/* test 2 - verify the catalog was actually refreshed */
proc sql noprint;
create table work.test2 as
select *
from &dc_libref..mpe_datacatalog_tabs(where=(&dc_dttmtfmt. lt tx_to))
where libref="DCTEST";
%let test2=0;
select count(*) into: test2 from work.test2;
%mp_assert(
iftrue=(&test2>0),
desc=DCTEST tables were catalogued by the refresh,
outds=work.test_results
)
@@ -3,13 +3,14 @@
@brief testing gethistory service
<h4> SAS Macros </h4>
@li mp_execute.sas
@li mp_assertdsobs.sas
**/
%let _program=&appLoc/services/approvers/getapprovals;
%mp_testservice(&_program,
%mp_execute(&_program,
viyacontext=&defaultcontext,
outlib=webout
)
+6 -6
View File
@@ -121,14 +121,14 @@ create view work.submits as
;
/* get latest reason text */
create table work.reviews as
select a.*
create table work.reviews as
select a.*
,b.reviewed_on_dttm
,b.reviewed_by_nm as approver
,b.review_reason_txt
from work.submits a
left join &mpelib..mpe_review b
on a.table_id=b.table_id
,b.review_reason_txt
from work.submits a
left join &mpelib..mpe_review b
on a.table_id=b.table_id
order by a.table_id desc, b.reviewed_on_dttm desc;
%mp_abort(iftrue= (&syscc > 0)
@@ -3,6 +3,7 @@
@brief testing gethistory service
<h4> SAS Macros </h4>
@li mp_execute.sas
@li mp_assertdsobs.sas
**/
@@ -17,7 +18,7 @@ data _null_;
put '50';
run;
%mp_testservice(&_program,
%mp_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=fref1:BrowserParams ,
outlib=webout,
@@ -42,6 +42,10 @@ data _null_;
call symputx('base_lib',base_lib);
call symputx('base_ds',base_ds);
run;
%mp_abort(iftrue= (&base_lib= or &base_ds=)
,mac=&_program..sas
,msg=%str(No mpe_submit record found for table_id=&table_id)
)
%dc_assignlib(READ,&base_lib)
%mp_getcols(&base_lib..&base_ds,outds=work.basecols)
+3
View File
@@ -706,6 +706,9 @@ run;
%postdata()
/* mp_abort cannot exit cleanly from inside a %include, so an abort there
(eg in a hook script run via mp_include) only records the error in
work.mp_abort_errds. mode=INCLUDE picks that up and aborts properly here. */
%mp_abort(mode=INCLUDE)
%mp_abort(iftrue= (&is_err=1)
@@ -3,7 +3,7 @@
@brief testing postdata with format table
<h4> SAS Macros </h4>
@li mp_testservice.sas
@li mp_execute.sas
@li mp_assert.sas
@@ -31,7 +31,7 @@ data work.jsdata;
if _n_>20 then stop;
run;
%mp_testservice(&_program,
%mp_execute(&_program,
viyacontext=&defaultcontext,
inputdatasets=work.sascontroltable work.jsdata,
outlib=web1,
@@ -61,7 +61,7 @@ data work.sascontroltable;
output;
stop;
run;
%mp_testservice(&appLoc/services/auditors/postdata,
%mp_execute(&appLoc/services/auditors/postdata,
viyacontext=&defaultcontext,
inputdatasets=work.sascontroltable,
outlib=web2,
+3
View File
@@ -466,6 +466,9 @@ select upcase(loadtype)
%mpestp_getdata()
/* mp_abort cannot exit cleanly from inside a %include, so an abort there
(eg in a hook script run via mp_include) only records the error in
work.mp_abort_errds. mode=INCLUDE picks that up and aborts properly here. */
%mp_abort(mode=INCLUDE)
/* extract column level security rules */
@@ -165,6 +165,9 @@ run;
/* execute the dynamic code */
%mp_include(sascode)
/* mp_abort cannot exit cleanly from inside a %include, so an abort there
(eg in a hook script run via mp_include) only records the error in
work.mp_abort_errds. mode=INCLUDE picks that up and aborts properly here. */
%mp_abort(mode=INCLUDE)
/* ensure that the DISPLAY_INDEX variable exists. */
+3
View File
@@ -272,6 +272,9 @@ options mprint;
,termstr=CRLF
,dc_dttmtfmt=&dc_dttmtfmt
)
/* mp_abort cannot exit cleanly from inside a %include, so an abort there
(eg in a hook script run via mp_include) only records the error in
work.mp_abort_errds. mode=INCLUDE picks that up and aborts properly here. */
%mp_abort(mode=INCLUDE)
%mp_abort(
+3 -6
View File
@@ -154,14 +154,11 @@ run;
,submitted_reason_txt=Restoring &loadref
,dc_dttmtfmt=&dc_dttmtfmt
)
/* mp_abort cannot exit cleanly from inside a %include, so an abort there
(eg in a hook script run via mp_include) only records the error in
work.mp_abort_errds. mode=INCLUDE picks that up and aborts properly here. */
%mp_abort(mode=INCLUDE)
%mp_abort(
iftrue=(%sysfunc(fileexist(%sysfunc(pathname(work))/mf_abort.error))=1)
,mac=&_program..sas
,msg=%str(mf_abort.error=1)
)
%mp_abort(iftrue= (&syscc ne 0)
,mac=&_program..sas
,msg=%str(syscc=&syscc)
+3 -6
View File
@@ -245,14 +245,11 @@ run;
,url=%superq(url)
,dc_dttmtfmt=&dc_dttmtfmt
)
/* mp_abort cannot exit cleanly from inside a %include, so an abort there
(eg in a hook script run via mp_include) only records the error in
work.mp_abort_errds. mode=INCLUDE picks that up and aborts properly here. */
%mp_abort(mode=INCLUDE)
%mp_abort(
iftrue=(%sysfunc(fileexist(%sysfunc(pathname(work))/mf_abort.error))=1)
,mac=&_program..sas
,msg=%str(mf_abort.error=1)
)
%mp_abort(iftrue= (&syscc ne 0)
,mac=&_program..sas
,msg=%str(syscc=&syscc)
@@ -3,6 +3,7 @@
@brief testing getchangeinfo service
<h4> SAS Macros </h4>
@li mp_execute.sas
@li mp_assert.sas
@li mp_assertcolvals.sas
@li mf_getuniquefileref.sas
@@ -72,7 +73,7 @@ data _null_;
put 'TABLE:$43.';
put "&dsid";
run;
%mp_testservice(&_program,
%mp_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f3:sascontroltable,
outlib=web3,
@@ -4,6 +4,7 @@
<h4> SAS Macros </h4>
@li mp_assertcolvals.sas
@li mp_execute.sas
@li mf_getuniquefileref.sas
**/
@@ -16,7 +17,7 @@ data _null_;
put 'LIBDS:$43. COL:$32.';
put "&dclib..MPE_X_TEST,SOME_TIME";
run;
%mp_testservice(&_program,
%mp_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f3:iwant,
outlib=web3
@@ -4,6 +4,7 @@
<h4> SAS Macros </h4>
@li mp_assertcolvals.sas
@li mp_execute.sas
@li mf_getuniquefileref.sas
**/
@@ -34,7 +35,7 @@ AND,OR,2,DSN,=,"'MPE_LOCK_ANYTABLE'"
AND,OR,2,DSN,=,"'MPE_X_TEST'"
;;;;
run;
%mp_testservice(&_program,
%mp_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f1:iwant &f2:filterquery,
outlib=web2
@@ -4,6 +4,7 @@
<h4> SAS Macros </h4>
@li mp_assertcolvals.sas
@li mp_execute.sas
@li mf_getuniquefileref.sas
**/
@@ -30,7 +31,7 @@ datalines4;
AND,OR,1,FMTNAME,CONTAINS,"'MOR'"
;;;;
run;
%mp_testservice(&_program,
%mp_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f2:filterquery,
inputdatasets=work.iwant,
@@ -4,6 +4,7 @@
<h4> SAS Macros </h4>
@li mp_assertcolvals.sas
@li mp_execute.sas
@li mf_getuniquefileref.sas
**/
@@ -16,7 +17,7 @@ data _null_;
put 'LIBDS:$43. COL:$32.';
put "&dclib..MPE_TABLES,LIBREF";
run;
%mp_testservice(&_program,
%mp_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f1:iwant,
outlib=web1
+2 -2
View File
@@ -8,7 +8,7 @@
@li dc_assignlib.sas
@li mf_existds.sas
@li mp_abort.sas
@li mp_getddl.sas
@li mp_ds2ddl.sas
@li mp_streamfile.sas
@@ -47,7 +47,7 @@ run;
%let tmploc=%sysfunc(pathname(work))/temp.txt;
filename tmp "&tmploc";
%mp_getddl(&libref,&ds,flavour=&flavour, fref=tmp, applydttm=YES)
%mp_ds2ddl(&libref,&ds,flavour=&flavour, fref=tmp, applydttm=YES, showlog=NO)
%mp_streamfile(contenttype=TEXT
,inloc=%str(&tmploc)
+2 -2
View File
@@ -5,7 +5,7 @@
<h4> SAS Macros </h4>
@li mf_getuser.sas
@li mp_assertcols.sas
@li mp_testservice.sas
@li mp_execute.sas
**/
@@ -13,7 +13,7 @@
/* add user */
%put &=sasjs_mdebug;
%mp_testservice(&_program,
%mp_execute(&_program,
viyacontext=&defaultcontext,
outlib=webout,
mdebug=1,
@@ -3,6 +3,7 @@
@brief testing public/validatefilter service
<h4> SAS Macros </h4>
@li mp_execute.sas
@li mp_assertdsobs.sas
@li mp_assertcolvals.sas
@li mf_getuniquefileref.sas
@@ -30,7 +31,7 @@ AND,AND,1,LIBREF,CONTAINS,"'DC'"
AND,OR,2,DSN,=,"'MPE_LOCK_ANYTABLE'"
;;;;
run;
%mp_testservice(&_program,
%mp_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f1:iwant &f2:filterquery,
outlib=web1
@@ -57,7 +58,7 @@ OPERATOR_NM='CONTAINS';
RAW_VALUE="'MORD'";
;;;;
run;
%mp_testservice(&_program,
%mp_execute(&_program,
viyacontext=&defaultcontext,
inputdatasets=work.iwant work.filterquery,
outlib=web2,
@@ -9,7 +9,7 @@
<h4> SAS Macros </h4>
@li mp_assert.sas
@li mp_testservice.sas
@li mp_execute.sas
@li mf_getuniquefileref.sas
**/
@@ -48,7 +48,7 @@ GROUP_LOGIC:$3. SUBGROUP_LOGIC:$3. SUBGROUP_ID:8. VARIABLE_NM:$32. OPERATOR_NM:$
AND,AND,1,PRIMARY_KEY_FIELD,IN,"(1,2,3)"
;;;;
run;
%mp_testservice(&_program,
%mp_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f1:iwant &f2:filterquery,
outlib=web1
@@ -72,7 +72,7 @@ data _null_;
put "&dclib..MPE_X_TEST,&filter_rk";
run;
%mp_testservice(&_program,
%mp_execute(&_program,
viyacontext=&defaultcontext,
inputfiles=&f3:SASControlTable ,
outlib=web2,
@@ -5,7 +5,7 @@
<h4> SAS Macros </h4>
@li mp_assert.sas
@li mp_testservice.sas
@li mp_execute.sas
**/
@@ -16,7 +16,7 @@ data work.sascontroltable;
FILTER_RK=0;
run;
%mp_testservice(&_program,
%mp_execute(&_program,
viyacontext=&defaultcontext,
inputdatasets=work.SASControlTable ,
outlib=web2,
+2 -2
View File
@@ -4,7 +4,7 @@
<h4> SAS Macros </h4>
@li mp_assertdsobs.sas
@li mp_testservice.sas
@li mp_execute.sas
<h4> Related Programs </h4>
@li viewlibs.sas
@@ -17,7 +17,7 @@ data work.sascontroltable;
mplib="&dclib";
run;
%mp_testservice(&_program,
%mp_execute(&_program,
viyacontext=&defaultcontext,
outlib=web1
)
@@ -83,7 +83,7 @@ run;
data work.dynamic_extended_values(keep=display_index extra_col_name display_type
RAW_VALUE_CHAR raw_value_num forced_value);
RAW_VALUE_CHAR raw_value_num forced_value);
set work.source end=last;
by libref dsn;
retain extra_col_name 'ALERT_DS';
-5
View File
@@ -2,18 +2,13 @@
@file
@brief testinit.sas
@details for SAS 9 you can run:
<h4> SAS Macros </h4>
@li dc_getsettings.sas
@li mf_getplatform.sas
@li mpeinit2.sas
@li mp_abort.sas
@li mp_init.sas
@li mp_testservice.sas
REMOVE THAT LAST MACRO
**/
%mp_init()
+5 -4
View File
@@ -14,9 +14,10 @@
@li mm_deletestp.sas
@li mm_getstpcode.sas
@li mp_assert.sas
@li mp_coretable.sas
@li mddl_dc_difftable.sas
@li mddl_dc_locktable.sas
@li mp_init.sas
@li mp_testservice.sas
@li mp_execute.sas
@li ms_createfile.sas
@li ms_deletefile.sas
@li mx_getcode.sas
@@ -147,8 +148,8 @@ proc format cntlin=work.fmts library=fmtonly.dcfmts;
run;
/* add some other tables */
%mp_coretable(LOCKTABLE,libds=dctest.locktable)
%mp_coretable(DIFFTABLE,libds=dctest.difftable)
%mddl_dc_locktable(libds=dctest.locktable)
%mddl_dc_difftable(libds=dctest.difftable)
/**