Skip to content

Commit 7069e8e

Browse files
authored
fix(type): generate single and multi options fields types correctly (#210)
* fix(type): generate single and multi options fields types correctly * refactor(types): correctly handle camelCase
1 parent 8317c30 commit 7069e8e

File tree

3 files changed

+110
-8
lines changed

3 files changed

+110
-8
lines changed

src/commands/types/generate/actions.test.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { generateStoryblokTypes, generateTypes, getComponentType } from './actions';
1+
import { generateStoryblokTypes, generateTypes, getComponentType, getStoryType } from './actions';
22
import type { SpaceComponent, SpaceData } from '../../../commands/components/constants';
33
import type { GenerateTypesOptions } from './constants';
44
import { join, resolve } from 'node:path';
@@ -298,6 +298,33 @@ describe('getComponentType', () => {
298298
});
299299
});
300300

301+
describe('getStoryType', () => {
302+
it('should convert property names to the correct format', () => {
303+
// Test cases for different property name formats
304+
expect(getStoryType('my_property')).toBe('ISbStoryData<MyProperty>');
305+
expect(getStoryType('my-property')).toBe('ISbStoryData<MyProperty>');
306+
expect(getStoryType('myProperty')).toBe('ISbStoryData<MyProperty>');
307+
expect(getStoryType('MyProperty')).toBe('ISbStoryData<MyProperty>');
308+
expect(getStoryType('my property')).toBe('ISbStoryData<MyProperty>');
309+
expect(getStoryType('my_property_name')).toBe('ISbStoryData<MyPropertyName>');
310+
});
311+
312+
it('should handle special characters and numbers', () => {
313+
expect(getStoryType('my_property_123')).toBe('ISbStoryData<MyProperty123>');
314+
expect(getStoryType('my-property!')).toBe('ISbStoryData<MyProperty>');
315+
expect(getStoryType('my_property@name')).toBe('ISbStoryData<MyPropertyName>');
316+
});
317+
318+
it('should handle empty or single character properties', () => {
319+
expect(getStoryType('')).toBe('ISbStoryData<>');
320+
expect(getStoryType('a')).toBe('ISbStoryData<A>');
321+
});
322+
323+
it('should handle prefix', () => {
324+
expect(getStoryType('my_property', 'Custom')).toBe('ISbStoryData<CustomMyProperty>');
325+
});
326+
});
327+
301328
describe('component property type annotations', () => {
302329
it('should handle text property type', async () => {
303330
// Create a component with text property type

src/commands/types/generate/actions.ts

Lines changed: 70 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { compile, type JSONSchema } from 'json-schema-to-typescript';
22
import type { SpaceComponent, SpaceData } from '../../../commands/components/constants';
3-
import { __dirname, handleError, handleFileSystemError, toCamelCase, toPascalCase } from '../../../utils';
3+
import { __dirname, capitalize, handleError, handleFileSystemError, toCamelCase, toPascalCase } from '../../../utils';
44
import type { GenerateTypesOptions } from './constants';
55
import type { StoryblokPropertyType } from '../../../types/storyblok';
66
import { storyblokSchemas } from '../../../utils/storyblok-schemas';
@@ -15,20 +15,20 @@ export interface ComponentGroupsAndNamesObject {
1515
}
1616

1717
// Constants
18+
const STORY_TYPE = 'ISbStoryData';
1819
const DEFAULT_TYPEDEFS_HEADER = [
1920
'// This file was generated by the storyblok CLI.',
2021
'// DO NOT MODIFY THIS FILE BY HAND.',
2122
];
2223

23-
const getPropertyTypeAnnotation = (property: ComponentPropertySchema) => {
24+
const getPropertyTypeAnnotation = (property: ComponentPropertySchema, prefix?: string) => {
2425
// If a property type is one of the ones provided by Storyblok, return that type
2526
// Casting as string[] to avoid TS error on using Array.includes on different narrowed types
2627
if (Array.from(storyblokSchemas.keys()).includes(property.type as StoryblokPropertyType)) {
2728
return { type: property.type };
2829
}
29-
3030
// Initialize property type as any (fallback type)
31-
// const type: string | string[] = 'any';
31+
let type: string | string[] = 'unknown';
3232

3333
const options = property.options && property.options.length > 0 ? property.options.map((item: { value: string }) => item.value) : [];
3434

@@ -37,6 +37,67 @@ const getPropertyTypeAnnotation = (property: ComponentPropertySchema) => {
3737
options.unshift('');
3838
}
3939

40+
if (property.source === 'internal_stories') {
41+
// Only if there is a filter_content_type, we can return a proper type
42+
if (property.filter_content_type) {
43+
if (typeof property.filter_content_type === 'string') {
44+
return {
45+
tsType: `(${getStoryType(property.filter_content_type, prefix)} | string )${property.type === 'options' ? '[]' : ''}`,
46+
};
47+
}
48+
49+
return {
50+
tsType: `(${property.filter_content_type
51+
.map(type2 => getStoryType(type2, prefix))
52+
// In this case property.type can be `option` or `options`. In case of `options` the type should be an array
53+
.join(' | ')} | string )${property.type === 'options' ? '[]' : ''}`,
54+
};
55+
}
56+
}
57+
58+
if (
59+
// If there is no `source` and there are options, the data source is the component itself
60+
// TODO: check if this is an old behaviour (shouldn't this be handled as an "internal" source?)
61+
(options.length > 0 && !property.source)
62+
|| property.source === 'internal_languages'
63+
|| property.source === 'external'
64+
) {
65+
type = 'string';
66+
}
67+
68+
if (property.source === 'internal') {
69+
type = ['number', 'string'];
70+
}
71+
72+
if (property.type === 'option') {
73+
if (options.length > 0) {
74+
return {
75+
type,
76+
enum: options,
77+
};
78+
}
79+
80+
return {
81+
type,
82+
};
83+
}
84+
85+
if (property.type === 'options') {
86+
if (options.length > 0) {
87+
return {
88+
type: 'array',
89+
items: {
90+
enum: options,
91+
},
92+
};
93+
}
94+
95+
return {
96+
type: 'array',
97+
items: { type },
98+
};
99+
}
100+
40101
switch (property.type) {
41102
case 'bloks':
42103
return { type: 'array' };
@@ -54,6 +115,10 @@ const getPropertyTypeAnnotation = (property: ComponentPropertySchema) => {
54115
}
55116
};
56117

118+
export function getStoryType(property: string, prefix?: string) {
119+
return `${STORY_TYPE}<${prefix ?? ''}${capitalize(toCamelCase(property))}>`;
120+
}
121+
57122
/**
58123
* Generates a TypeScript type name for a component
59124
* @param componentName - The name of the component
@@ -102,7 +167,7 @@ const getComponentPropertiesTypeAnnotations = async (
102167

103168
const propertyType = value.type;
104169
const propertyTypeAnnotation: JSONSchema = {
105-
[key]: getPropertyTypeAnnotation(value as ComponentPropertySchema),
170+
[key]: getPropertyTypeAnnotation(value as ComponentPropertySchema, options.typePrefix),
106171
};
107172

108173
if (propertyType === 'custom' && customFieldsParser) {

src/utils/format.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,15 @@ export const toPascalCase = (str: string) => {
44

55
export const toCamelCase = (str: string) => {
66
return str
7-
.replace(/(?:^|_)(\w)/g, (_, char) => char.toUpperCase())
7+
// First replace snake_case
8+
.replace(/_([a-z])/g, (_, letter) => letter.toUpperCase())
89
.replace(/_/g, '')
9-
.replace(/^[A-Z]/, char => char.toLowerCase());
10+
// Then replace kebab-case
11+
.replace(/-([a-z])/g, (_, letter) => letter.toUpperCase())
12+
// Capitalize letters after special characters
13+
.replace(/[^a-z0-9]([a-z])/gi, (_, letter) => letter.toUpperCase())
14+
// Remove special characters
15+
.replace(/[^a-z0-9]/gi, '');
1016
};
1117

1218
export const toSnakeCase = (str: string) => {
@@ -15,6 +21,10 @@ export const toSnakeCase = (str: string) => {
1521
.replace(/^_/, '');
1622
};
1723

24+
export const capitalize = (str: string) => {
25+
return str.charAt(0).toUpperCase() + str.slice(1);
26+
};
27+
1828
export function maskToken(token: string): string {
1929
// Show only the first 4 characters and replace the rest with asterisks
2030
if (token.length <= 4) {

0 commit comments

Comments
 (0)