diff --git a/code/core/src/preview-api/modules/store/inferControls.test.ts b/code/core/src/preview-api/modules/store/inferControls.test.ts index 40b3ff8ea3fd..4b94e96e2c03 100644 --- a/code/core/src/preview-api/modules/store/inferControls.test.ts +++ b/code/core/src/preview-api/modules/store/inferControls.test.ts @@ -7,130 +7,225 @@ import type { StoryContextForEnhancers } from 'storybook/internal/types'; import { argTypesEnhancers } from './inferControls.ts'; const getStoryContext = (overrides: any = {}): StoryContextForEnhancers => ({ - id: '', - title: '', - kind: '', - name: '', - story: '', - initialArgs: {}, - argTypes: { - label: { control: 'text' }, - labelName: { control: 'text' }, - borderWidth: { control: { type: 'number', min: 0, max: 10 } }, - }, - ...overrides, - parameters: { - __isArgsStory: true, - ...overrides.parameters, - }, + id: '', + title: '', + kind: '', + name: '', + story: '', + initialArgs: {}, + argTypes: { + label: { control: 'text' }, + labelName: { control: 'text' }, + borderWidth: { control: { type: 'number', min: 0, max: 10 } }, + }, + ...overrides, + parameters: { + __isArgsStory: true, + ...overrides.parameters, + }, }); const [inferControls] = argTypesEnhancers; describe('inferControls', () => { - describe('with custom matchers', () => { - let warnSpy: MockInstance; - beforeEach(() => { - warnSpy = vi.spyOn(logger, 'warn'); - warnSpy.mockImplementation(() => {}); - }); - afterEach(() => { - warnSpy.mockRestore(); - }); - - it('should return color type when using color matcher', () => { - // passing a string, should return control type color - const inferredControls = inferControls( - getStoryContext({ - argTypes: { - background: { - type: { - name: 'string', - }, - name: 'background', - }, - }, - parameters: { - controls: { - matchers: { - color: /background/, - }, - }, - }, - }) - ); - - const control = inferredControls.background.control; - expect(typeof control === 'object' && control.type).toEqual('color'); - }); - - it('should return inferred type when using color matcher but arg passed is not a string', () => { - const sampleTypes = [ - { - name: 'object', - value: { - rgb: { - name: 'number', - }, - }, - }, - { name: 'number' }, - { name: 'boolean' }, - ]; - - sampleTypes.forEach((type) => { - const inferredControls = inferControls( - getStoryContext({ - argTypes: { - background: { - // passing an object which is unsupported - // should ignore color control and infer the type instead - type, - name: 'background', - }, - }, - parameters: { - controls: { - matchers: { - color: /background/, - }, - }, - }, - }) - ); - - expect(warnSpy).toHaveBeenCalled(); - const control = inferredControls.background.control; - expect(typeof control === 'object' && control.type).toEqual(type.name); - }); - }); - }); - - it('should return argTypes as is when no exclude or include is passed', () => { - const controls = inferControls(getStoryContext()); - expect(Object.keys(controls)).toEqual(['label', 'labelName', 'borderWidth']); - }); - - it('should return filtered argTypes when include is passed', () => { - const [includeString, includeArray, includeRegex] = [ - inferControls(getStoryContext({ parameters: { controls: { include: 'label' } } })), - inferControls(getStoryContext({ parameters: { controls: { include: ['label'] } } })), - inferControls(getStoryContext({ parameters: { controls: { include: /label*/ } } })), - ]; - - expect(Object.keys(includeString)).toEqual(['label', 'labelName']); - expect(Object.keys(includeArray)).toEqual(['label']); - expect(Object.keys(includeRegex)).toEqual(['label', 'labelName']); - }); - - it('should return filtered argTypes when exclude is passed', () => { - const [excludeString, excludeArray, excludeRegex] = [ - inferControls(getStoryContext({ parameters: { controls: { exclude: 'label' } } })), - inferControls(getStoryContext({ parameters: { controls: { exclude: ['label'] } } })), - inferControls(getStoryContext({ parameters: { controls: { exclude: /label*/ } } })), - ]; - - expect(Object.keys(excludeString)).toEqual(['borderWidth']); - expect(Object.keys(excludeArray)).toEqual(['labelName', 'borderWidth']); - expect(Object.keys(excludeRegex)).toEqual(['borderWidth']); - }); -}); + describe('with custom matchers', () => { + let warnSpy: MockInstance; + beforeEach(() => { + warnSpy = vi.spyOn(logger, 'warn'); + warnSpy.mockImplementation(() => {}); + }); + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('should return color type when using color matcher', () => { + const inferredControls = inferControls( + getStoryContext({ + argTypes: { + background: { + type: { + name: 'string', + }, + name: 'background', + }, + }, + parameters: { + controls: { + matchers: { + color: /background/, + }, + }, + }, + }) + ); + + const control = inferredControls.background.control; + expect(typeof control === 'object' && control.type).toEqual('color'); + }); + + it('should return inferred type when using color matcher but arg passed is not a string', () => { + const sampleTypes = [ + { + name: 'object', + value: { + rgb: { + name: 'number', + }, + }, + }, + { name: 'number' }, + { name: 'boolean' }, + ]; + + sampleTypes.forEach((type) => { + const inferredControls = inferControls( + getStoryContext({ + argTypes: { + background: { + type, + name: 'background', + }, + }, + parameters: { + controls: { + matchers: { + color: /background/, + }, + }, + }, + }) + ); + + expect(warnSpy).toHaveBeenCalled(); + const control = inferredControls.background.control; + expect(typeof control === 'object' && control.type).toEqual(type.name); + }); + }); + }); + + it('should return argTypes as is when no exclude or include is passed', () => { + const controls = inferControls(getStoryContext()); + expect(Object.keys(controls)).toEqual(['label', 'labelName', 'borderWidth']); + }); + + it('should return filtered argTypes when include is passed', () => { + const [includeString, includeArray, includeRegex] = [ + inferControls(getStoryContext({ parameters: { controls: { include: 'label' } } })), + inferControls(getStoryContext({ parameters: { controls: { include: ['label'] } } })), + inferControls(getStoryContext({ parameters: { controls: { include: /label*/ } } })), + ]; + + expect(Object.keys(includeString)).toEqual(['label', 'labelName']); + expect(Object.keys(includeArray)).toEqual(['label']); + expect(Object.keys(includeRegex)).toEqual(['label', 'labelName']); + }); + + it('should return filtered argTypes when exclude is passed', () => { + const [excludeString, excludeArray, excludeRegex] = [ + inferControls(getStoryContext({ parameters: { controls: { exclude: 'label' } } })), + inferControls(getStoryContext({ parameters: { controls: { exclude: ['label'] } } })), + inferControls(getStoryContext({ parameters: { controls: { exclude: /label*/ } } })), + ]; + + expect(Object.keys(excludeString)).toEqual(['borderWidth']); + expect(Object.keys(excludeArray)).toEqual(['labelName', 'borderWidth']); + expect(Object.keys(excludeRegex)).toEqual(['borderWidth']); + }); + + describe('with union types containing string literals', () => { + it('should infer radio control for union of string literals (<=5)', () => { + const inferredControls = inferControls( + getStoryContext({ + argTypes: { + size: { + type: { + name: 'union', + value: [ + { name: 'literal', value: 'S' }, + { name: 'literal', value: 'M' }, + { name: 'literal', value: 'L' }, + ], + }, + name: 'size', + }, + }, + }) + ); + + const control = inferredControls.size.control; + expect(typeof control === 'object' && control.type).toEqual('radio'); + expect(inferredControls.size.options).toEqual(['S', 'M', 'L']); + }); + + it('should infer select control for union with many string literals (>5)', () => { + const inferredControls = inferControls( + getStoryContext({ + argTypes: { + theme: { + type: { + name: 'union', + value: [ + { name: 'literal', value: 'light' }, + { name: 'literal', value: 'dark' }, + { name: 'literal', value: 'blue' }, + { name: 'literal', value: 'green' }, + { name: 'literal', value: 'red' }, + { name: 'literal', value: 'purple' }, + ], + }, + name: 'theme', + }, + }, + }) + ); + + const control = inferredControls.theme.control; + expect(typeof control === 'object' && control.type).toEqual('select'); + expect(inferredControls.theme.options).toEqual(['light', 'dark', 'blue', 'green', 'red', 'purple']); + }); + + it('should not infer select for union with non-literal members', () => { + const inferredControls = inferControls( + getStoryContext({ + argTypes: { + value: { + type: { + name: 'union', + value: [ + { name: 'literal', value: 'foo' }, + { name: 'string' }, + ], + }, + name: 'value', + }, + }, + }) + ); + + // Falls through to default — object control + const control = inferredControls.value.control; + expect(typeof control === 'object' && control.type).toEqual('object'); + }); + + it('should not infer select for union with number literals', () => { + const inferredControls = inferControls( + getStoryContext({ + argTypes: { + level: { + type: { + name: 'union', + value: [ + { name: 'literal', value: 1 }, + { name: 'literal', value: 2 }, + ], + }, + name: 'level', + }, + }, + }) + ); + + const control = inferredControls.level.control; + expect(typeof control === 'object' && control.type).toEqual('object'); + }); + }); +}); \ No newline at end of file diff --git a/code/core/src/preview-api/modules/store/inferControls.ts b/code/core/src/preview-api/modules/store/inferControls.ts index d3e961dca186..2bcad4e9417a 100644 --- a/code/core/src/preview-api/modules/store/inferControls.ts +++ b/code/core/src/preview-api/modules/store/inferControls.ts @@ -1,9 +1,11 @@ import { logger } from 'storybook/internal/client-logger'; import type { - ArgTypesEnhancer, - Renderer, - SBEnumType, - StrictInputType, + ArgTypesEnhancer, + Renderer, + SBEnumType, + SBUnionType, + SBLiteralType, + StrictInputType, } from 'storybook/internal/types'; import { mapValues } from 'es-toolkit/object'; @@ -12,75 +14,91 @@ import { filterArgTypes } from './filterArgTypes.ts'; import { combineParameters } from './parameters.ts'; export type ControlsMatchers = { - date: RegExp; - color: RegExp; + date: RegExp; + color: RegExp; }; const inferControl = (argType: StrictInputType, name: string, matchers: ControlsMatchers): any => { - const { type, options } = argType; - if (!type) { - return undefined; - } + const { type, options } = argType; + if (!type) { + return undefined; + } - // args that end with background or color e.g. iconColor - if (matchers.color && matchers.color.test(name)) { - const controlType = type.name; + // args that end with background or color e.g. iconColor + if (matchers.color && matchers.color.test(name)) { + const controlType = type.name; - if (controlType === 'string') { - return { control: { type: 'color' } }; - } + if (controlType === 'string') { + return { control: { type: 'color' } }; + } - if (controlType !== 'enum') { - logger.warn( - `Addon controls: Control of type color only supports string, received "${controlType}" instead` - ); - } - } + if (controlType !== 'enum') { + logger.warn( + `Addon controls: Control of type color only supports string, received "${controlType}" instead` + ); + } + } - // args that end with date e.g. purchaseDate - if (matchers.date && matchers.date.test(name)) { - return { control: { type: 'date' } }; - } + // args that end with date e.g. purchaseDate + if (matchers.date && matchers.date.test(name)) { + return { control: { type: 'date' } }; + } - switch (type.name) { - case 'array': - return { control: { type: 'object' } }; - case 'boolean': - return { control: { type: 'boolean' } }; - case 'string': - return { control: { type: 'text' } }; - case 'number': - return { control: { type: 'number' } }; - case 'enum': { - const { value } = type as SBEnumType; - return { control: { type: value?.length <= 5 ? 'radio' : 'select' }, options: value }; - } - case 'function': - case 'symbol': - return null; - default: - return { control: { type: options ? 'select' : 'object' } }; - } + switch (type.name) { + case 'array': + return { control: { type: 'object' } }; + case 'boolean': + return { control: { type: 'boolean' } }; + case 'string': + return { control: { type: 'text' } }; + case 'number': + return { control: { type: 'number' } }; + case 'enum': { + const { value } = type as SBEnumType; + return { control: { type: value?.length <= 5 ? 'radio' : 'select' }, options: value }; + } + case 'union': { + const { value } = type as SBUnionType; + // Only infer select/radio control if all union members are string literals + const allStringLiterals = + Array.isArray(value) && + value.length > 0 && + value.every( + (v) => (v as SBLiteralType).name === 'literal' && typeof (v as SBLiteralType).value === 'string' + ); + if (allStringLiterals) { + const opts = (value as SBLiteralType[]).map((v) => v.value); + return { control: { type: opts.length <= 5 ? 'radio' : 'select' }, options: opts }; + } + // Fall through to default for non-string-literal unions + break; + } + case 'function': + case 'symbol': + return null; + default: + return { control: { type: options ? 'select' : 'object' } }; + } }; export const inferControls: ArgTypesEnhancer = (context) => { - const { - argTypes, - parameters: { __isArgsStory, controls: { include = null, exclude = null, matchers = {} } = {} }, - } = context; + const { + argTypes, + parameters: { __isArgsStory, controls: { include = null, exclude = null, matchers = {} } = {} }, + } = context; - if (!__isArgsStory) { - return argTypes; - } + if (!__isArgsStory) { + return argTypes; + } - const filteredArgTypes = filterArgTypes(argTypes, include, exclude); - const withControls = mapValues(filteredArgTypes, (argType, name) => { - return argType?.type && inferControl(argType, name.toString(), matchers); - }); + const filteredArgTypes = filterArgTypes(argTypes, include, exclude); + const withControls = mapValues(filteredArgTypes, (argType, name) => { + return argType?.type && inferControl(argType, name.toString(), matchers); + }); - return combineParameters(withControls, filteredArgTypes); + return combineParameters(withControls, filteredArgTypes); }; inferControls.secondPass = true; -export const argTypesEnhancers = [inferControls]; +export const argTypesEnhancers = [inferControls]; \ No newline at end of file