Skip to content

Commit

Permalink
feat(workspace): Created reusable ScopedStorage (#13920)
Browse files Browse the repository at this point in the history
  • Loading branch information
framitdavid authored Oct 25, 2024
1 parent 0808b19 commit e371e77
Show file tree
Hide file tree
Showing 4 changed files with 194 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { type ScopedStorage, ScopedStorageImpl } from './ScopedStorage';

describe('ScopedStorage', () => {
beforeEach(() => {
window.localStorage.clear();
});

describe('add new key', () => {
it('should create a single scoped key with the provided key-value pair as its value', () => {
const scopedStorage = new ScopedStorageImpl(window.localStorage, 'unit/test');
scopedStorage.setItem('firstName', 'Random Value');
expect(scopedStorage.getItem('firstName')).toBe('Random Value');
});
});

describe('get item', () => {
it('should return "null" if key does not exist', () => {
const scopedStorage = new ScopedStorageImpl(window.localStorage, 'unit/test');
expect(scopedStorage.getItem('firstName')).toBeNull();
});
});

describe('update existing key', () => {
it('should append a new key-value pair to the existing scoped key', () => {
const scopedStorage = new ScopedStorageImpl(window.localStorage, 'unit/test');
scopedStorage.setItem('firstKey', 'first value');
scopedStorage.setItem('secondKey', 'secondValue');

expect(scopedStorage.getItem('firstKey')).toBe('first value');
expect(scopedStorage.getItem('secondKey')).toBe('secondValue');
});

it('should update the value of an existing key-value pair within the scoped key if the value has changed', () => {
const scopedStorage = new ScopedStorageImpl(window.localStorage, 'unit/test');
scopedStorage.setItem('firstKey', 'first value');
scopedStorage.setItem('firstKey', 'first value is updated');
expect(scopedStorage.getItem('firstKey')).toBe('first value is updated');
});
});

describe('delete values from key', () => {
it('should remove a specific key-value pair from the existing scoped key', () => {
const scopedStorage = new ScopedStorageImpl(window.localStorage, 'unit/test');
scopedStorage.setItem('firstKey', 'first value');
expect(scopedStorage.getItem('firstKey')).toBeDefined();

scopedStorage.removeItem('firstKey');
expect(scopedStorage.getItem('firstKey')).toBeUndefined();
});

it('should not remove key if it does not exist', () => {
const removeItemMock = jest.fn();
const customStorage = {
getItem: jest.fn().mockImplementation(() => null),
removeItem: removeItemMock,
setItem: jest.fn(),
};

const scopedStorage = new ScopedStorageImpl(customStorage, 'unit/test');
scopedStorage.removeItem('keyDoesNotExist');

expect(removeItemMock).not.toHaveBeenCalled();
});
});

describe('Storage parsing', () => {
const consoleErrorMock = jest.fn();
const originalConsoleError = console.error;
beforeEach(() => {
console.error = consoleErrorMock;
});

afterEach(() => {
console.error = originalConsoleError;
});

it('should console.error when parsing the storage fails', () => {
window.localStorage.setItem('unit/test', '{"person";{"name":"tester"}}');
const scopedStorage = new ScopedStorageImpl(window.localStorage, 'unit/test');
expect(scopedStorage.getItem('person')).toBeNull();
expect(consoleErrorMock).toHaveBeenCalledWith(
expect.stringContaining(
'Failed to parse storage with key unit/test. Ensure that the storage is a valid JSON string. Error: SyntaxError:',
),
);
});
});

// Verify that Dependency Inversion works as expected
describe('when using localStorage', () => {
it('should store and retrieve values using localStorage', () => {
const scopedStorage = new ScopedStorageImpl(window.sessionStorage, 'local/storage');
scopedStorage.setItem('firstNameInSession', 'Random Session Value');
expect(scopedStorage.getItem('firstNameInSession')).toBe('Random Session Value');
});
});

describe('when using sessionStorage', () => {
it('should store and retrieve values using sessionStorage', () => {
const scopedStorage = new ScopedStorageImpl(window.sessionStorage, 'session/storage');
scopedStorage.setItem('firstNameInSession', 'Random Session Value');
expect(scopedStorage.getItem('firstNameInSession')).toBe('Random Session Value');
});
});

describe('when using a custom storage implementation', () => {
it('should store and retrieve values using the provided custom storage', () => {
const setItemMock = jest.fn();

const customStorage: ScopedStorage = {
setItem: setItemMock,
getItem: jest.fn(),
removeItem: jest.fn(),
};

const scopedStorage = new ScopedStorageImpl(customStorage, 'unit/test');
scopedStorage.setItem('testKey', 'testValue');
expect(setItemMock).toHaveBeenCalledWith('unit/test', '{"testKey":"testValue"}');
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
type StorageKey = string;

export interface ScopedStorage extends Pick<Storage, 'setItem' | 'getItem' | 'removeItem'> {}

export class ScopedStorageImpl implements ScopedStorage {
private readonly storageKey: StorageKey;
private readonly scopedStorage: ScopedStorage;

constructor(
private storage: ScopedStorage,
private key: StorageKey,
) {
this.storageKey = this.key;
this.scopedStorage = this.storage;
}

public setItem<T>(key: string, value: T): void {
const storageRecords: T = this.getAllRecordsInStorage();
this.saveToStorage(
JSON.stringify({
...storageRecords,
[key]: value,
}),
);
}

public getItem<T>(key: string) {
const records: T = this.getAllRecordsInStorage();

if (!records) {
return null;
}

return records[key] as T;
}

public removeItem<T>(key: string): void {
const storageRecords: T | null = this.getAllRecordsInStorage<T>();

if (!storageRecords) {
return;
}

const storageCopy = { ...storageRecords };
delete storageCopy[key];
this.saveToStorage(JSON.stringify({ ...storageCopy }));
}

private getAllRecordsInStorage<T>(): T | null {
return this.parseStorageData<T>(this.scopedStorage.getItem(this.storageKey));
}

private saveToStorage(value: string) {
this.storage.setItem(this.storageKey, value);
}

private parseStorageData<T>(storage: string | null): T | null {
if (!storage) {
return null;
}

try {
return JSON.parse(storage) satisfies T;
} catch (error) {
console.error(
`Failed to parse storage with key ${this.storageKey}. Ensure that the storage is a valid JSON string. Error: ${error}`,
);
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { ScopedStorageImpl, type ScopedStorage } from './ScopedStorage';
1 change: 1 addition & 0 deletions frontend/libs/studio-pure-functions/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,6 @@ export * from './BlobDownloader';
export * from './DateUtils';
export * from './NumberUtils';
export * from './ObjectUtils';
export * from './ScopedStorage';
export * from './StringUtils';
export * from './types';

0 comments on commit e371e77

Please sign in to comment.