-
Notifications
You must be signed in to change notification settings - Fork 15
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #1560 from ag-grid/AG-11148-annotations-save-restore
AG-11148 Add saving and restoring of annotation mementos
- Loading branch information
Showing
12 changed files
with
984 additions
and
31 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
50 changes: 48 additions & 2 deletions
50
packages/ag-charts-community/src/chart/annotation/annotationManager.ts
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,99 @@ | ||
import { describe, expect, it } from '@jest/globals'; | ||
|
||
import { isPlainObject } from '../util/type-guards'; | ||
import { Memento, MementoCaretaker, MementoOriginator } from './memento'; | ||
import { expectWarning, setupMockConsole } from './test/utils'; | ||
|
||
describe('Memento Caretaker', () => { | ||
setupMockConsole(); | ||
|
||
class TestMemento implements Memento { | ||
type = 'test'; | ||
constructor( | ||
public readonly version: string, | ||
public readonly data?: any | ||
) {} | ||
} | ||
|
||
class TestOriginator implements MementoOriginator { | ||
mementoOriginatorName = 'TestOriginator'; | ||
data?: object; | ||
restored?: object; | ||
|
||
createMemento(version: string) { | ||
return new TestMemento(version, this.data); | ||
} | ||
|
||
guardMemento(blob: unknown): boolean { | ||
return isPlainObject(blob) && 'type' in blob && blob.type === 'test'; | ||
} | ||
|
||
restoreMemento(blob: TestMemento): void { | ||
this.restored = blob.data; | ||
} | ||
} | ||
|
||
let originator: TestOriginator; | ||
let caretaker: MementoCaretaker; | ||
|
||
beforeEach(() => { | ||
originator = new TestOriginator(); | ||
caretaker = new MementoCaretaker('10.0.0'); | ||
}); | ||
|
||
it('should save and restore data', () => { | ||
originator.data = { hello: 'world' }; | ||
|
||
const blob = caretaker.save(originator); | ||
caretaker.restore(originator, blob); | ||
|
||
expect(blob).toBe('eyJ2ZXJzaW9uIjoiMTAuMC4wIiwiZGF0YSI6eyJoZWxsbyI6IndvcmxkIn0sInR5cGUiOiJ0ZXN0In0='); | ||
expect(originator.restored).toEqual({ hello: 'world' }); | ||
}); | ||
|
||
it('should save and restore data with unicode strings', () => { | ||
originator.data = { hello: '🌍' }; | ||
|
||
const blob = caretaker.save(originator); | ||
caretaker.restore(originator, blob); | ||
|
||
expect(blob).toBe('eyJ2ZXJzaW9uIjoiMTAuMC4wIiwiZGF0YSI6eyJoZWxsbyI6IvCfjI0ifSwidHlwZSI6InRlc3QifQ=='); | ||
expect(originator.restored).toEqual({ hello: '🌍' }); | ||
}); | ||
|
||
it('should guard an incorrect memento', () => { | ||
class OtherTestMemento extends TestMemento { | ||
override type = 'other-test'; | ||
} | ||
|
||
class OtherTestOriginator extends TestOriginator { | ||
override mementoOriginatorName = 'OtherTestOriginator'; | ||
|
||
override createMemento(version: string) { | ||
return new OtherTestMemento(version, this.data); | ||
} | ||
|
||
override guardMemento(blob: unknown): boolean { | ||
return isPlainObject(blob) && 'type' in blob && blob.type === 'other-test'; | ||
} | ||
} | ||
|
||
const otherOriginator = new OtherTestOriginator(); | ||
otherOriginator.data = { hello: 'world' }; | ||
|
||
const blob = caretaker.save(otherOriginator); | ||
caretaker.restore(originator, blob); | ||
|
||
expectWarning('AG Charts - TestOriginator - Could not restore data, memento was invalid, ignoring.', { | ||
data: otherOriginator.data, | ||
type: 'other-test', | ||
version: '10.0.0', | ||
}); | ||
expect(originator.restored).toBeUndefined(); | ||
}); | ||
|
||
it('should handle an invalid memento', () => { | ||
expect(() => caretaker.restore(originator, 'some nonsense')).toThrow(); | ||
expect(originator.restored).toBeUndefined(); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,98 @@ | ||
import { Logger } from '../util/logger'; | ||
import { isDate, isPlainObject } from '../util/type-guards'; | ||
|
||
export interface Memento { | ||
type: string; | ||
version: string; | ||
} | ||
|
||
export interface MementoOriginator { | ||
mementoOriginatorName: string; | ||
createMemento(version: string): Memento; | ||
guardMemento(blob: unknown): boolean; | ||
restoreMemento(blob: Memento): void; | ||
} | ||
|
||
/** | ||
* The caretaker manages the encoding and decoding of mementos from originators, ensuring they can be provided to and | ||
* received from external systems. A memento encapsulates the state of an originator at a point in time. A memento | ||
* is also versioned to ensure it can be migrated to newer versions of the originator. | ||
*/ | ||
export class MementoCaretaker { | ||
private readonly version: string; | ||
|
||
constructor(version: string) { | ||
// Strip out version suffixes, e.g. `-beta` | ||
this.version = version.split('-')[0]; | ||
} | ||
|
||
save(originator: MementoOriginator) { | ||
return this.encode(originator, originator.createMemento(this.version)); | ||
} | ||
|
||
restore(originator: MementoOriginator, blob: unknown) { | ||
if (typeof blob !== 'string') { | ||
Logger.warnOnce( | ||
`${originator.mementoOriginatorName} - Could not restore data of type [${typeof blob}], expecting a string, ignoring.` | ||
); | ||
return; | ||
} | ||
|
||
const memento = this.decode(originator, blob); | ||
|
||
if (!originator.guardMemento(memento)) { | ||
Logger.warnOnce( | ||
`${originator.mementoOriginatorName} - Could not restore data, memento was invalid, ignoring.`, | ||
memento | ||
); | ||
return; | ||
} | ||
|
||
originator.restoreMemento(memento); | ||
} | ||
|
||
/** | ||
* Encode a memento as a base64 string. Base64 encoding only supports utf-8, so multi-byte unicode characters are | ||
* converted into multiple single-byte utf-8 characters. | ||
*/ | ||
private encode(originator: MementoOriginator, memento: Memento) { | ||
try { | ||
const mementoString = JSON.stringify(memento, this.encodeTypes); | ||
const bytes = new TextEncoder().encode(mementoString); | ||
const binaryString = Array.from(bytes, (byte) => String.fromCodePoint(byte)).join(''); | ||
return btoa(binaryString); | ||
} catch (error) { | ||
throw new Error(`${originator.mementoOriginatorName} - Failed to encode memento [${error}].`, { | ||
cause: error, | ||
}); | ||
} | ||
} | ||
|
||
/** | ||
* Decode an encoded memento, translating multiple single-byte uft-8 characters back into multi-byte unicode. | ||
*/ | ||
private decode(originator: MementoOriginator, encoded: string) { | ||
try { | ||
const binaryString = atob(encoded); | ||
const bytes = Uint8Array.from(binaryString, (m) => m.codePointAt(0) ?? 0); | ||
const blobString = new TextDecoder().decode(bytes); | ||
return JSON.parse(blobString, this.decodeTypes); | ||
} catch (error) { | ||
throw new Error(`${originator.mementoOriginatorName} - Failed to decode memento [${error}].`, { | ||
cause: error, | ||
}); | ||
} | ||
} | ||
|
||
private encodeTypes(this: any, key: any, value: any) { | ||
if (isDate(this[key])) return { __type: 'date', value: String(this[key]) }; | ||
return value; | ||
} | ||
|
||
private decodeTypes(this: any, key: any, value: any) { | ||
if (isPlainObject(this[key]) && '__type' in this[key] && this[key].__type === 'date') { | ||
return new Date(this[key].value); | ||
} | ||
return value; | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.