Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"changes": [
{
"packageName": "@uifabric/utilities",
"comment": "Allow a function to be passed to the Customizers props",
"type": "minor"
}
],
"packageName": "@uifabric/utilities",
"email": "[email protected]"
}
16 changes: 9 additions & 7 deletions packages/utilities/src/Customizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ import {
EventGroup
} from './EventGroup';

// tslint:disable-next-line:no-any
export type Settings = { [key: string]: any };
export type SettingsFunction = (settings: Settings) => Settings;

export interface ICustomizations {
// tslint:disable-next-line:no-any
settings: { [key: string]: any };
// tslint:disable-next-line:no-any
scopedSettings: { [key: string]: { [key: string]: any } };
settings: Settings;
scopedSettings: { [key: string]: Settings };
}

const CustomizationsGlobalKey = 'customizations';
Expand All @@ -29,13 +31,13 @@ export class Customizations {
}

// tslint:disable-next-line:no-any
public static applySettings(settings: { [key: string]: any }): void {
public static applySettings(settings: Settings): void {
_allSettings.settings = { ..._allSettings.settings, ...settings };
Customizations._raiseChange();
}

// tslint:disable-next-line:no-any
public static applyScopedSettings(scopeName: string, settings: { [key: string]: any }): void {
public static applyScopedSettings(scopeName: string, settings: Settings): void {
_allSettings.scopedSettings[scopeName] = { ..._allSettings.scopedSettings[scopeName], ...settings };
Customizations._raiseChange();
}
Expand All @@ -47,7 +49,7 @@ export class Customizations {
// tslint:disable-next-line:no-any
): any {
// tslint:disable-next-line:no-any
const settings: { [key: string]: any } = {};
const settings: Settings = {};
const localScopedSettings = (scopeName && localSettings.scopedSettings[scopeName]) || {};
const globalScopedSettings = (scopeName && _allSettings.scopedSettings[scopeName]) || {};

Expand Down
76 changes: 75 additions & 1 deletion packages/utilities/src/Customizer.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ describe('Customizer', () => {
});

it('can scope settings to specific components', () => {
let scopedSettings = {
const scopedSettings = {
Foo: { field: 'scopedToFoo' },
Bar: { field: 'scopedToBar' }
};
Expand Down Expand Up @@ -91,4 +91,78 @@ describe('Customizer', () => {
)).toEqual('<div>fieldfield2field3</div>');
});

it('can layer scoped settings with scopedSettingsFunction', () => {
Customizations.applySettings({ 'field3': 'field3' });

expect(ReactDOM.renderToStaticMarkup(
<Customizer scopedSettings={ { Bar: { field: 'field' } } }>
<Customizer
scopedSettings={
// tslint:disable-next-line:jsx-no-lambda
(scopedSettings: { Bar: { field2: string } }) => ({ Bar: { ...scopedSettings.Bar, field2: 'field2' } })
}
>
<Bar />
</Customizer >
</Customizer >
)).toEqual('<div>fieldfield2field3</div>');
});

it('it allows scopedSettings to be merged when a function is passed', () => {
expect(ReactDOM.renderToStaticMarkup(
<Customizer scopedSettings={ { Foo: { field: 'scopedToFoo' } } }>
<Customizer
scopedSettings={
// tslint:disable-next-line:jsx-no-lambda
(settings: { Foo: { field: string } }) => ({ ...settings, Bar: { field: 'scopedToBar' } })
}
>
<div>
<Foo />
<Bar />
</div>
</Customizer>
</Customizer>
)).toEqual('<div><div>scopedToFoo</div><div>scopedToBar</div></div>');
});

it('does not override previously set settings', () => {
expect(ReactDOM.renderToStaticMarkup(
<Customizer settings={ { field: 'field1' } }>
<Customizer settings={ { field: 'field2' } }>
<Bar />
</Customizer >
</Customizer >
)).toEqual('<div>field1</div>');
});

it('overrides the old settings when the parameter is ignored', () => {
expect(ReactDOM.renderToStaticMarkup(
<Customizer settings={ { field: 'field1' } }>
<Customizer
settings={
// tslint:disable-next-line:jsx-no-lambda
(settings: { field: string }) => ({ field: 'field2' })
}
>
<Bar />
</Customizer >
</Customizer >
)).toEqual('<div>field2</div>');
});

it('can use a function to merge settings', () => {
expect(ReactDOM.renderToStaticMarkup(
<Customizer settings={ { field: 'field1' } }>
<Customizer
settings={
// tslint:disable-next-line:jsx-no-lambda
(settings: { field: string }) => ({ field: settings.field + 'field2' })
}
>
<Bar />
</Customizer >
</Customizer >
)).toEqual('<div>field1field2</div>');
});
});
110 changes: 86 additions & 24 deletions packages/utilities/src/Customizer.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,55 @@
import * as React from 'react';
import * as PropTypes from 'prop-types';
import { BaseComponent, IBaseProps } from './BaseComponent';
import { ICustomizations } from './Customizations';
import { ICustomizations, Settings, SettingsFunction } from './Customizations';

export interface ICustomizerContext {
customizations: ICustomizations;
}

export type ICustomizerProps = Partial<ICustomizations> & IBaseProps;
export type ICustomizerProps = IBaseProps & Partial<{
/**
* @description
* Settings are used as general settings for the React tree below.
* Components can subscribe to receive the settings by using `customizable`.
*
* @example
* Settings can be represented by a plain object that contains the key value pairs.
* ```
* <Customizer settings={{ color: 'red' }} />
* ```
* or a function that receives the current settings and returns the new ones
* ```
* <Customizer settings={(currentSettings) => ({ ...currentSettings, color: 'red' })} />
* ```
*/
settings: Settings | SettingsFunction;
/**
* @description
* Scoped settings are settings that are scoped to a specific scope. The
* scope is the name that is passed to the `customizable` function when the
* the component is customized.
*
* @example
* Scoped settings can be represented by a plain object that contains the key value pairs.
* ```
* const myScopedSettings = {
* Button: { color: 'red' };
* };
*
* <Customizer scopedSettings={myScopedSettings} />
* ```
* or a function that receives the current settings and returns the new ones
* ```
* const myScopedSettings = {
* Button: { color: 'red' };
* };
*
* <Customizer scopedSettings={(currentScopedSettings) => ({ ...currentScopedSettings, ...myScopedSettings })} />
* ```
*/
scopedSettings: Settings | SettingsFunction
}>;

/**
* The Customizer component allows for default props to be mixed into components which
Expand All @@ -16,17 +58,18 @@ export type ICustomizerProps = Partial<ICustomizations> & IBaseProps;
* 1. render svg icons instead of the icon font within all buttons
* 2. inject a custom theme object into a component
*
* Props are provided via the settings prop, which should be a json map which contains 1 or more
* name/value pairs representing injectable props.
* Props are provided via the settings prop which should be one of the following:
* - A json map which contains 1 or more name/value pairs representing injectable props.
* - A function that receives the current settings and returns the new ones that apply to the scope
*
* @public
*/
export class Customizer extends BaseComponent<ICustomizerProps, ICustomizerContext> {
public static contextTypes: {
customizations: PropTypes.Requireable<{}>;
} = {
customizations: PropTypes.object
};
customizations: PropTypes.object
};

public static childContextTypes: {
customizations: PropTypes.Requireable<{}>;
Expand Down Expand Up @@ -56,30 +99,49 @@ export class Customizer extends BaseComponent<ICustomizerProps, ICustomizerConte
props: ICustomizerProps,
context: ICustomizerContext
): ICustomizerContext {
let {
settings = {},
scopedSettings = {}
} = props;
let {
const {
customizations = { settings: {}, scopedSettings: {} }
} = context;

let newScopedSettings = { ...scopedSettings };

for (let name in customizations.scopedSettings) {
if (customizations.scopedSettings.hasOwnProperty(name)) {
newScopedSettings[name] = { ...scopedSettings[name], ...customizations.scopedSettings[name] };
}
}

return {
customizations: {
settings: {
...settings,
...customizations.settings
},
scopedSettings: newScopedSettings
settings: mergeSettings(customizations.settings, props.settings),
scopedSettings: mergeScopedSettings(customizations.scopedSettings, props.scopedSettings),
}
};
}
}

function mergeSettings(oldSettings: Settings = {}, newSettings?: Settings | SettingsFunction): Settings {
const mergeSettingsWith = isSettingsFunction(newSettings) ? newSettings : settingsMergeWith(newSettings);

return mergeSettingsWith(oldSettings);
}

function mergeScopedSettings(oldSettings: Settings = {}, newSettings?: Settings | SettingsFunction): Settings {
const mergeSettingsWith = isSettingsFunction(newSettings) ? newSettings : scopedSettingsMergeWith(newSettings);

return mergeSettingsWith(oldSettings);
}

function isSettingsFunction(settings?: Settings | SettingsFunction): settings is SettingsFunction {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

👍

return typeof settings === 'function';
}

function settingsMergeWith(newSettings?: object): (settings: Settings) => Settings {
return (settings: Settings) => newSettings ? { ...newSettings, ...settings } : settings;
}

function scopedSettingsMergeWith(scopedSettingsFromProps: Settings = {}): (scopedSettings: Settings) => Settings {
return (oldScopedSettings: Settings): Settings => {
const newScopedSettings: Settings = { ...oldScopedSettings };

for (let scopeName in scopedSettingsFromProps) {
if (scopedSettingsFromProps.hasOwnProperty(scopeName)) {
newScopedSettings[scopeName] = { ...oldScopedSettings[scopeName], ...scopedSettingsFromProps[scopeName] };
}
}

return newScopedSettings;
};
}