diff --git a/.circleci/config.yml b/.circleci/config.yml index 94b305d..4766c93 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -24,7 +24,6 @@ jobs: - v1-dependencies - run: yarn - - save_cache: paths: - node_modules @@ -33,3 +32,5 @@ jobs: # run flow and unit tests! - run: yarn run flow - run: yarn run jest:coverage + - store_artifacts: + path: coverage diff --git a/README.md b/README.md index 3159303..93bd658 100644 --- a/README.md +++ b/README.md @@ -80,16 +80,16 @@ create: (image: Image, options?: Options) => Promise ##### Creating a palette from a network resource, with 'vibrant' color profile, maximumColorCount = 16 and the whole region of the image (default behaviour) ```js -import MaterialPalette from "react-native-material-palette"; +import { createMaterialPalette } from "react-native-material-palette"; -const palette = await MaterialPalette.create({ uri: 'http://dummySite/images/yummy.jpg' }); +const palette = await createMaterialPalette({ uri: 'http://dummySite/images/yummy.jpg' }); ``` ##### Creating a palette from an internal image asset, with 'muted' and 'lightVibrant' color profiles, maximumColorCount = 32 and a specific region of the image ```js -import MaterialPalette from "react-native-material-palette"; +import { createMaterialPalette } from "react-native-material-palette"; -const palette = await MaterialPalette.create(require('./assets/image.jpg'), { +const palette = await createMaterialPalette(require('./assets/image.jpg'), { region: { top: 0, left: 0, bottom: 50, right: 50}, maximumColorCount: 32, type: ['muted', 'lightVibrant'], diff --git a/docs/API.md b/docs/API.md index a453107..d8baddc 100644 --- a/docs/API.md +++ b/docs/API.md @@ -2,8 +2,6 @@ ## `MaterialPaletteProvider` -__Also available from the default import:__ `MaterialPalette.PaletteProvider` - ### Example of usage: ```javascript import React from 'react'; @@ -30,16 +28,6 @@ class App extends React.Component { } ``` -> You can import the component directly, as a named import: -> ```javascript -> import { MaterialPaletteProvider } from 'react-native-material-palette'; -> ``` -> or using the default import and accessing the component using `PaletteProvider` property: -> ```javascript -> import MaterialPalette from 'react-native-material-palette'; -> // MaterialPalette.PaletteProvider -> ``` - ### Description `MaterialPaletteProvider` is a component, which handles palette creation and provides the access to the palette instance for _connected_ components (via `withMaterialPalette`) using context. Ideally, `MaterialPaletteProvider` should be placed at the top of components tree, so that all nested components can _connect_ to it. By default it will render `null` when the palette is being created unless either `forceRender` or `LoaderComponent` is specified. @@ -53,8 +41,7 @@ The concept is very similar to `Provider` component from `react-redux`. type Options = { region?: { top: number, left: number, bottom: number, right: number }, maximumColorCount?: number = 16, - type?: ColorProfile = 'vibrant', - types?: Array = [] + type?: ColorProfile | Array = 'vibrant', } ``` @@ -88,7 +75,7 @@ The concept is very similar to `Provider` component from `react-redux`. * `onInit?: () => void` - (optional) - Init handler, called when the `MaterialPaletteProvider` is just about to start creating the palette. -* `onFinish?: (palette: PaletteInstance, globalDefaults: PaletteDefaults) => void` - (optional) - Finish handler, called when the palette is created, but before it gets propagated to _connected_ components - use it, if you want to mutate the palette instance. +* `onFinish?: (palette: PaletteInstance) => void` - (optional) - Finish handler, called when the palette is created, but before it gets propagated to _connected_ components - use it, if you want to mutate the palette instance. If some profiles are not available for the provided image, the defaults will apply, taking precedence the ones you passed to the component as `this.props.defaults`. * `children: React$Element<*>`, - (__required__) - Children elements - the rest of your app's component tree. @@ -96,8 +83,6 @@ The concept is very similar to `Provider` component from `react-redux`. ## `withMaterialPalette` -__Also available from the default import:__ `MaterialPalette.withPalette` - ### Example of usage: ```javascript import React from 'react'; @@ -112,16 +97,6 @@ export default withMaterialPalette( )(Text); ``` -> You can import the function directly, as a named import: -> ```javascript -> import { withMaterialPalette } from 'react-native-material-palette'; -> ``` -> or using the default import and accessing the component using `withPalette` property: -> ```javascript -> import MaterialPalette from 'react-native-material-palette'; -> // MaterialPalette.withPalette -> ``` - ### Description `withMaterialPalette` is a function that returns a Higher Order Component (HOC), which allows to seemlessy _connect_ to the `MaterialPaletteProvider` and get the palette instance via context. diff --git a/example/index.android.js b/example/index.android.js index 06e5720..0864f8d 100644 --- a/example/index.android.js +++ b/example/index.android.js @@ -7,7 +7,7 @@ import React, { Component } from 'react'; import { AppRegistry, StyleSheet, Text, View, Image } from 'react-native'; -import MaterialPalette from 'react-native-material-palette'; +import { createMaterialPalette } from 'react-native-material-palette'; export default class TestPalette extends Component { state = { @@ -16,7 +16,7 @@ export default class TestPalette extends Component { }; async componentDidMount() { - const palette = await MaterialPalette.create( + const palette = await createMaterialPalette( require('./assets/wroclaw.jpg'), // eslint-disable-line global-require { type: ['lightMuted', 'darkVibrant', 'vibrant'], diff --git a/package.json b/package.json index ff23440..57a3576 100644 --- a/package.json +++ b/package.json @@ -138,7 +138,7 @@ "boolean" ], "flowtype/no-weak-types": 1, - "flowtype/require-parameter-type": 2, + "flowtype/require-parameter-type": 0, "flowtype/require-return-type": [ 0, "always", diff --git a/src/PaletteProvider.js b/src/PaletteProvider.js index 5a16e5c..511ddfc 100644 --- a/src/PaletteProvider.js +++ b/src/PaletteProvider.js @@ -3,9 +3,17 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import createEventEmitter from './createEventEmitter'; -import MaterialPalette from './index'; +import { createMaterialPalette } from './index'; +import { defaultSwatches } from './constants/defaults'; +import { validateDefaults } from './utils/validateCreatePaletteArgs'; -import type { PaletteInstance, Image, Options, PaletteDefaults } from './types'; +import type { + PaletteInstance, + Image, + Options, + PaletteDefaults, + ColorProfile, +} from './types'; export const KEY = '__react-native-material-palette__'; @@ -37,10 +45,7 @@ type Props = { /** * Finish handler, called right after the palette is generated */ - onFinish?: ( - palette: PaletteInstance, - globalDefaults: PaletteDefaults, - ) => void, + onFinish?: (palette: PaletteInstance) => void, /** * Render the children regardless whether palette is still being created, does not * take effect if `LoaderComponent` is specified @@ -93,17 +98,62 @@ export default class MaterialPaletteProvider }; } + _mergeWithDefaults(palette: PaletteInstance) { + const globalDefaultsForTypesProvided = ((Object.keys( + palette, + ): any): ColorProfile[]).reduce( + (acc, profile) => ({ + ...acc, + [profile]: defaultSwatches[profile], + }), + {}, + ); + + const defaults = { + ...globalDefaultsForTypesProvided, + ...((Object.keys( + this.props.defaults || {}, + ): any): ColorProfile[]).reduce( + (acc: *, profile: ColorProfile) => ({ + ...acc, + [profile]: { + ...(this.props.defaults && this.props.defaults[profile] + ? this.props.defaults[profile] + : defaultSwatches[profile]), + population: 0, + }, + }), + {}, + ), + }; + return { + ...defaults, + ...((Object.keys(palette): any): ColorProfile[]) + .filter((profile: ColorProfile) => !!palette[profile]) // Stripping out unavailable profiles + .reduce( + (acc: *, profile: ColorProfile) => ({ + ...acc, + [profile]: palette[profile], + }), + {}, + ), + }; + } + componentWillMount() { + if (this.props.defaults) { + validateDefaults(this.props.defaults); + } execIfFunction(this.props.onInit); - MaterialPalette.create(this.props.image, this.props.options) + createMaterialPalette(this.props.image, this.props.options) .then((palette: PaletteInstance) => { - execIfFunction(this.props.onFinish, palette, this.props.defaults); + const paletteWithDefaults = this._mergeWithDefaults(palette); + execIfFunction(this.props.onFinish, paletteWithDefaults); if (!this.props.forceRender) { - this.setState({ palette }); + this.setState({ palette: paletteWithDefaults }); } this.eventEmitter.publish({ - palette, - globalDefaults: this.props.defaults, + palette: paletteWithDefaults, }); }) .catch((error: Error) => { diff --git a/src/__tests__/PaletteProvider.test.js b/src/__tests__/PaletteProvider.test.js index 693d03c..c1c2999 100644 --- a/src/__tests__/PaletteProvider.test.js +++ b/src/__tests__/PaletteProvider.test.js @@ -1,13 +1,17 @@ -/* eslint flowtype/require-parameter-type: 0 */ /* eslint-disable import/first */ -jest.mock('../index.js', () => ({ create: jest.fn() })); +jest.mock('../index.js', () => ({ createMaterialPalette: jest.fn() })); +jest.mock('../utils/validateCreatePaletteArgs', () => ({ + __esModule: true, + validateDefaults: jest.fn(), +})); import React from 'react'; import { Text } from 'react-native'; import PropTypes from 'prop-types'; import { shallow, render } from 'enzyme'; import PaletteProvider, { KEY } from '../PaletteProvider'; -import MaterialPalette from '../index'; +import { createMaterialPalette } from '../index'; +import { defaultSwatches, defaultDarkSwatch } from '../constants/defaults'; // eslint-disable-next-line react/prefer-stateless-function class TestComponent extends React.Component { @@ -23,19 +27,20 @@ class TestComponent extends React.Component { describe('PaletteProvider', () => { beforeEach(() => { - MaterialPalette.create.mockReset(); + createMaterialPalette.mockReset(); }); it('should create palette and call `onInit` and `onFinish` handlers', done => { - MaterialPalette.create.mockImplementation(() => + createMaterialPalette.mockImplementation(() => Promise.resolve({ vibrant: null })); - function onFinish(palette, defaults) { - expect(MaterialPalette.create).toHaveBeenCalledWith(0, { + function onFinish(palette) { + expect(createMaterialPalette).toHaveBeenCalledWith(0, { type: 'vibrant', }); - expect(palette).toEqual({ vibrant: null }); - expect(defaults).toEqual({ vibrant: { color: '#000000' } }); + expect(palette).toEqual({ + vibrant: defaultDarkSwatch, + }); done(); } @@ -44,7 +49,13 @@ describe('PaletteProvider', () => { image={0} options={{ type: 'vibrant' }} onFinish={onFinish} - defaults={{ vibrant: { color: '#000000' } }} + defaults={{ + vibrant: { + color: '#000000', + bodyTextColor: '#FFFFFF', + titleTextColor: '#FFFFFF', + }, + }} > Test , @@ -52,7 +63,7 @@ describe('PaletteProvider', () => { }); it('should pass `subscribe` function via context', done => { - MaterialPalette.create.mockImplementation(() => + createMaterialPalette.mockImplementation(() => Promise.resolve({ vibrant: null })); function onRender(context) { @@ -72,7 +83,7 @@ describe('PaletteProvider', () => { }); it('should run `onError` handler if palette creation fails', done => { - MaterialPalette.create.mockImplementation(() => + createMaterialPalette.mockImplementation(() => Promise.reject(new Error('test'))); function onError(error) { @@ -98,7 +109,7 @@ describe('PaletteProvider', () => { resolve(); } - MaterialPalette.create.mockImplementation(() => ({ + createMaterialPalette.mockImplementation(() => ({ then() { return this; }, @@ -119,7 +130,7 @@ describe('PaletteProvider', () => { })); it('should render children if `forceRender` is true when creating palette', done => { - MaterialPalette.create.mockImplementation( + createMaterialPalette.mockImplementation( () => new Promise(resolve => { setTimeout( @@ -131,13 +142,13 @@ describe('PaletteProvider', () => { }), ); - let firstNatification = true; + let firstNotification = true; function onRender(context) { setTimeout( () => { context[KEY](data => { - if (firstNatification) { - firstNatification = false; + if (firstNotification) { + firstNotification = false; expect(data).toBeNull(); } else { expect(data.palette.vibrant).toEqual({}); @@ -159,7 +170,7 @@ describe('PaletteProvider', () => { }); it('should render component specified in `waitForPalette` when creating palette', () => { - MaterialPalette.create.mockImplementation(() => new Promise(() => {})); + createMaterialPalette.mockImplementation(() => new Promise(() => {})); const wrapper = shallow( { ); expect(wrapper.shallow().props().children).toEqual('Loading'); }); + + describe('Merge with defaults', () => { + const PaletteWrapper = ({ types, defaults, onFinish }) => ( + + Test + + ); + + it('should merge palette with globals when props.defaults is not provided, for the types specified', done => { + createMaterialPalette.mockImplementation(() => + Promise.resolve({ + vibrant: { + color: 'green', + bodyTextColor: 'red', + titleTextColor: 'red', + population: 20, + }, + muted: null, + })); + + function onFinish(palette) { + expect(palette).toEqual({ + vibrant: { + color: 'green', + bodyTextColor: 'red', + titleTextColor: 'red', + population: 20, + }, + muted: defaultSwatches.muted, + }); + done(); + } + + render( + , + ); + }); + + it('should merge palette with globals when props.defaults contains a wrong profile, for the types specified', done => { + createMaterialPalette.mockImplementation(() => + Promise.resolve({ + vibrant: defaultSwatches.vibrant, + })); + + function onFinish(palette) { + expect(palette).toEqual({ + vibrant: defaultSwatches.vibrant, + darkMuted: defaultSwatches.darkMuted, + }); + done(); + } + render( + , + ); + }); + + it('should merge palette with both globals and local defaults, for the types specified', done => { + createMaterialPalette.mockImplementation(() => + Promise.resolve({ + muted: { + color: 'green', + bodyTextColor: 'red', + titleTextColor: 'red', + population: 20, + }, + darkMuted: { + color: 'yellow', + bodyTextColor: 'blue', + titleTextColor: 'blue', + population: 40, + }, + lightVibrant: null, + darkVibrant: null, + })); + + function onFinish(palette) { + expect(palette).toEqual({ + muted: { + color: 'green', + bodyTextColor: 'red', + titleTextColor: 'red', + population: 20, + }, + darkMuted: { + color: 'yellow', + bodyTextColor: 'blue', + titleTextColor: 'blue', + population: 40, + }, + lightVibrant: { + color: 'orange', + bodyTextColor: 'purple', + titleTextColor: 'purple', + population: 0, + }, + darkVibrant: defaultSwatches.darkVibrant, + }); + done(); + } + + render( + , + ); + }); + }); }); diff --git a/src/__tests__/createEventEmitter.test.js b/src/__tests__/createEventEmitter.test.js index f46f226..aa2077f 100644 --- a/src/__tests__/createEventEmitter.test.js +++ b/src/__tests__/createEventEmitter.test.js @@ -1,4 +1,3 @@ -/* eslint flowtype/require-parameter-type: 0 */ import createEventEmitter from '../createEventEmitter'; describe('createEventEmitter', () => { diff --git a/src/__tests__/createMaterialPalette.test.js b/src/__tests__/createMaterialPalette.test.js new file mode 100644 index 0000000..ed52f8b --- /dev/null +++ b/src/__tests__/createMaterialPalette.test.js @@ -0,0 +1,79 @@ +/* eslint-disable import/first */ +jest.mock('react-native', () => ({ + NativeModules: { + MaterialPalette: { + createMaterialPalette: jest.fn(), + }, + }, +})); +jest.mock('react-native/Libraries/Image/resolveAssetSource', () => jest.fn()); +jest.mock('../utils/validateCreatePalette', () => jest.fn()); + +import { NativeModules } from 'react-native'; +import resolveAssetSource + from 'react-native/Libraries/Image/resolveAssetSource'; +import createPalette from '../createMaterialPalette'; +import validateCreatePalette from '../utils/validateCreatePalette'; +import { defaultLightSwatch, defaultOptions } from '../constants/defaults'; + +describe('createMaterialPalette', () => { + beforeEach(() => { + NativeModules.MaterialPalette.createMaterialPalette.mockReset(); + resolveAssetSource.mockReset(); + }); + + it('should create palette with default options if no options are provided', () => { + NativeModules.MaterialPalette.createMaterialPalette.mockImplementation(() => + Promise.resolve({ + vibrant: defaultLightSwatch, + })); + resolveAssetSource.mockImplementation(() => `file://asset.jpg`); + + return createPalette(0).then(() => { + expect( + NativeModules.MaterialPalette.createMaterialPalette, + ).toHaveBeenCalledWith(`file://asset.jpg`, { + type: ['vibrant'], + region: defaultOptions.region, + maximumColorCount: defaultOptions.maximumColorCount, + }); + }); + }); + + it('should call all the proper methods and provide null for the profiles not available', () => { + NativeModules.MaterialPalette.createMaterialPalette.mockImplementation(() => + Promise.resolve({ + lightVibrant: defaultLightSwatch, + darkMuted: { + color: 'green', + population: 20, + bodyTextColor: 'red', + titleTextColor: 'red', + }, + })); + resolveAssetSource.mockImplementation(() => `file://asset.jpg`); + + return createPalette(0, { + type: ['lightVibrant', 'darkMuted'], + }).then(palette => { + expect(validateCreatePalette).toHaveBeenCalledWith(0, { + type: ['lightVibrant', 'darkMuted'], + }); + expect(resolveAssetSource).toHaveBeenCalledWith(0); + expect( + NativeModules.MaterialPalette.createMaterialPalette, + ).toHaveBeenCalledWith(`file://asset.jpg`, { + type: ['lightVibrant', 'darkMuted'], + region: defaultOptions.region, + maximumColorCount: defaultOptions.maximumColorCount, + }); + expect(palette.lightVibrant).toBeNull(); + expect(palette.darkMuted).toEqual({ + color: 'green', + population: 20, + bodyTextColor: 'red', + titleTextColor: 'red', + }); + }); + }); +}); diff --git a/src/__tests__/index.test.js b/src/__tests__/index.test.js deleted file mode 100644 index 82866e3..0000000 --- a/src/__tests__/index.test.js +++ /dev/null @@ -1,9 +0,0 @@ -/* eslint-env jest */ - -import MaterialPalette from '../'; - -describe('reactNativeMaterialPalette', () => { - it('should return argument', () => { - expect(typeof MaterialPalette.create).toBe('function'); - }); -}); diff --git a/src/__tests__/withPalette.test.js b/src/__tests__/withPalette.test.js index 8b32fb8..de6d284 100644 --- a/src/__tests__/withPalette.test.js +++ b/src/__tests__/withPalette.test.js @@ -1,4 +1,3 @@ -/* eslint flowtype/require-parameter-type: 0 */ import React from 'react'; import { shallow } from 'enzyme'; import withPalette from '../withPalette'; @@ -147,66 +146,7 @@ describe('withPalette', () => { wrapper.shallow(); }); - it('should merge palette with global defaults', () => { - const defaults = { - lightVibrant: { - color: '#f1f1f1', - bodyTextColor: '#000000', - titleTextColor: '#000000', - }, - }; - let subscriber = jest.fn(); - function onFirstRender(palette, style) { - expect(palette).toEqual({}); - expect(style).toEqual([undefined, {}]); - } - function onSecondRender(palette, style) { - expect(palette).toEqual({ - ...paletteMock, - lightVibrant: { population: 0, ...defaults.lightVibrant }, - }); - expect(style).toEqual([ - undefined, - { color: defaults.lightVibrant.color }, - ]); - } - - const PaletteTest = withPalette(palette => ({ - color: palette.lightVibrant && palette.lightVibrant.color, - }))(getTestComponent()); - const wrapper = shallow( - , - { - context: createContext(fn => { - subscriber = fn; - }), - }, - ); - - wrapper.shallow(); - subscriber({ - palette: paletteMock, - globalDefaults: defaults, - }); - wrapper.shallow(); - }); - - it('should merge palette with both global and local defaults and style prop', () => { - const globalDefaults = { - lightVibrant: { - color: '#f1f1f1', - bodyTextColor: '#000000', - titleTextColor: '#000000', - }, - muted: { - color: '#f1f1f1', - bodyTextColor: '#000000', - titleTextColor: '#000000', - }, - }; + it('should merge palette with local defaults and style prop', () => { const localDefaults = { lightVibrant: { color: '#a4a4a4', @@ -225,13 +165,11 @@ describe('withPalette', () => { expect(palette).toEqual({ ...paletteMock, lightVibrant: { population: 0, ...localDefaults.lightVibrant }, - muted: { population: 0, ...globalDefaults.muted }, }); expect(style).toEqual([ { fontSize: '14px' }, { color: localDefaults.lightVibrant.color, - backgroundColor: globalDefaults.muted.color, }, ]); } @@ -259,7 +197,6 @@ describe('withPalette', () => { wrapper.shallow(); subscriber({ palette: paletteMock, - globalDefaults, }); wrapper.shallow(); }); diff --git a/src/constants/defaults.js b/src/constants/defaults.js index f4a0ed7..dbb807c 100644 --- a/src/constants/defaults.js +++ b/src/constants/defaults.js @@ -2,6 +2,22 @@ import type { Region, Options, Swatch } from '../types'; +const defaultVibrant = '#757575'; +const defaultLightVibrant = '#E0E0E0'; +const defaultDarkVibrant = '#212121'; +const defaultMuted = '#9E9E9E'; +const defaultLightMuted = '#BDBDBD'; +const defaultDarkMuted = '#616161'; + +export const validColorProfiles = { + vibrant: true, + lightVibrant: true, + darkVibrant: true, + muted: true, + lightMuted: true, + darkMuted: true, +}; + export const defaultRegion: Region = { top: 0, right: 0, @@ -15,9 +31,31 @@ export const defaultOptions: Options = { type: 'vibrant', }; -export const nullSwatch: Swatch = { +export const defaultLightSwatch: Swatch = { population: 0, color: '#000000', bodyTextColor: '#000000', titleTextColor: '#000000', }; + +export const defaultProfile = { + color: '#000000', + bodyTextColor: '#000000', + titleTextColor: '#000000', +}; + +export const defaultDarkSwatch: Swatch = { + population: 0, + color: '#000000', + bodyTextColor: '#FFFFFF', + titleTextColor: '#FFFFFF', +}; + +export const defaultSwatches = { + vibrant: { ...defaultDarkSwatch, color: defaultVibrant }, + lightVibrant: { ...defaultLightSwatch, color: defaultLightVibrant }, + darkVibrant: { ...defaultLightSwatch, color: defaultDarkVibrant }, + muted: { ...defaultDarkSwatch, color: defaultMuted }, + lightMuted: { ...defaultLightSwatch, color: defaultLightMuted }, + darkMuted: { ...defaultLightSwatch, color: defaultDarkMuted }, +}; diff --git a/src/createMaterialPalette.js b/src/createMaterialPalette.js new file mode 100644 index 0000000..700d7f8 --- /dev/null +++ b/src/createMaterialPalette.js @@ -0,0 +1,38 @@ +/* @flow */ + +import { NativeModules } from 'react-native'; +import resolveAssetSource + from 'react-native/Libraries/Image/resolveAssetSource'; +import isEqual from 'lodash/isEqual'; +import { defaultOptions, defaultLightSwatch } from './constants/defaults'; +import validateCreatePalette from './utils/validateCreatePalette'; +import type { Image, PaletteInstance, Options, ColorProfile } from './types'; + +export default (async function createMaterialPalette( + image: Image, + options?: Options = {}, +): Promise { + validateCreatePalette(image, options); + const { + region, + maximumColorCount, + type, + } = { ...defaultOptions, ...options }; + + const source = resolveAssetSource(image); + + const paletteInstance = await NativeModules.MaterialPalette.createMaterialPalette( + source, + { + region, + maximumColorCount, + type: typeof type === 'string' ? [type] : type, + }, + ); + Object.keys(paletteInstance).forEach((profile: ColorProfile) => { + if (isEqual(paletteInstance[profile], defaultLightSwatch)) { + paletteInstance[profile] = null; + } + }); + return paletteInstance; +}); diff --git a/src/index.js b/src/index.js index 781ec2f..3c6f318 100644 --- a/src/index.js +++ b/src/index.js @@ -1,69 +1,5 @@ /* @flow */ -import { NativeModules } from 'react-native'; -import resolveAssetSource - from 'react-native/Libraries/Image/resolveAssetSource'; -import isEqual from 'lodash/isEqual'; -import { defaultOptions, nullSwatch } from './constants/defaults'; -import validate from './utils/validate'; -import type { - Image, - PaletteInstance, - Options, - ColorProfile, - PaletteDefaults, -} from './types'; - -import PaletteProvider from './PaletteProvider'; -import withPalette from './withPalette'; - -export const MaterialPaletteProvider = PaletteProvider; -export const withMaterialPalette = withPalette; - -/** API */ - -type Namespace = { - create: (image: Image, options?: Options) => Promise, - PaletteProvider: Class>, - withPalette: ( - mapPaletteToStyle?: (palette: PaletteInstance) => { - [key: string]: mixed, - }, - localDefaults?: PaletteDefaults, - ) => (WrappedComponent: ReactClass<*>) => Class>, -}; - -const namespace: Namespace = { - async create( - image: Image, - options?: Options = defaultOptions, - ): Promise { - validate(image, options); - const { - region, - maximumColorCount, - type, - } = { ...defaultOptions, ...options }; - - const source = resolveAssetSource(image); - - const paletteInstance = await NativeModules.MaterialPalette.createMaterialPalette( - source, - { - region, - maximumColorCount, - type: typeof type === 'string' ? [type] : type, - }, - ); - Object.keys(paletteInstance).forEach((profile: ColorProfile) => { - if (isEqual(paletteInstance[profile], nullSwatch)) { - paletteInstance[profile] = null; - } - }); - return paletteInstance; - }, - PaletteProvider: MaterialPaletteProvider, - withPalette: withMaterialPalette, -}; - -export default namespace; +export { default as MaterialPaletteProvider } from './PaletteProvider'; +export { default as withPalette } from './withPalette'; +export { default as createMaterialPalette } from './createMaterialPalette'; diff --git a/src/types.js b/src/types.js index 57ddada..17c63d9 100644 --- a/src/types.js +++ b/src/types.js @@ -1,5 +1,7 @@ /* @flow */ +import { validColorProfiles } from './constants/defaults'; + // Number is the opaque type returned by require('./image.jpg') export type Image = number | { uri: string }; export type Region = { @@ -9,14 +11,7 @@ export type Region = { right: number, }; -export type ColorProfile = - | 'muted' - | 'vibrant' - | 'darkMuted' - | 'darkVibrant' - | 'lightMuted' - | 'lightVibrant'; - +export type ColorProfile = $Keys; export type Swatch = { population: number, // number of pixels color: string, // color for swatch, @@ -34,12 +29,6 @@ export type PaletteDefaults = { [key: ColorProfile]: DefaultSwatch, }; -export type SwatchColors = { - color?: string, - bodyTextColor?: string, - titleTextColor?: string, -}; - export type PaletteInstance = { [key: ColorProfile]: ?Swatch, }; diff --git a/src/utils/__tests__/validateCreatePalette.test.js b/src/utils/__tests__/validateCreatePalette.test.js new file mode 100644 index 0000000..ed657ef --- /dev/null +++ b/src/utils/__tests__/validateCreatePalette.test.js @@ -0,0 +1,49 @@ +/* eslint-disable import/first */ +jest.mock('../validateCreatePaletteArgs'); + +import validateCreatePalette from '../validateCreatePalette'; +import { defaultOptions } from '../../constants/defaults'; +import { + validateType, + validateImage, + validateMaximumColorCount, + validateRegion, + validateOptionsKeys, +} from '../validateCreatePaletteArgs'; + +const VALID_IMAGE = { uri: 'https://something.image.jpg' }; + +describe('validateCreatePalette', () => { + beforeEach(() => { + validateType.mockReset(); + validateImage.mockReset(); + validateMaximumColorCount.mockReset(); + validateRegion.mockReset(); + validateOptionsKeys.mockReset(); + }); + + it('should run all validators if all args are passed', () => { + expect(() => + validateCreatePalette(VALID_IMAGE, defaultOptions)).not.toThrow(); + + validateCreatePalette(VALID_IMAGE, defaultOptions); + + expect(validateImage).toHaveBeenCalledWith(VALID_IMAGE); + expect(validateOptionsKeys).toHaveBeenCalledWith(defaultOptions); + expect(validateType).toHaveBeenCalledWith(defaultOptions.type); + expect(validateRegion).toHaveBeenCalledWith(defaultOptions.region); + expect(validateMaximumColorCount).toHaveBeenCalledWith( + defaultOptions.maximumColorCount, + ); + }); + + it('should not run options validators if options are not provided', () => { + expect(() => validateCreatePalette(VALID_IMAGE, {})).not.toThrow(); + validateCreatePalette(VALID_IMAGE, {}); + expect(validateImage).toHaveBeenCalledWith(VALID_IMAGE); + expect(validateOptionsKeys).not.toHaveBeenCalled(); + expect(validateType).not.toHaveBeenCalled(); + expect(validateRegion).not.toHaveBeenCalled(); + expect(validateMaximumColorCount).not.toHaveBeenCalled(); + }); +}); diff --git a/src/utils/__tests__/validate.test.js b/src/utils/__tests__/validateCreatePaletteArgs.test.js similarity index 64% rename from src/utils/__tests__/validate.test.js rename to src/utils/__tests__/validateCreatePaletteArgs.test.js index 1720a77..fc489b7 100644 --- a/src/utils/__tests__/validate.test.js +++ b/src/utils/__tests__/validateCreatePaletteArgs.test.js @@ -1,5 +1,4 @@ -/* eslint flowtype/require-parameter-type: 0 */ -import validate, { +import { INVALID_IMAGE_MESSAGE, createOptionsErrorMessage, validateImage, @@ -7,16 +6,13 @@ import validate, { validateRegion, validateMaximumColorCount, validateType, -} from '../validate'; -import { defaultOptions } from '../../constants/defaults'; + validateDefaults, +} from '../validateCreatePaletteArgs'; +import { defaultProfile } from '../../constants/defaults'; const VALID_IMAGE = { uri: 'https://something.image.jpg' }; -describe('validation', () => { - it('should run all validators if all args are passed', () => { - expect(() => validate(VALID_IMAGE, defaultOptions)).not.toThrow(); - }); - +describe('validateCreatePaletteArgs', () => { it('Should throw if image param is not valid', () => { expect(() => validateImage(false)).toThrow(INVALID_IMAGE_MESSAGE); expect(() => validateImage({ urii: '' })).toThrow(INVALID_IMAGE_MESSAGE); @@ -94,4 +90,54 @@ describe('validation', () => { expect(() => validateType('vibrant')).not.toThrow(); expect(() => validateType(['vibrant', 'lightVibrant'])).not.toThrow(); }); + + it('this.props.defaults validation', () => { + const invalidDefaults1 = 'hej'; + const invalidDefaults2 = { + foo: 'bar', + vibrant: 'bar', + }; + const invalidDefaults3 = { + lightVibrant: 'foo', + muted: { + color: '#000000', + bodyTextColor: '#000000', + titleTextColor: '#000000', + }, + }; + const invalidDefaults4 = { + muted: { + color: '#000000', + bodyTextColor: '#000000', + titleTextColor: '#000000', + }, + darkVibrant: { + color: '#000000', + bodyTextColor: 12, + titleTextColor: '#000000', + }, + }; + const validDefaults = { + muted: defaultProfile, + darkMuted: defaultProfile, + lightMuted: defaultProfile, + darkVibrant: defaultProfile, + vibrant: defaultProfile, + lightVibrant: defaultProfile, + }; + + expect(() => validateDefaults(invalidDefaults1)).toThrow( + 'this.props.defaults should be an object', + ); + expect(() => validateDefaults(invalidDefaults2)).toThrow( + 'foo is not a valid color profile for this.props.defaults. Please refer to the API documentation', + ); + expect(() => validateDefaults(invalidDefaults3)).toThrow( + `Each default profile should define 'bodyTextColor', 'color' and 'titleTextColor' parameters. Please refer to the API documentation`, + ); + expect(() => validateDefaults(invalidDefaults4)).toThrow( + `'bodyTextColor', 'color' and 'titleTextColor' should all be strings`, + ); + expect(() => validateDefaults(validDefaults)).not.toThrow(); + }); }); diff --git a/src/utils/validateCreatePalette.js b/src/utils/validateCreatePalette.js new file mode 100644 index 0000000..fd05f09 --- /dev/null +++ b/src/utils/validateCreatePalette.js @@ -0,0 +1,26 @@ +/* @flow */ + +import { + validateImage, + validateOptionsKeys, + validateType, + validateMaximumColorCount, + validateRegion, +} from './validateCreatePaletteArgs'; +import type { Image, Options } from '../types'; + +export default function validateCreatePalette(image: Image, options: Options) { + validateImage(image); + if (Object.keys(options).length) { + validateOptionsKeys(options); + } + if (options.type) { + validateType(options.type); + } + if (options.maximumColorCount) { + validateMaximumColorCount(options.maximumColorCount); + } + if (options.region) { + validateRegion(options.region); + } +} diff --git a/src/utils/validate.js b/src/utils/validateCreatePaletteArgs.js similarity index 63% rename from src/utils/validate.js rename to src/utils/validateCreatePaletteArgs.js index f8538f9..9782201 100644 --- a/src/utils/validate.js +++ b/src/utils/validateCreatePaletteArgs.js @@ -1,26 +1,19 @@ -// @flow +/* @flow */ -import type { Image, Options, Region, ColorProfile } from '../types'; +import { validColorProfiles } from '../constants/defaults'; +import type { + Image, + Options, + Region, + ColorProfile, + PaletteDefaults, +} from '../types'; export const INVALID_IMAGE_MESSAGE = 'Invalid image param, you should either require a local asset, or provide an external URI'; export const createOptionsErrorMessage = (hint: string): string => `Invalid options param - ${hint}. Please refer to the API documentation`; -export default function validate(image: Image, options: Options) { - validateImage(image); - validateOptionsKeys(options); - if (options.type) { - validateType(options.type); - } - if (options.maximumColorCount) { - validateMaximumColorCount(options.maximumColorCount); - } - if (options.region) { - validateRegion(options.region); - } -} - export function validateImage(image: Image) { if (typeof image !== 'number' && typeof image !== 'object') { throw new Error(INVALID_IMAGE_MESSAGE); @@ -98,3 +91,35 @@ export function validateType(type: ColorProfile | Array) { }); } } + +export function validateDefaults(defaults: PaletteDefaults) { + if (typeof defaults !== 'object') { + throw new Error('this.props.defaults should be an object'); + } else { + const validProfilesKeys = ['bodyTextColor', 'color', 'titleTextColor']; + (Object.keys((defaults: any)): ColorProfile[]).forEach(profile => { + if (!(profile in validColorProfiles)) { + throw new Error( + `${profile} is not a valid color profile for this.props.defaults. Please refer to the API documentation`, + ); + } else { + const profileKeys = Object.keys(defaults[profile]).sort(); + const areTypesCorrect = profileKeys.every( + key => typeof defaults[profile][key] === 'string', + ); + const areEqual = validProfilesKeys.length === profileKeys.length && + validProfilesKeys.every((v, i) => v === profileKeys[i]); + if (!areEqual) { + throw new Error( + `Each default profile should define 'bodyTextColor', 'color' and 'titleTextColor' parameters. Please refer to the API documentation`, + ); + } + if (!areTypesCorrect) { + throw new Error( + `'bodyTextColor', 'color' and 'titleTextColor' should all be strings`, + ); + } + } + }); + } +} diff --git a/src/withPalette.js b/src/withPalette.js index a5308cf..6a4e5c1 100644 --- a/src/withPalette.js +++ b/src/withPalette.js @@ -14,10 +14,10 @@ type State = { /** * Connect component to a material palette notification channel in order to obtain - * pallete instance when palette is resolved. + * palette instance when palette is resolved. * Prop `palette` will be passed to wrapped component with either `null` or with palette instance. * - * If `mapPaletteToStyle` is specified, it will be evaluated when pallete is available and + * If `mapPaletteToStyle` is specified, it will be evaluated when palette is available and * the results will be passed to a `style` prop to wrapped component. */ export default function withMaterialPalette( @@ -56,7 +56,6 @@ export default function withMaterialPalette( this.unsubscribe = subscribe((data: { palette: PaletteInstance, - globalDefaults: PaletteDefaults, }) => { if (data) { this.setState(data); @@ -71,24 +70,19 @@ export default function withMaterialPalette( } _mergePaletteWithDefaults(): PaletteInstance { - const { palette, globalDefaults } = this.state; + const { palette } = this.state; return [ ...Object.keys(palette || {}), - ...Object.keys(globalDefaults || {}), ...Object.keys(localDefaults || {}), ].reduce( (acc: *, key: string) => { - // $FlowFixMe - const profile = (key: ColorProfile); + const profile = ((key: any): ColorProfile); return { ...acc, [key]: { population: 0, ...acc[key], - ...(globalDefaults && globalDefaults[profile] - ? globalDefaults[profile] - : {}), ...(localDefaults && localDefaults[profile] ? localDefaults[profile] : {}), diff --git a/yarn.lock b/yarn.lock index 71fe108..15fd374 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3106,14 +3106,14 @@ js-tokens@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-3.0.1.tgz#08e9f132484a2c45a30907e9dc4d5567b7f114d7" -js-yaml@3.6.1: +js-yaml@3.6.1, js-yaml@^3.5.1: version "3.6.1" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.6.1.tgz#6e5fe67d8b205ce4d22fad05b7781e8dadcc4b30" dependencies: argparse "^1.0.7" esprima "^2.6.0" -js-yaml@^3.5.1, js-yaml@^3.7.0: +js-yaml@^3.7.0: version "3.8.4" resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-3.8.4.tgz#520b4564f86573ba96662af85a8cafa7b4b5a6f6" dependencies: @@ -3505,13 +3505,13 @@ mime-db@~1.23.0: version "1.23.0" resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.23.0.tgz#a31b4070adaea27d732ea333740a64d0ec9a6659" -mime-types@2.1.11: +mime-types@2.1.11, mime-types@~2.1.7: version "2.1.11" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.11.tgz#c259c471bda808a85d6cd193b430a5fae4473b3c" dependencies: mime-db "~1.23.0" -mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.15, mime-types@~2.1.6, mime-types@~2.1.7, mime-types@~2.1.9: +mime-types@^2.1.12, mime-types@~2.1.11, mime-types@~2.1.15, mime-types@~2.1.6, mime-types@~2.1.9: version "2.1.15" resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.15.tgz#a4ebf5064094569237b8cf70046776d09fc92aed" dependencies: @@ -4410,7 +4410,7 @@ replace-ext@0.0.1: version "0.0.1" resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-0.0.1.tgz#29bbd92078a739f0bcce2b4ee41e837953522924" -request@2.79.0: +request@2.79.0, request@^2.79.0: version "2.79.0" resolved "https://registry.yarnpkg.com/request/-/request-2.79.0.tgz#4dfe5bf6be8b8cdc37fcf93e04b65577722710de" dependencies: @@ -4435,7 +4435,7 @@ request@2.79.0: tunnel-agent "~0.4.1" uuid "^3.0.0" -request@^2.79.0, request@^2.81.0: +request@^2.81.0: version "2.81.0" resolved "https://registry.yarnpkg.com/request/-/request-2.81.0.tgz#c6928946a0e06c5f8d6f8a9333469ffda46298a0" dependencies: