Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(addon-knobs): Knob values not properly deserialized from the URL #38

Open
wants to merge 8 commits into
base: next
Choose a base branch
from
Open
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
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,5 @@ build-storybook.log
.DS_Store
.env
.cache
yarn-error.log
yarn-error.log
*.orig
3 changes: 2 additions & 1 deletion jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}',
Expand All @@ -19,4 +20,4 @@ module.exports = {
setupFiles: [],
testURL: 'http://localhost',
moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx', 'json', 'node'],
};
};
4 changes: 3 additions & 1 deletion src/KnobManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> = Object.entries(getQueryParams()).reduce(
(acc, [k, v]) => {
Expand Down Expand Up @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion src/components/Panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ export default class KnobPanel extends PureComponent<KnobPanelProps> {

// 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);

Expand Down
15 changes: 5 additions & 10 deletions src/components/types/Array.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
15 changes: 9 additions & 6 deletions src/components/types/Array.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,17 @@ export default class ArrayType extends Component<ArrayTypeProps> {
onChange: PropTypes.func as Validator<ArrayTypeProps['onChange']>,
};

static serialize = (value: ArrayTypeKnobValue) => value;
static serialize = (value: ArrayTypeKnobValue) => JSON.stringify(value ?? []);

static deserialize = (value: string[] | Record<string, string>) => {
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<ArrayTypeProps>) {
Expand Down
4 changes: 2 additions & 2 deletions src/components/types/Boolean.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<BooleanTypeProps> & {
serialize: typeof serialize;
Expand Down
7 changes: 5 additions & 2 deletions src/components/types/Checkboxes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,9 +65,12 @@ export default class CheckboxesType extends Component<CheckboxesTypeProps, Check
isInline: PropTypes.bool as Validator<CheckboxesTypeProps['isInline']>,
};

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);
Expand Down
8 changes: 7 additions & 1 deletion src/components/types/Color.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,13 @@ export default class ColorType extends Component<ColorTypeProps, ColorTypeState>

static serialize = (value: ColorTypeKnobValue) => value;

static deserialize = (value: ColorTypeKnobValue) => value;
static deserialize = (value: ColorTypeKnobValue) => {

if (!value) { return undefined }

value;

}

state: ColorTypeState = {
displayColorPicker: false,
Expand Down
15 changes: 11 additions & 4 deletions src/components/types/Date.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,11 +53,18 @@ export default class DateType extends Component<DateTypeProps, DateTypeState> {
onChange: PropTypes.func as Validator<DateTypeProps['onChange']>,
};

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 };
Expand Down
3 changes: 2 additions & 1 deletion src/components/types/Number.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,8 @@ export default class NumberType extends Component<NumberTypeProps> {
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;
Expand Down
4 changes: 2 additions & 2 deletions src/components/types/Options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,8 @@ interface OptionsSelectValueItem {
label: string;
}

const serialize: { <T>(value: T): T } = (value) => value;
const deserialize: { <T>(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<OptionsTypeProps<any>> & {
serialize: typeof serialize;
Expand Down
22 changes: 20 additions & 2 deletions src/components/types/Radio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,9 +55,27 @@ class RadiosType extends Component<RadiosTypeProps> {
isInline: PropTypes.bool as Validator<RadiosTypeProps['isInline']>,
};

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)) {
Expand Down
47 changes: 40 additions & 7 deletions src/components/types/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>;

Expand All @@ -22,13 +24,42 @@ export interface SelectTypeProps<T extends SelectTypeKnobValue = SelectTypeKnobV
knob: SelectTypeKnob<T>;
}

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<SelectTypeProps> & {
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<SelectTypeProps> & Codec
= ({ knob, onChange }) => {
const { options } = knob;

const callbackReduceArrayOptions = (acc: any, option: any, i: number) => {
Expand All @@ -46,7 +77,9 @@ const SelectType: FunctionComponent<SelectTypeProps> & {
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 (
Expand Down
10 changes: 7 additions & 3 deletions src/components/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand All @@ -31,10 +34,11 @@ export default KnobControls;

export type KnobType = keyof typeof KnobControls;

export type KnobControlType = ComponentType<any> & {
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<any> & Codec;

// Note: this is a utility function that helps in resolving types more orderly
export const getKnobControl = (type: KnobType) => KnobControls[type] as KnobControlType;
Expand Down
Loading