diff --git a/.gitignore b/.gitignore index 9c509ce..f16c7ca 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ build-storybook.log .DS_Store .env .cache -yarn-error.log \ No newline at end of file +yarn-error.log +*.orig \ No newline at end of file diff --git a/jest.config.js b/jest.config.js index 96a61dc..e258477 100644 --- a/jest.config.js +++ b/jest.config.js @@ -2,6 +2,7 @@ module.exports = { cacheDirectory: '.cache/jest', clearMocks: true, testMatch: ['**/__tests__/**/*.[jt]s?(x)', '**/?(*.)+(spec|test).[jt]s?(x)'], + testPathIgnorePatterns: ["/node_modules/", "/dist"], collectCoverage: false, collectCoverageFrom: [ 'src/**/*.{js,jsx,ts,tsx}', @@ -19,4 +20,4 @@ module.exports = { setupFiles: [], testURL: 'http://localhost', moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json', 'node'], -}; \ No newline at end of file +}; diff --git a/src/KnobManager.ts b/src/KnobManager.ts index db2f872..2e61490 100644 --- a/src/KnobManager.ts +++ b/src/KnobManager.ts @@ -10,6 +10,7 @@ import { Knob, KnobType, Mutable } from './type-defs'; import { SET } from './shared'; import { deserializers } from './converters'; +import { Codec } from './components/types'; const knobValuesFromUrl: Record = Object.entries(getQueryParams()).reduce( (acc, [k, v]) => { @@ -103,7 +104,8 @@ export default class KnobManager { }; if (knobValuesFromUrl[knobName]) { - const value = deserializers[options.type](knobValuesFromUrl[knobName]); + const deserialize = deserializers[options.type] as Codec['deserialize']; + const value = deserialize(knobValuesFromUrl[knobName], options); knobInfo.defaultValue = value; knobInfo.value = value; diff --git a/src/components/Panel.tsx b/src/components/Panel.tsx index bcb9bd3..eaf48d9 100644 --- a/src/components/Panel.tsx +++ b/src/components/Panel.tsx @@ -134,7 +134,7 @@ export default class KnobPanel extends PureComponent { // If the knob value present in url if (urlValue !== undefined) { - const value = getKnobControl(knob.type).deserialize(urlValue); + const value = getKnobControl(knob.type).deserialize(urlValue, knob); knob.value = value; queryParams[`knob-${name}`] = getKnobControl(knob.type).serialize(value); diff --git a/src/components/types/Array.test.tsx b/src/components/types/Array.test.tsx index 15c83be..fefcfbc 100644 --- a/src/components/types/Array.test.tsx +++ b/src/components/types/Array.test.tsx @@ -21,20 +21,15 @@ describe('Array', () => { expect(onChange).toHaveBeenLastCalledWith(['Fishing', 'Skiing', '']); }); - it('deserializes an Array to an Array', () => { - const array = ['a', 'b', 'c']; - const deserialized = ArrayType.deserialize(array); - expect(deserialized).toEqual(['a', 'b', 'c']); - }); - it('deserializes an Object to an Array', () => { - const object = { 1: 'one', 0: 'zero', 2: 'two' }; - const deserialized = ArrayType.deserialize(object); - expect(deserialized).toEqual(['zero', 'one', 'two']); - }); + + + + + it('should change to an empty array when emptied', () => { const onChange = jest.fn(); diff --git a/src/components/types/Array.tsx b/src/components/types/Array.tsx index 52aace3..89c93f1 100644 --- a/src/components/types/Array.tsx +++ b/src/components/types/Array.tsx @@ -36,14 +36,17 @@ export default class ArrayType extends Component { onChange: PropTypes.func as Validator, }; - static serialize = (value: ArrayTypeKnobValue) => value; + static serialize = (value: ArrayTypeKnobValue) => JSON.stringify(value ?? []); - static deserialize = (value: string[] | Record) => { - if (Array.isArray(value)) return value; + static deserialize = (value: string) => { + if (!value) { return [] } - return Object.keys(value) - .sort() - .reduce((array, key) => [...array, value[key]], [] as string[]); + // For extra safety. + if (typeof value !== 'string') { + value = ArrayType.serialize(value); + } + + return JSON.parse(value); }; shouldComponentUpdate(nextProps: Readonly) { diff --git a/src/components/types/Boolean.tsx b/src/components/types/Boolean.tsx index 452dbd7..943f649 100644 --- a/src/components/types/Boolean.tsx +++ b/src/components/types/Boolean.tsx @@ -23,8 +23,8 @@ const Input = styled.input({ color: '#555', }); -const serialize = (value: BooleanTypeKnobValue): string | null => (value ? String(value) : null); -const deserialize = (value: string | null) => value === 'true'; +const serialize = (value: BooleanTypeKnobValue): string | undefined => (value ? String(value) : undefined); +const deserialize = (value: string | undefined) => value === 'true'; const BooleanType: FunctionComponent & { serialize: typeof serialize; diff --git a/src/components/types/Checkboxes.tsx b/src/components/types/Checkboxes.tsx index 56fc40e..8ec3ab5 100644 --- a/src/components/types/Checkboxes.tsx +++ b/src/components/types/Checkboxes.tsx @@ -65,9 +65,12 @@ export default class CheckboxesType extends Component, }; - static serialize = (value: CheckboxesTypeKnobValue) => value; + static serialize = (value: CheckboxesTypeKnobValue) => JSON.stringify(value); - static deserialize = (value: CheckboxesTypeKnobValue) => value; + static deserialize = (value: string) => { + if (!value) { return undefined } + return JSON.parse(value); + } constructor(props: CheckboxesTypeProps) { super(props); diff --git a/src/components/types/Color.tsx b/src/components/types/Color.tsx index a7869c9..a949df5 100644 --- a/src/components/types/Color.tsx +++ b/src/components/types/Color.tsx @@ -61,7 +61,13 @@ export default class ColorType extends Component static serialize = (value: ColorTypeKnobValue) => value; - static deserialize = (value: ColorTypeKnobValue) => value; + static deserialize = (value: ColorTypeKnobValue) => { + + if (!value) { return undefined } + + value; + + } state: ColorTypeState = { displayColorPicker: false, diff --git a/src/components/types/Date.tsx b/src/components/types/Date.tsx index 87380df..c73520f 100644 --- a/src/components/types/Date.tsx +++ b/src/components/types/Date.tsx @@ -53,11 +53,18 @@ export default class DateType extends Component { onChange: PropTypes.func as Validator, }; - static serialize = (value: DateTypeKnobValue) => - new Date(value).getTime() || new Date().getTime(); + static serialize = (value: DateTypeKnobValue) => { + if (!value) { return undefined; } + return String(new Date(value).getTime()); + } - static deserialize = (value: DateTypeKnobValue) => - new Date(value).getTime() || new Date().getTime(); + static deserialize = (value: string) => { + if (!value) { return undefined } + if (/-?\d+\.?\d*/.test(value)) { + return parseFloat(value) + } + return new Date(value).getTime() ?? new Date().getTime(); + } static getDerivedStateFromProps() { return { valid: true }; diff --git a/src/components/types/Number.tsx b/src/components/types/Number.tsx index 53389c2..8683763 100644 --- a/src/components/types/Number.tsx +++ b/src/components/types/Number.tsx @@ -72,7 +72,8 @@ export default class NumberType extends Component { static serialize = (value: NumberTypeKnobValue | null | undefined) => value === null || value === undefined ? '' : String(value); - static deserialize = (value: string) => (value === '' ? null : parseFloat(value)); + static deserialize = (value: string) => + (value === '' || value === null || value === undefined ? undefined : parseFloat(value)); shouldComponentUpdate(nextProps: NumberTypeProps) { const { knob } = this.props; diff --git a/src/components/types/Options.tsx b/src/components/types/Options.tsx index a13be29..09d2697 100644 --- a/src/components/types/Options.tsx +++ b/src/components/types/Options.tsx @@ -63,8 +63,8 @@ interface OptionsSelectValueItem { label: string; } -const serialize: { (value: T): T } = (value) => value; -const deserialize: { (value: T): T } = (value) => value; +const serialize = (value: any) => !value ? undefined : JSON.stringify(value); +const deserialize = (value: string) => !value ? undefined : JSON.parse(value); const OptionsType: FunctionComponent> & { serialize: typeof serialize; diff --git a/src/components/types/Radio.tsx b/src/components/types/Radio.tsx index 68bff78..e28581f 100644 --- a/src/components/types/Radio.tsx +++ b/src/components/types/Radio.tsx @@ -55,9 +55,27 @@ class RadiosType extends Component { isInline: PropTypes.bool as Validator, }; - static serialize = (value: RadiosTypeKnobValue) => value; + static serialize = (value: RadiosTypeKnobValue) => !value ? undefined : JSON.stringify(value); + + static deserialize = (value: string, knob: any) => { + if (!value) { + return undefined; + } + + if (!knob) { + // Without options, the best that we can do is use the value as-is. + return JSON.parse(value); + } + + if (typeof value !== 'string') { + value = String(value); + } + + const optionsObject = (knob as RadiosTypeKnob).options; + const options = Array.isArray(optionsObject) ? optionsObject : Object.values(optionsObject); + return options.find(option => RadiosType.serialize(option) === String(value)) ?? value; + }; - static deserialize = (value: RadiosTypeKnobValue) => value; private renderRadioButtonList({ options }: RadiosTypeKnob) { if (Array.isArray(options)) { diff --git a/src/components/types/Select.tsx b/src/components/types/Select.tsx index c99bcd4..9fecdcc 100644 --- a/src/components/types/Select.tsx +++ b/src/components/types/Select.tsx @@ -3,6 +3,8 @@ import PropTypes from 'prop-types'; import { Form } from '@storybook/components'; import { KnobControlConfig, KnobControlProps } from './types'; +import { Knob } from 'src/type-defs'; +import { Codec } from '.'; export type SelectTypeKnobValue = string | number | boolean | null | undefined | PropertyKey[] | Record; @@ -22,13 +24,42 @@ export interface SelectTypeProps; } -const serialize = (value: SelectTypeKnobValue) => value; -const deserialize = (value: SelectTypeKnobValue) => value; +const serialize = (value: SelectTypeKnobValue): string | undefined => { + if (!value) { return undefined; } + if (typeof value === 'object') { // Works for arrays and objects. + return JSON.stringify(value); + } + return String(value); +}; + +const deserialize = (value: string, knob?: Knob) => { + if (!value) { + return undefined; + } + + if (!knob) { + // Without options to pick from, we can only make educated guesses. + if (value.indexOf(']') > 0 || value.indexOf('}') > 0) { + return JSON.parse(value); + } else if (/-?\d+\.\d*/.test(value)) { + return Number(value); + } else if (value === 'true' || value === 'false') { + return value === 'true'; + } else { + return value; + } + } + + const castKnob = knob as unknown as SelectTypeKnob; // Safe because only called for SelectType. + const options = Array.isArray(castKnob.options) ? castKnob.options : Object.values(castKnob.options); -const SelectType: FunctionComponent & { - serialize: typeof serialize; - deserialize: typeof deserialize; -} = ({ knob, onChange }) => { + // Now to find the option that matches. Returns 'undefined if doesn't match any values'. + // This is done this way to support complex types (like objects with array values etc.). + return options.find(option => serialize(option) === value); +}; + +const SelectType: FunctionComponent & Codec + = ({ knob, onChange }) => { const { options } = knob; const callbackReduceArrayOptions = (acc: any, option: any, i: number) => { @@ -46,7 +77,9 @@ const SelectType: FunctionComponent & { if (Array.isArray(knobVal)) { return JSON.stringify(entryVal) === JSON.stringify(knobVal); } - return entryVal === knobVal; + + // NOTE: Using loose equals here to match number values to string-serialized values. + return entryVal == knobVal; }); return ( diff --git a/src/components/types/index.ts b/src/components/types/index.ts index c5543e7..bae504b 100644 --- a/src/components/types/index.ts +++ b/src/components/types/index.ts @@ -3,6 +3,7 @@ import { ComponentType } from 'react'; import TextType from './Text'; import NumberType from './Number'; import ColorType from './Color'; +import CheckboxType from './Checkboxes'; import BooleanType from './Boolean'; import ObjectType from './Object'; import SelectType from './Select'; @@ -12,10 +13,12 @@ import DateType from './Date'; import ButtonType from './Button'; import FilesType from './Files'; import OptionsType from './Options'; +import { Knob } from 'src/type-defs'; const KnobControls = { text: TextType, number: NumberType, + checkbox: CheckboxType, color: ColorType, boolean: BooleanType, object: ObjectType, @@ -31,10 +34,11 @@ export default KnobControls; export type KnobType = keyof typeof KnobControls; -export type KnobControlType = ComponentType & { - serialize: (v: any) => any; - deserialize: (v: any) => any; +export type Codec = { + serialize: (value: any) => string | undefined; + deserialize: (serializedValue: string, knob?: Knob) => any; }; +export type KnobControlType = ComponentType & Codec; // Note: this is a utility function that helps in resolving types more orderly export const getKnobControl = (type: KnobType) => KnobControls[type] as KnobControlType; diff --git a/src/converters.test.ts b/src/converters.test.ts new file mode 100644 index 0000000..dfeefad --- /dev/null +++ b/src/converters.test.ts @@ -0,0 +1,101 @@ +import { deserializers, serializers } from './converters'; +import { KnobType } from './type-defs'; +import { Knob } from './type-defs'; + +function serializeAndDeserialize(type: KnobType, value: T, knob?: Partial) { + const serialize = serializers[type]; + const deserialize = deserializers[type]; + + const serializedValue = serialize(value); + const deserializedValue = deserialize(serializedValue, knob); + + return deserializedValue; +} + +test('Converter Array', () => { + const undefinedValue = serializeAndDeserialize('array', undefined); + expect(undefinedValue).toEqual([]); + const numberArray = serializeAndDeserialize('array', [1, 2, 3]); + expect(numberArray).toEqual([1, 2, 3]); + const stringArray = serializeAndDeserialize('array', ["1", "2", "3"]); + expect(stringArray).toEqual(["1", "2", "3"]); +}) + +test('Converter Number', () => { + const undefinedValue = serializeAndDeserialize('number', undefined); + expect(undefinedValue).toEqual(undefined); + const numberValue = serializeAndDeserialize('number', 23); + expect(numberValue).toEqual(23); +}) + +test('Converter Checkbox', () => { + const undefinedValue = serializeAndDeserialize('checkbox', undefined); + expect(undefinedValue).toEqual(undefined); + const numberObject = serializeAndDeserialize('checkbox', { one: 1, two: 2, three: 3 }); + expect(numberObject).toEqual({ one: 1, two: 2, three: 3 }); + const stringObject = serializeAndDeserialize('checkbox', { one: "1", two: "2", three: "3" }); + expect(stringObject).toEqual({ one: "1", two: "2", three: "3" }); +}) + +test('Converter Date', () => { + const undefinedValue = serializeAndDeserialize('date', undefined); + expect(undefinedValue).toEqual(undefined); + const time = new Date().getTime(); + const epochValue = serializeAndDeserialize('date', time); + expect(epochValue).toEqual(epochValue); +}) + +test('Converter Boolean', () => { + const undefinedValue = serializeAndDeserialize('boolean', undefined); + expect(undefinedValue).toEqual(false); // Getting a 'false' value. + const trueValue = serializeAndDeserialize('boolean', true); + expect(trueValue).toBeTruthy(); + const falseValue = serializeAndDeserialize('boolean', false); + expect(falseValue).toBeFalsy(); +}) + +test('Converter Object', () => { + const undefinedValue = serializeAndDeserialize('object', undefined); + expect(undefinedValue).toEqual({}); + const numberObject = serializeAndDeserialize('object', { one: 1, two: 2, three: 3 }); + expect(numberObject).toEqual({ one: 1, two: 2, three: 3 }); + const stringObject = serializeAndDeserialize('object', { one: "1", two: "2", three: "3" }); + expect(stringObject).toEqual({ one: "1", two: "2", three: "3" }); +}) + +test('Converter Select', () => { + const undefinedValue = serializeAndDeserialize('select', undefined); + expect(undefinedValue).toEqual(undefined); + const stringValue = serializeAndDeserialize('select', 'string value'); + expect(stringValue).toEqual('string value'); + const numberValue = serializeAndDeserialize('select', 1_005, { options: [1, 2, 1005 ]}); + expect(numberValue).toEqual(1_005); + const numberValueAsString = serializeAndDeserialize('select', 1_005, { options: ["1", "2", "1005" ]}); + expect(numberValueAsString).toEqual("1005"); + const numberArray = serializeAndDeserialize('select', [1, 2, 3]); + expect(numberArray).toEqual([1, 2, 3]); + const stringArray = serializeAndDeserialize('select', ["1", "2", "3"]); + expect(stringArray).toEqual(["1", "2", "3"]); + const numberObject = serializeAndDeserialize('select', { one: 1, two: 2, three: 3 }); + expect(numberObject).toEqual({ one: 1, two: 2, three: 3 }); + const stringObject = serializeAndDeserialize('select', { one: "1", two: "2", three: "3" }); + expect(stringObject).toEqual({ one: "1", two: "2", three: "3" }); +}) + +test('Converter Radios', () => { + const undefinedValue = serializeAndDeserialize('radios', undefined); + expect(undefinedValue).toEqual(undefined); + const numberObject = serializeAndDeserialize('radios', { one: 1, two: 2, three: 3 }); + expect(numberObject).toEqual({ one: 1, two: 2, three: 3 }); + const stringObject = serializeAndDeserialize('radios', { one: "1", two: "2", three: "3" }); + expect(stringObject).toEqual({ one: "1", two: "2", three: "3" }); +}) + +test('Converter Object', () => { + const undefinedValue = serializeAndDeserialize('object', undefined); + expect(undefinedValue).toEqual({}); + const numberObject = serializeAndDeserialize('object', { one: 1, two: 2, three: 3 }); + expect(numberObject).toEqual({ one: 1, two: 2, three: 3 }); + const stringObject = serializeAndDeserialize('object', { one: "1", two: "2", three: "3" }); + expect(stringObject).toEqual({ one: "1", two: "2", three: "3" }); +}) \ No newline at end of file diff --git a/src/converters.ts b/src/converters.ts index e84b159..71567bf 100644 --- a/src/converters.ts +++ b/src/converters.ts @@ -1,5 +1,20 @@ -const unconvertable = (): undefined => undefined; +import { Codec } from './components/types'; +import ArrayType from './components/types/Array'; +import BooleanType from './components/types/Boolean'; +import ButtonType from './components/types/Button'; +import CheckboxType from './components/types/Checkboxes'; +import ColorType from './components/types/Color'; +import DateType from './components/types/Date'; +import FilesType from './components/types/Files'; +import NumberType from './components/types/Number'; +import ObjectType from './components/types/Object'; +import OptionsType from './components/types/Options'; +import RadioType from './components/types/Radio'; +import SelectType from './components/types/Select'; +import TextType from './components/types/Text'; +import { KnobType } from './type-defs'; +// Unused. Kept here to maintain compatibility. export const converters = { jsonParse: (value: any): any => JSON.parse(value), jsonStringify: (value: any): string => JSON.stringify(value), @@ -19,34 +34,35 @@ export const converters = { toFloat: (value: any): number | null => (value === '' ? null : parseFloat(value)), }; -export const serializers = { - array: converters.simple, - boolean: converters.stringifyIfTruthy, - button: unconvertable, - checkbox: converters.simple, - color: converters.simple, - date: converters.toDate, - files: unconvertable, - number: converters.stringifyIfSet, - object: converters.jsonStringify, - options: converters.simple, - radios: converters.simple, - select: converters.simple, - text: converters.simple, +// Unused. Kept here to maintain compatibility. +export const serializers: Record = { + array: ArrayType.serialize, + boolean: BooleanType.serialize, + button: ButtonType.serialize, + checkbox: CheckboxType.serialize, + color: ColorType.serialize, + date: DateType.serialize, + files: FilesType.serialize, + number: NumberType.serialize, + object: ObjectType.serialize, + options: OptionsType.serialize, + radios: RadioType.serialize, + select: SelectType.serialize, + text: TextType.serialize, }; -export const deserializers = { - array: converters.toArray, - boolean: converters.toBoolean, - button: unconvertable, - checkbox: converters.simple, - color: converters.simple, - date: converters.toDate, - files: unconvertable, - number: converters.toFloat, - object: converters.jsonParse, - options: converters.simple, - radios: converters.simple, - select: converters.simple, - text: converters.simple, +export const deserializers: Record = { + array: ArrayType.deserialize, + boolean: BooleanType.deserialize, + button: ButtonType.deserialize, + checkbox: CheckboxType.deserialize, + color: ColorType.deserialize, + date: DateType.deserialize, + files: FilesType.deserialize, + number: NumberType.deserialize, + object: ObjectType.deserialize, + options: OptionsType.deserialize, + radios: RadioType.deserialize, + select: SelectType.deserialize, + text: TextType.deserialize, }; diff --git a/stories/addon-knobs/with-knobs.stories.js b/stories/addon-knobs/with-knobs.stories.js index c7c3ba4..dc506ab 100644 --- a/stories/addon-knobs/with-knobs.stories.js +++ b/stories/addon-knobs/with-knobs.stories.js @@ -241,7 +241,7 @@ export const ComplexSelect = () => { string: 'string', object: {}, array: [1, 2, 3], - function: () => {}, + function: () => { }, }, 'string' ); @@ -394,3 +394,16 @@ WithDuplicateDecorator.decorators = [withKnobs]; export const WithKnobValueToBeEncoded = () => { return text('Text', '10% 20%'); }; + + +export const WithNumericSelect = () => { + return select( + 'Select a number', + { + ONE: 1, + TWO: 2, + THREE: 3, + }, + 1 + ); +};