Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1031ea7ed7 | ||
|
|
bf54a589f5 | ||
|
|
36963aa746 | ||
|
|
9b2e0df2b0 | ||
|
|
023c29f00f | ||
|
|
59e9e96f5a | ||
|
|
067c08765b | ||
|
|
9cd976495b |
@@ -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')
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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);
|
||||
})
|
||||
}
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
@@ -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,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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -2544,8 +2544,7 @@ app-licensing {
|
||||
}
|
||||
|
||||
.license-key-form,
|
||||
.activation-key-form,
|
||||
.combined-key-form {
|
||||
.activation-key-form {
|
||||
padding: 0;
|
||||
|
||||
.clr-control-container {
|
||||
|
||||
Generated
+4
-4
@@ -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
@@ -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
|
||||
|
||||
**/
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
**/
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user