diff --git a/packages/cli/src/commands/types/generate/actions.test.ts b/packages/cli/src/commands/types/generate/actions.test.ts index f62cc495c..c08c9e4e5 100644 --- a/packages/cli/src/commands/types/generate/actions.test.ts +++ b/packages/cli/src/commands/types/generate/actions.test.ts @@ -747,6 +747,73 @@ describe('component property type annotations', () => { expect(result).toContain('colorPicker?:'); expect(result).toContain('color: string'); }); + it('should handle datasource property type', async () => { + // Create a component with boolean property type + const componentWithDatasourceType: SpaceComponent = { + name: 'test_component', + display_name: 'Test Component', + created_at: '2023-01-01T00:00:00Z', + updated_at: '2023-01-01T00:00:00Z', + id: 1, + schema: { + sex: { + type: 'option', + pos: 1, + use_uuid: true, + source: 'internal', + datasource_slug: 'garden-325', + id: '679gCtyCRbKrTlUkXBHw7w', + }, + }, + internal_tags_list: [], + internal_tag_ids: [], + }; + + // Create a space data with this component + const spaceData: SpaceComponentsData = { + components: [componentWithDatasourceType], + datasources: [{ + id: 109556769159015, + name: 'Garden-325', + slug: 'garden-325', + dimensions: [], + created_at: '2025-11-06T13:47:38.095Z', + updated_at: '2025-11-06T13:47:38.095Z', + entries: [ + { + id: 109556771421284, + datasource_id: 109556769159015, + name: 'deform-206', + value: 'deform-206', + dimension_value: '', + }, + { + id: 109556773588069, + datasource_id: 109556769159015, + name: 'because-854', + value: 'because-854', + dimension_value: '', + }, + { + id: 109556775738470, + datasource_id: 109556769159015, + name: 'even-218', + value: 'even-218', + dimension_value: '', + }, + ], + }], + groups: [], + presets: [], + internalTags: [], + }; + + // Generate types + const result = await generateTypes(spaceData, { strict: false }); + // Verify that the result contains the expected property type + expect(result).toContain('sex?: Garden325DataSource'); + expect(result).toContain('export type Garden325DataSource = "deform-206" | "because-854" | "even-218";'); + }); }); describe('generateStoryblokTypes', () => { diff --git a/packages/cli/src/commands/types/generate/actions.ts b/packages/cli/src/commands/types/generate/actions.ts index 1189a20ab..bd8923a8e 100644 --- a/packages/cli/src/commands/types/generate/actions.ts +++ b/packages/cli/src/commands/types/generate/actions.ts @@ -21,6 +21,8 @@ const DEFAULT_TYPEDEFS_HEADER = [ '// This file was generated by the storyblok CLI.', '// DO NOT MODIFY THIS FILE BY HAND.', ]; +const getDatasourceTypeTitle = (slug: string) => + `${toPascalCase(slug)}DataSource`; const getPropertyTypeAnnotation = (property: ComponentPropertySchema, prefix?: string, suffix?: string) => { // If a property type is one of the ones provided by Storyblok, return that type @@ -191,7 +193,18 @@ const getComponentPropertiesTypeAnnotations = async ( const componentType = toPascalCase(propertyType); propertyTypeAnnotation[key].tsType = `Storyblok${componentType}`; } - + if (spaceData.datasources.length > 0 && schema.source === 'internal' && schema?.datasource_slug) { + // Check if the datasource actually exists in spaceData.datasources + const datasourceExists = spaceData.datasources.some(ds => ds.slug === schema.datasource_slug); + if (datasourceExists) { + const type = getDatasourceTypeTitle(schema.datasource_slug); + // Assign type based on whether it's single or multiple selection + propertyTypeAnnotation[key].tsType + = propertyType === 'options' ? `${type}[]` : type; + } + // If datasource doesn't exist, fall back to default number | string union type + // The type annotation already has the correct fallback from getPropertyTypeAnnotation + } if (propertyType === 'multilink') { const excludedLinktypes: string[] = [ ...(!schema.email_link_type ? ['{ linktype?: "email" }'] : []), @@ -306,6 +319,7 @@ export const generateTypes = async ( try { const typeDefs = [...DEFAULT_TYPEDEFS_HEADER]; const storyblokPropertyTypes = new Set(); + const contentTypeBloks = new Set(); let customFieldsParser: ((key: string, value: Record) => Record) | undefined; let compilerOptions: Record | undefined; // Custom fields parser @@ -317,10 +331,13 @@ export const generateTypes = async ( if (options.compilerOptions) { compilerOptions = await loadCompilerOptions(options.compilerOptions); } - - const schemas = await Promise.all(spaceData.components.map(async (component) => { + const componentsSchema = spaceData.components.map(async (component) => { // Get the component type name with proper handling of numbers at the start const type = getComponentType(component.name, options); + // Add all the Content Type and Universial Blok to contentTypeBloks + if (component.is_root) { + contentTypeBloks.add(type); + } const componentPropertiesTypeAnnotations = await getComponentPropertiesTypeAnnotations(component, options, spaceData, customFieldsParser); const requiredFields = Object.entries(component?.schema || {}).reduce( (acc: string[], [key, value]) => { @@ -363,7 +380,41 @@ export const generateTypes = async ( }; return componentSchema; - })); + }); + const resolvedComponentsSchema = await Promise.all(componentsSchema); + + const datasourcesSchema = spaceData.datasources.map(async (datasource) => { + const allComponentTypes = resolvedComponentsSchema.map(schema => schema.title); + + const enumValues: string[] | undefined = datasource.entries + ?.filter(d => d.value) + .map(d => d.value!); + const type = getDatasourceTypeTitle(datasource.slug); + // Check for conflicts with existing component types + if (allComponentTypes.includes(type)) { + console.warn(`Warning: Datasource type "${type}" conflicts with existing component type`); + } + const datasourceSchema: JSONSchema = { + $id: `#/${datasource.slug}`, + title: type, + type: 'string', + enum: enumValues, + }; + return datasourceSchema; + }); + const resolvedDatasourcesSchema = await Promise.all(datasourcesSchema); + + const contentTypeSchema: JSONSchema = { + $id: `#/ContentType`, + title: 'ContentType', + type: 'string', + tsType: contentTypeBloks.size > 0 ? `${Array.from(contentTypeBloks).join(' | ')}` : 'never', + }; + const schemas = [ + ...resolvedComponentsSchema, + ...resolvedDatasourcesSchema, + contentTypeSchema, + ]; const result = await Promise.all(schemas.map(async (schema) => { // Use the title as the interface name diff --git a/packages/cli/src/commands/types/generate/index.test.ts b/packages/cli/src/commands/types/generate/index.test.ts index e49847458..ba447a45c 100644 --- a/packages/cli/src/commands/types/generate/index.test.ts +++ b/packages/cli/src/commands/types/generate/index.test.ts @@ -9,6 +9,26 @@ import '../index'; import { typesCommand } from '../command'; import { readComponentsFiles } from '../../components/push/actions'; +const mockResponse = [{ + name: 'component-name', + display_name: 'Component Name', + created_at: '2021-08-09T12:00:00Z', + updated_at: '2021-08-09T12:00:00Z', + id: 12345, + schema: { type: 'object' }, + color: undefined, + internal_tags_list: [], + internal_tag_ids: [], +}]; + +const mockSpaceData = { + components: mockResponse, + groups: [], + presets: [], + internalTags: [], + datasources: [], +}; + vi.mock('./actions', () => ({ generateStoryblokTypes: vi.fn(), generateTypes: vi.fn(), @@ -75,26 +95,6 @@ describe('types generate', () => { describe('default mode', () => { it('should prompt the user if the operation was sucessfull', async () => { - const mockResponse = [{ - name: 'component-name', - display_name: 'Component Name', - created_at: '2021-08-09T12:00:00Z', - updated_at: '2021-08-09T12:00:00Z', - id: 12345, - schema: { type: 'object' }, - color: null, - internal_tags_list: [], - internal_tag_ids: [], - }]; - - const mockSpaceData = { - components: mockResponse, - groups: [], - presets: [], - internalTags: [], - datasources: [], - }; - session().state = { isLoggedIn: true, password: 'valid-token', @@ -120,26 +120,6 @@ describe('types generate', () => { }); it('should pass strict mode option to generateTypes when --strict flag is used', async () => { - const mockResponse = [{ - name: 'component-name', - display_name: 'Component Name', - created_at: '2021-08-09T12:00:00Z', - updated_at: '2021-08-09T12:00:00Z', - id: 12345, - schema: { type: 'object' }, - color: null, - internal_tags_list: [], - internal_tag_ids: [], - }]; - - const mockSpaceData = { - components: mockResponse, - groups: [], - presets: [], - internalTags: [], - datasources: [], - }; - session().state = { isLoggedIn: true, password: 'valid-token', @@ -161,26 +141,6 @@ describe('types generate', () => { }); it('should pass typePrefix option to generateTypes when --type-prefix flag is used', async () => { - const mockResponse = [{ - name: 'component-name', - display_name: 'Component Name', - created_at: '2021-08-09T12:00:00Z', - updated_at: '2021-08-09T12:00:00Z', - id: 12345, - schema: { type: 'object' }, - color: null, - internal_tags_list: [], - internal_tag_ids: [], - }]; - - const mockSpaceData = { - components: mockResponse, - groups: [], - presets: [], - internalTags: [], - datasources: [], - }; - session().state = { isLoggedIn: true, password: 'valid-token', @@ -202,32 +162,6 @@ describe('types generate', () => { }); it('should pass typeSuffix option to generateTypes when --type-suffix flag is used', async () => { - const mockResponse = [{ - name: 'component-name', - display_name: 'Component Name', - created_at: '2021-08-09T12:00:00Z', - updated_at: '2021-08-09T12:00:00Z', - id: 12345, - schema: { type: 'object' }, - color: null, - internal_tags_list: [], - internal_tag_ids: [], - }]; - - const mockSpaceData = { - components: mockResponse, - groups: [], - presets: [], - internalTags: [], - datasources: [], - }; - - session().state = { - isLoggedIn: true, - password: 'valid-token', - region: 'eu', - }; - vi.mocked(readComponentsFiles).mockResolvedValue(mockSpaceData); vi.mocked(generateStoryblokTypes).mockResolvedValue(true); vi.mocked(generateTypes).mockResolvedValue('// Generated types'); @@ -243,26 +177,6 @@ describe('types generate', () => { }); it('should pass suffix option to generateTypes when --suffix flag is used', async () => { - const mockResponse = [{ - name: 'component-name', - display_name: 'Component Name', - created_at: '2021-08-09T12:00:00Z', - updated_at: '2021-08-09T12:00:00Z', - id: 12345, - schema: { type: 'object' }, - color: null, - internal_tags_list: [], - internal_tag_ids: [], - }]; - - const mockSpaceData = { - components: mockResponse, - groups: [], - presets: [], - internalTags: [], - datasources: [], - }; - session().state = { isLoggedIn: true, password: 'valid-token', @@ -284,26 +198,6 @@ describe('types generate', () => { }); it('should pass separateFiles option to generateTypes when --separate-files flag is used', async () => { - const mockResponse = [{ - name: 'component-name', - display_name: 'Component Name', - created_at: '2021-08-09T12:00:00Z', - updated_at: '2021-08-09T12:00:00Z', - id: 12345, - schema: { type: 'object' }, - color: null, - internal_tags_list: [], - internal_tag_ids: [], - }]; - - const mockSpaceData = { - components: mockResponse, - groups: [], - presets: [], - internalTags: [], - datasources: [], - }; - session().state = { isLoggedIn: true, password: 'valid-token', @@ -325,26 +219,6 @@ describe('types generate', () => { }); it('should pass customFieldsParser option to generateTypes when --custom-fields-parser flag is used', async () => { - const mockResponse = [{ - name: 'component-name', - display_name: 'Component Name', - created_at: '2021-08-09T12:00:00Z', - updated_at: '2021-08-09T12:00:00Z', - id: 12345, - schema: { type: 'object' }, - color: null, - internal_tags_list: [], - internal_tag_ids: [], - }]; - - const mockSpaceData = { - components: mockResponse, - groups: [], - presets: [], - internalTags: [], - datasources: [], - }; - session().state = { isLoggedIn: true, password: 'valid-token', @@ -366,26 +240,6 @@ describe('types generate', () => { }); it('should pass compilerOptions option to generateTypes when --compiler-options flag is used', async () => { - const mockResponse = [{ - name: 'component-name', - display_name: 'Component Name', - created_at: '2021-08-09T12:00:00Z', - updated_at: '2021-08-09T12:00:00Z', - id: 12345, - schema: { type: 'object' }, - color: null, - internal_tags_list: [], - internal_tag_ids: [], - }]; - - const mockSpaceData = { - components: mockResponse, - groups: [], - presets: [], - internalTags: [], - datasources: [], - }; - session().state = { isLoggedIn: true, password: 'valid-token', diff --git a/packages/cli/src/commands/types/generate/index.ts b/packages/cli/src/commands/types/generate/index.ts index 8e9c54fea..71506350b 100644 --- a/packages/cli/src/commands/types/generate/index.ts +++ b/packages/cli/src/commands/types/generate/index.ts @@ -1,5 +1,5 @@ import { colorPalette, commands } from '../../../constants'; -import { handleError, isVitest, konsola } from '../../../utils'; +import { FileSystemError, handleError, isVitest, konsola } from '../../../utils'; import { getProgram } from '../../../program'; import { Spinner } from '@topcli/spinner'; import { type ComponentsData, readComponentsFiles } from '../../components/push/actions'; @@ -7,6 +7,9 @@ import type { GenerateTypesOptions } from './constants'; import type { ReadComponentsOptions } from '../../components/push/constants'; import { typesCommand } from '../command'; import { generateStoryblokTypes, generateTypes, saveTypesToComponentsFile } from './actions'; +import { readDatasourcesFiles } from '../../datasources/push/actions'; +import type { SpaceDatasourcesData } from '../../../commands/datasources/constants'; +import type { ReadDatasourcesOptions } from './../../datasources/push/constants'; const program = getProgram(); @@ -38,23 +41,40 @@ typesCommand try { spinner.start(`Generating types...`); - const spaceData = await readComponentsFiles({ - ...options as ReadComponentsOptions, + const componentsData = await readComponentsFiles({ + ...(options as ReadComponentsOptions), from: space, path, }); - + // Try to read datasources, but make it optional + let dataSourceData: SpaceDatasourcesData; + try { + dataSourceData = await readDatasourcesFiles({ + ...(options as ReadDatasourcesOptions), + from: space, + path, + }); + } + catch (error) { + // Only catch the specific case where datasources don't exist + if (error instanceof FileSystemError && error.errorId === 'file_not_found') { + dataSourceData = { datasources: [] }; + } + else { + throw error; + } + } await generateStoryblokTypes({ path, }); // Add empty datasources array to match expected type for generateTypes - const spaceDataWithDatasources: ComponentsData & { datasources: [] } = { - ...spaceData, - datasources: [], + const spaceDataWithComponentsAndDatasources: ComponentsData & SpaceDatasourcesData = { + ...componentsData, + ...dataSourceData, }; - const typedefString = await generateTypes(spaceDataWithDatasources, { + const typedefString = await generateTypes(spaceDataWithComponentsAndDatasources, { ...options, path, }); diff --git a/packages/cli/src/types/schemas.ts b/packages/cli/src/types/schemas.ts index 6abf6064e..cf2d49f53 100644 --- a/packages/cli/src/types/schemas.ts +++ b/packages/cli/src/types/schemas.ts @@ -35,6 +35,7 @@ export interface ComponentPropertySchema { restrict_components?: boolean; restrict_type?: 'groups' | 'components' | 'tags' | ''; source?: 'internal' | 'external' | 'internal_stories' | 'internal_languages'; + datasource_slug?: string; type: ComponentPropertySchemaType; use_uuid?: boolean; };