diff --git a/packages/superset-ui-core/README.md b/packages/superset-ui-core/README.md new file mode 100644 index 0000000000000..816de826a1fac --- /dev/null +++ b/packages/superset-ui-core/README.md @@ -0,0 +1,23 @@ +## @superset-ui/core + +[![Version](https://img.shields.io/npm/v/@superset-ui/core.svg?style=flat)](https://img.shields.io/npm/v/@superset-ui/core.svg?style=flat) +[![David (path)](https://img.shields.io/david/apache-superset/superset-ui.svg?path=packages%2Fsuperset-ui-core&style=flat-square)](https://david-dm.org/apache-superset/superset-ui?path=packages/superset-ui-core) + +Description + +#### Example usage + +```js +import { xxx } from '@superset-ui/core'; +``` + +#### API + +`fn(args)` + +- TBD + +### Development + +`@data-ui/build-config` is used to manage the build configuration for this package including babel +builds, jest testing, eslint, and prettier. diff --git a/packages/superset-ui-core/package.json b/packages/superset-ui-core/package.json new file mode 100644 index 0000000000000..41cfab906b856 --- /dev/null +++ b/packages/superset-ui-core/package.json @@ -0,0 +1,31 @@ +{ + "name": "@superset-ui/core", + "version": "0.2.0", + "description": "Superset UI core", + "sideEffects": false, + "main": "lib/index.js", + "module": "esm/index.js", + "files": [ + "esm", + "lib" + ], + "repository": { + "type": "git", + "url": "git+https://github.com/apache-superset/superset-ui.git" + }, + "keywords": [ + "superset" + ], + "author": "Superset", + "license": "Apache-2.0", + "bugs": { + "url": "https://github.com/apache-superset/superset-ui/issues" + }, + "homepage": "https://github.com/apache-superset/superset-ui#readme", + "publishConfig": { + "access": "public" + }, + "dependencies": { + "lodash": "^4.17.11" + } +} diff --git a/packages/superset-ui-core/src/index.js b/packages/superset-ui-core/src/index.js new file mode 100644 index 0000000000000..7d879d27ccc5f --- /dev/null +++ b/packages/superset-ui-core/src/index.js @@ -0,0 +1,8 @@ +export { default as Plugin } from './models/Plugin'; +export { default as Preset } from './models/Preset'; +export { default as Registry } from './models/Registry'; + +export { default as convertKeysToCamelCase } from './utils/convertKeysToCamelCase'; +export { default as isDefined } from './utils/isDefined'; +export { default as isRequired } from './utils/isRequired'; +export { default as makeSingleton } from './utils/makeSingleton'; diff --git a/packages/superset-ui-core/src/models/Plugin.js b/packages/superset-ui-core/src/models/Plugin.js new file mode 100644 index 0000000000000..6b6784ee5b114 --- /dev/null +++ b/packages/superset-ui-core/src/models/Plugin.js @@ -0,0 +1,27 @@ +export default class Plugin { + constructor() { + this.resetConfig(); + } + + resetConfig() { + // The child class can set default config + // by overriding this function. + this.config = {}; + + return this; + } + + configure(config, replace = false) { + if (replace) { + this.config = config; + } else { + this.config = { ...this.config, ...config }; + } + + return this; + } + + register() { + return this; + } +} diff --git a/packages/superset-ui-core/src/models/Preset.js b/packages/superset-ui-core/src/models/Preset.js new file mode 100644 index 0000000000000..33b42b8da6047 --- /dev/null +++ b/packages/superset-ui-core/src/models/Preset.js @@ -0,0 +1,19 @@ +export default class Preset { + constructor({ name = '', description = '', presets = [], plugins = [] } = {}) { + this.name = name; + this.description = description; + this.presets = presets; + this.plugins = plugins; + } + + register() { + this.presets.forEach(preset => { + preset.register(); + }); + this.plugins.forEach(plugin => { + plugin.register(); + }); + + return this; + } +} diff --git a/packages/superset-ui-core/src/models/Registry.js b/packages/superset-ui-core/src/models/Registry.js new file mode 100644 index 0000000000000..00c0e7602c91b --- /dev/null +++ b/packages/superset-ui-core/src/models/Registry.js @@ -0,0 +1,124 @@ +export default class Registry { + constructor(name = '') { + this.name = name; + this.items = {}; + this.promises = {}; + } + + clear() { + this.items = {}; + this.promises = {}; + + return this; + } + + has(key) { + const item = this.items[key]; + + return item !== null && item !== undefined; + } + + registerValue(key, value) { + const item = this.items[key]; + if (!item || item.value !== value) { + this.items[key] = { value }; + delete this.promises[key]; + } + + return this; + } + + registerLoader(key, loader) { + const item = this.items[key]; + if (!item || item.loader !== loader) { + this.items[key] = { loader }; + delete this.promises[key]; + } + + return this; + } + + get(key) { + const item = this.items[key]; + if (item) { + return item.loader ? item.loader() : item.value; + } + + return null; + } + + getAsPromise(key) { + const promise = this.promises[key]; + if (promise) { + return promise; + } + const item = this.get(key); + if (item) { + const newPromise = Promise.resolve(item); + this.promises[key] = newPromise; + + return newPromise; + } + + return Promise.reject(new Error(`Item with key "${key}" is not registered.`)); + } + + getMap() { + return this.keys().reduce((prev, key) => { + const map = prev; + map[key] = this.get(key); + + return map; + }, {}); + } + + getMapAsPromise() { + const keys = this.keys(); + + return Promise.all(keys.map(key => this.getAsPromise(key))).then(values => + values.reduce((prev, value, i) => { + const map = prev; + map[keys[i]] = value; + + return map; + }, {}), + ); + } + + keys() { + return Object.keys(this.items); + } + + values() { + return this.keys().map(key => this.get(key)); + } + + valuesAsPromise() { + return Promise.all(this.keys().map(key => this.getAsPromise(key))); + } + + entries() { + return this.keys().map(key => ({ + key, + value: this.get(key), + })); + } + + entriesAsPromise() { + const keys = this.keys(); + + return Promise.all(keys.map(key => this.getAsPromise(key))).then(values => + values.map((value, i) => ({ + key: keys[i], + value, + })), + ); + } + + remove(key) { + delete this.items[key]; + delete this.promises[key]; + + return this; + } +} diff --git a/packages/superset-ui-core/src/utils/convertKeysToCamelCase.js b/packages/superset-ui-core/src/utils/convertKeysToCamelCase.js new file mode 100644 index 0000000000000..ac99ce85b01c8 --- /dev/null +++ b/packages/superset-ui-core/src/utils/convertKeysToCamelCase.js @@ -0,0 +1,13 @@ +import camelCase from 'lodash/fp/camelCase'; +import isPlainObject from 'lodash/fp/isPlainObject'; +import mapKeys from 'lodash/fp/mapKeys'; + +export default function convertKeysToCamelCase(object) { + if (object === null || object === undefined) { + return object; + } + if (isPlainObject(object)) { + return mapKeys(k => camelCase(k), object); + } + throw new Error(`Cannot convert input that is not a plain object: ${object}`); +} diff --git a/packages/superset-ui-core/src/utils/isDefined.js b/packages/superset-ui-core/src/utils/isDefined.js new file mode 100644 index 0000000000000..807bbb76aebdd --- /dev/null +++ b/packages/superset-ui-core/src/utils/isDefined.js @@ -0,0 +1,3 @@ +export default function isDefined(x) { + return x !== null && x !== undefined; +} diff --git a/packages/superset-ui-core/src/utils/isRequired.js b/packages/superset-ui-core/src/utils/isRequired.js new file mode 100644 index 0000000000000..988c8cebf900f --- /dev/null +++ b/packages/superset-ui-core/src/utils/isRequired.js @@ -0,0 +1,3 @@ +export default function isRequired(field) { + throw new Error(`${field} is required.`); +} diff --git a/packages/superset-ui-core/src/utils/makeSingleton.js b/packages/superset-ui-core/src/utils/makeSingleton.js new file mode 100644 index 0000000000000..ab12cbf641821 --- /dev/null +++ b/packages/superset-ui-core/src/utils/makeSingleton.js @@ -0,0 +1,11 @@ +export default function makeSingleton(BaseClass, ...args) { + let singleton; + + return function getInstance() { + if (!singleton) { + singleton = new BaseClass(...args); + } + + return singleton; + }; +} diff --git a/packages/superset-ui-core/test/index.test.js b/packages/superset-ui-core/test/index.test.js new file mode 100644 index 0000000000000..457263a03f069 --- /dev/null +++ b/packages/superset-ui-core/test/index.test.js @@ -0,0 +1,5 @@ +describe('My Test', () => { + it('tests something', () => { + expect(1).toEqual(1); + }); +}); diff --git a/packages/superset-ui-core/test/models/Plugin.test.js b/packages/superset-ui-core/test/models/Plugin.test.js new file mode 100644 index 0000000000000..1a4c3c8d50974 --- /dev/null +++ b/packages/superset-ui-core/test/models/Plugin.test.js @@ -0,0 +1,53 @@ +import Plugin from '../../src/models/Plugin'; + +describe('Plugin', () => { + it('exists', () => { + expect(Plugin).toBeDefined(); + }); + + describe('new Plugin()', () => { + it('creates a new plugin', () => { + const plugin = new Plugin(); + expect(plugin).toBeInstanceOf(Plugin); + }); + }); + + describe('.configure(config, replace)', () => { + it('extends the default config with given config when replace is not set or false', () => { + const plugin = new Plugin(); + plugin.configure({ key: 'abc', foo: 'bar' }); + plugin.configure({ key: 'def' }); + expect(plugin.config).toEqual({ key: 'def', foo: 'bar' }); + }); + it('replaces the default config with given config when replace is true', () => { + const plugin = new Plugin(); + plugin.configure({ key: 'abc', foo: 'bar' }); + plugin.configure({ key: 'def' }, true); + expect(plugin.config).toEqual({ key: 'def' }); + }); + it('returns the plugin itself', () => { + const plugin = new Plugin(); + expect(plugin.configure({ key: 'abc' })).toBe(plugin); + }); + }); + + describe('.resetConfig()', () => { + it('resets config back to default', () => { + const plugin = new Plugin(); + plugin.configure({ key: 'abc', foo: 'bar' }); + plugin.resetConfig(); + expect(plugin.config).toEqual({}); + }); + it('returns the plugin itself', () => { + const plugin = new Plugin(); + expect(plugin.resetConfig()).toBe(plugin); + }); + }); + + describe('.register()', () => { + it('returns the plugin itself', () => { + const plugin = new Plugin(); + expect(plugin.register()).toBe(plugin); + }); + }); +}); diff --git a/packages/superset-ui-core/test/models/Preset.test.js b/packages/superset-ui-core/test/models/Preset.test.js new file mode 100644 index 0000000000000..c161b5fd09f5f --- /dev/null +++ b/packages/superset-ui-core/test/models/Preset.test.js @@ -0,0 +1,60 @@ +import Preset from '../../src/models/Preset'; +import Plugin from '../../src/models/Plugin'; + +describe('Preset', () => { + it('exists', () => { + expect(Preset).toBeDefined(); + }); + + describe('new Preset()', () => { + it('creates new preset', () => { + const preset = new Preset(); + expect(preset).toBeInstanceOf(Preset); + }); + }); + + describe('.register()', () => { + it('register all listed presets then plugins', () => { + const values = []; + class Plugin1 extends Plugin { + register() { + values.push(1); + } + } + class Plugin2 extends Plugin { + register() { + values.push(2); + } + } + class Plugin3 extends Plugin { + register() { + values.push(3); + } + } + class Plugin4 extends Plugin { + register() { + const { key } = this.config; + values.push(key); + } + } + + const preset1 = new Preset({ + plugins: [new Plugin1()], + }); + const preset2 = new Preset({ + plugins: [new Plugin2()], + }); + const preset3 = new Preset({ + presets: [preset1, preset2], + plugins: [new Plugin3(), new Plugin4().configure({ key: 'abc' })], + }); + preset3.register(); + expect(values).toEqual([1, 2, 3, 'abc']); + }); + + it('returns itself', () => { + const preset = new Preset(); + expect(preset.register()).toBe(preset); + }); + }); +}); diff --git a/packages/superset-ui-core/test/models/Registry.test.js b/packages/superset-ui-core/test/models/Registry.test.js new file mode 100644 index 0000000000000..0022478779702 --- /dev/null +++ b/packages/superset-ui-core/test/models/Registry.test.js @@ -0,0 +1,244 @@ +import Registry from '../../src/models/Registry'; + +describe('Registry', () => { + it('exists', () => { + expect(Registry !== undefined).toBe(true); + }); + + describe('new Registry(name)', () => { + it('can create a new registry when name is not given', () => { + const registry = new Registry(); + expect(registry).toBeInstanceOf(Registry); + }); + it('can create a new registry when name is given', () => { + const registry = new Registry('abc'); + expect(registry).toBeInstanceOf(Registry); + expect(registry.name).toBe('abc'); + }); + }); + + describe('.clear()', () => { + it('clears all registered items', () => { + const registry = new Registry(); + registry.registerValue('a', 'testValue'); + registry.clear(); + expect(Object.keys(registry.items)).toHaveLength(0); + expect(Object.keys(registry.promises)).toHaveLength(0); + }); + it('returns the registry itself', () => { + const registry = new Registry(); + expect(registry.clear()).toBe(registry); + }); + }); + + describe('.has(key)', () => { + it('returns true if an item with the given key exists', () => { + const registry = new Registry(); + registry.registerValue('a', 'testValue'); + expect(registry.has('a')).toBe(true); + registry.registerLoader('b', () => 'testValue2'); + expect(registry.has('b')).toBe(true); + }); + it('returns false if an item with the given key does not exist', () => { + const registry = new Registry(); + expect(registry.has('a')).toBe(false); + }); + }); + + describe('.registerValue(key, value)', () => { + it('registers the given value with the given key', () => { + const registry = new Registry(); + registry.registerValue('a', 'testValue'); + expect(registry.has('a')).toBe(true); + expect(registry.get('a')).toBe('testValue'); + }); + it('returns the registry itself', () => { + const registry = new Registry(); + expect(registry.registerValue('a', 'testValue')).toBe(registry); + }); + }); + + describe('.registerLoader(key, loader)', () => { + it('registers the given loader with the given key', () => { + const registry = new Registry(); + registry.registerLoader('a', () => 'testValue'); + expect(registry.has('a')).toBe(true); + expect(registry.get('a')).toBe('testValue'); + }); + it('returns the registry itself', () => { + const registry = new Registry(); + expect(registry.registerLoader('a', () => 'testValue')).toBe(registry); + }); + }); + + describe('.get(key)', () => { + it('given the key, returns the value if the item is a value', () => { + const registry = new Registry(); + registry.registerValue('a', 'testValue'); + expect(registry.get('a')).toBe('testValue'); + }); + it('given the key, returns the result of the loader function if the item is a loader', () => { + const registry = new Registry(); + registry.registerLoader('b', () => 'testValue2'); + expect(registry.get('b')).toBe('testValue2'); + }); + it('returns null if the item with specified key does not exist', () => { + const registry = new Registry(); + expect(registry.get('a')).toBeNull(); + }); + it('If the key was registered multiple times, returns the most recent item.', () => { + const registry = new Registry(); + registry.registerValue('a', 'testValue'); + expect(registry.get('a')).toBe('testValue'); + registry.registerLoader('a', () => 'newValue'); + expect(registry.get('a')).toBe('newValue'); + }); + }); + + describe('.getAsPromise(key)', () => { + it('given the key, returns a promise of item value if the item is a value', () => { + const registry = new Registry(); + registry.registerValue('a', 'testValue'); + + return registry.getAsPromise('a').then(value => expect(value).toBe('testValue')); + }); + it('given the key, returns a promise of result of the loader function if the item is a loader ', () => { + const registry = new Registry(); + registry.registerLoader('a', () => 'testValue'); + + return registry.getAsPromise('a').then(value => expect(value).toBe('testValue')); + }); + it('returns same promise object for the same key unless user re-registers new value with the key.', () => { + const registry = new Registry(); + registry.registerLoader('a', () => 'testValue'); + const promise1 = registry.getAsPromise('a'); + const promise2 = registry.getAsPromise('a'); + expect(promise1).toBe(promise2); + }); + it('returns a rejected promise if the item with specified key does not exist', () => { + const registry = new Registry(); + + return registry.getAsPromise('a').then(null, err => { + expect(err.toString()).toEqual('Error: Item with key "a" is not registered.'); + }); + }); + it('If the key was registered multiple times, returns a promise of the most recent item.', () => { + const registry = new Registry(); + registry.registerValue('a', 'testValue'); + const promise1 = registry.getAsPromise('a').then(value => expect(value).toBe('testValue')); + registry.registerLoader('a', () => 'newValue'); + const promise2 = registry.getAsPromise('a').then(value => expect(value).toBe('newValue')); + + return Promise.all([promise1, promise2]); + }); + }); + + describe('.getMap()', () => { + it('returns key-value map as plain object', () => { + const registry = new Registry(); + registry.registerValue('a', 'cat'); + registry.registerLoader('b', () => 'dog'); + expect(registry.getMap()).toEqual({ + a: 'cat', + b: 'dog', + }); + }); + }); + + describe('.getMapAsPromise()', () => { + it('returns a promise of key-value map', () => { + const registry = new Registry(); + registry.registerValue('a', 'test1'); + registry.registerLoader('b', () => 'test2'); + registry.registerLoader('c', () => Promise.resolve('test3')); + + return registry.getMapAsPromise().then(map => + expect(map).toEqual({ + a: 'test1', + b: 'test2', + c: 'test3', + }), + ); + }); + }); + + describe('.keys()', () => { + it('returns an array of keys', () => { + const registry = new Registry(); + registry.registerValue('a', 'testValue'); + registry.registerLoader('b', () => 'test2'); + expect(registry.keys()).toEqual(['a', 'b']); + }); + }); + + describe('.values()', () => { + it('returns an array of values', () => { + const registry = new Registry(); + registry.registerValue('a', 'test1'); + registry.registerLoader('b', () => 'test2'); + expect(registry.values()).toEqual(['test1', 'test2']); + }); + }); + + describe('.valuesAsPromise()', () => { + it('returns a Promise of an array { key, value }', () => { + const registry = new Registry(); + registry.registerValue('a', 'test1'); + registry.registerLoader('b', () => 'test2'); + registry.registerLoader('c', () => Promise.resolve('test3')); + + return registry + .valuesAsPromise() + .then(entries => expect(entries).toEqual(['test1', 'test2', 'test3'])); + }); + }); + + describe('.entries()', () => { + it('returns an array of { key, value }', () => { + const registry = new Registry(); + registry.registerValue('a', 'test1'); + registry.registerLoader('b', () => 'test2'); + expect(registry.entries()).toEqual([ + { key: 'a', value: 'test1' }, + { key: 'b', value: 'test2' }, + ]); + }); + }); + + describe('.entriesAsPromise()', () => { + it('returns a Promise of an array { key, value }', () => { + const registry = new Registry(); + registry.registerValue('a', 'test1'); + registry.registerLoader('b', () => 'test2'); + registry.registerLoader('c', () => Promise.resolve('test3')); + + return registry + .entriesAsPromise() + .then(entries => + expect(entries).toEqual([ + { key: 'a', value: 'test1' }, + { key: 'b', value: 'test2' }, + { key: 'c', value: 'test3' }, + ]), + ); + }); + }); + + describe('.remove(key)', () => { + it('removes the item with given key', () => { + const registry = new Registry(); + registry.registerValue('a', 'testValue'); + registry.remove('a'); + expect(registry.get('a')).toBeNull(); + }); + it('does not throw error if the key does not exist', () => { + const registry = new Registry(); + expect(() => registry.remove('a')).not.toThrowError(); + }); + it('returns itself', () => { + const registry = new Registry(); + registry.registerValue('a', 'testValue'); + expect(registry.remove('a')).toBe(registry); + }); + }); +}); diff --git a/packages/superset-ui-core/test/utils/convertKeysToCamelCase.test.js b/packages/superset-ui-core/test/utils/convertKeysToCamelCase.test.js new file mode 100644 index 0000000000000..ba973b3fe453a --- /dev/null +++ b/packages/superset-ui-core/test/utils/convertKeysToCamelCase.test.js @@ -0,0 +1,33 @@ +import convertKeysToCamelCase from '../../src/utils/convertKeysToCamelCase'; + +describe('convertKeysToCamelCase(object)', () => { + it('returns undefined for undefined input', () => { + expect(convertKeysToCamelCase(undefined)).toBeUndefined(); + }); + it('returns null for null input', () => { + expect(convertKeysToCamelCase(null)).toBeNull(); + }); + it('returns a new object that has all keys in camelCase', () => { + const input = { + is_happy: true, + 'is-angry': false, + isHungry: false, + }; + expect(convertKeysToCamelCase(input)).toEqual({ + isHappy: true, + isAngry: false, + isHungry: false, + }); + }); + it('throws error if input is not a plain object', () => { + expect(() => { + convertKeysToCamelCase({}); + }).not.toThrowError(); + expect(() => { + convertKeysToCamelCase(''); + }).toThrowError(); + expect(() => { + convertKeysToCamelCase(new Map()); + }).toThrowError(); + }); +}); diff --git a/packages/superset-ui-core/test/utils/isDefined.test.js b/packages/superset-ui-core/test/utils/isDefined.test.js new file mode 100644 index 0000000000000..d69234ba09ad6 --- /dev/null +++ b/packages/superset-ui-core/test/utils/isDefined.test.js @@ -0,0 +1,20 @@ +import isDefined from '../../src/utils/isDefined'; + +describe('isDefined(value)', () => { + it('returns true if value is not null and not undefined', () => { + expect(isDefined(0)).toBe(true); + expect(isDefined(1)).toBe(true); + expect(isDefined('')).toBe(true); + expect(isDefined('a')).toBe(true); + expect(isDefined([])).toBe(true); + expect(isDefined([0])).toBe(true); + expect(isDefined([1])).toBe(true); + expect(isDefined({})).toBe(true); + expect(isDefined({ a: 1 })).toBe(true); + expect(isDefined([{}])).toBe(true); + }); + it('returns false otherwise', () => { + expect(isDefined(null)).toBe(false); + expect(isDefined(undefined)).toBe(false); + }); +}); diff --git a/packages/superset-ui-core/test/utils/isRequired.test.js b/packages/superset-ui-core/test/utils/isRequired.test.js new file mode 100644 index 0000000000000..66370d256f2ef --- /dev/null +++ b/packages/superset-ui-core/test/utils/isRequired.test.js @@ -0,0 +1,7 @@ +import isRequired from '../../src/utils/isRequired'; + +describe('isRequired(field)', () => { + it('should throw error with the given field in the message', () => { + expect(() => isRequired('myField')).toThrowError(Error); + }); +}); diff --git a/packages/superset-ui-core/test/utils/makeSingleton.test.js b/packages/superset-ui-core/test/utils/makeSingleton.test.js new file mode 100644 index 0000000000000..685e59634b851 --- /dev/null +++ b/packages/superset-ui-core/test/utils/makeSingleton.test.js @@ -0,0 +1,36 @@ +import makeSingleton from '../../src/utils/makeSingleton'; + +describe('makeSingleton()', () => { + class Dog { + constructor(name) { + this.name = name; + } + + sit() { + this.isSitting = true; + } + } + describe('makeSingleton(BaseClass)', () => { + const getInstance = makeSingleton(Dog); + + it('returns a function for getting singleton instance of a given base class', () => { + expect(typeof getInstance).toBe('function'); + expect(getInstance()).toBeInstanceOf(Dog); + }); + it('returned function returns same instance across all calls', () => { + expect(getInstance()).toBe(getInstance()); + }); + }); + describe('makeSingleton(BaseClass, ...args)', () => { + const getInstance = makeSingleton(Dog, 'Doug'); + + it('returns a function for getting singleton instance of a given base class constructed with the given arguments', () => { + expect(typeof getInstance).toBe('function'); + expect(getInstance()).toBeInstanceOf(Dog); + expect(getInstance().name).toBe('Doug'); + }); + it('returned function returns same instance across all calls', () => { + expect(getInstance()).toBe(getInstance()); + }); + }); +});