From 775968dae9e2b39ba3574bbb53219d3f3cad0ab4 Mon Sep 17 00:00:00 2001 From: alvarosabu Date: Thu, 12 Dec 2024 10:37:29 +0100 Subject: [PATCH] feat: components presets --- src/commands/block/index.ts | 30 ++++++ src/commands/components/actions.test.ts | 6 +- src/commands/components/actions.ts | 137 ++++++++++++++++++++---- src/commands/components/index.ts | 28 ++++- src/utils/error/api-error.ts | 2 + src/utils/index.ts | 10 ++ 6 files changed, 187 insertions(+), 26 deletions(-) create mode 100644 src/commands/block/index.ts diff --git a/src/commands/block/index.ts b/src/commands/block/index.ts new file mode 100644 index 00000000..9ddedfd6 --- /dev/null +++ b/src/commands/block/index.ts @@ -0,0 +1,30 @@ +import chalk from 'chalk' +import { colorPalette, commands } from '../../constants' +import { session } from '../../session' +import { getProgram } from '../../program' +import { CommandError, handleError, konsola } from '../../utils' + +import type { PullComponentsOptions } from './constants' + +const program = getProgram() // Get the shared singleton instance + +export const blocksCommand = program + .command('block') + .option('-s, --space ', 'space ID') + .option('-p, --path ', 'path to where schemas are saved. Default is .storyblok/components') + .description(`Operations on Storyblok blocks`) + +blocksCommand + .command('pull') + .description(`Download your space's blocks schema as json`) + .option('--sf, --separate-files [value]', 'Argument to create a single file for each component') + .action(async (options: PullComponentsOptions) => { + console.log('pulling blocks...', blocksCommand.opts()) + }) + +blocksCommand + .command('push') + .description(`Push your space's blocks schema as json`) + .action(async (options: PullComponentsOptions) => { + console.log('pushing blocks...', options) + }) diff --git a/src/commands/components/actions.test.ts b/src/commands/components/actions.test.ts index 20369a61..2847a481 100644 --- a/src/commands/components/actions.test.ts +++ b/src/commands/components/actions.test.ts @@ -2,7 +2,7 @@ import { http, HttpResponse } from 'msw' import { setupServer } from 'msw/node' import { vol } from 'memfs' import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest' -import { pullComponents, saveComponentsToFiles } from './actions' +import { fetchComponents, saveComponentsToFiles } from './actions' const handlers = [ http.get('https://api.storyblok.com/v1/spaces/12345/components', async ({ request }) => { @@ -70,12 +70,12 @@ describe('pull components actions', () => { interntal_tags_ids: [1], }] - const result = await pullComponents('12345', 'valid-token', 'eu') + const result = await fetchComponents('12345', 'valid-token', 'eu') expect(result).toEqual(mockResponse) }) it('should throw an masked error for invalid token', async () => { - await expect(pullComponents('12345', 'invalid-token', 'eu')).rejects.toThrow( + await expect(fetchComponents('12345', 'invalid-token', 'eu')).rejects.toThrow( new Error(`The user is not authorized to access the API`), ) }) diff --git a/src/commands/components/actions.ts b/src/commands/components/actions.ts index 276b095b..f5cdb442 100644 --- a/src/commands/components/actions.ts +++ b/src/commands/components/actions.ts @@ -1,5 +1,5 @@ import { ofetch } from 'ofetch' -import { handleAPIError, handleFileSystemError } from '../../utils' +import { handleAPIError, handleFileSystemError, slugify } from '../../utils' import { regionsDomain } from '../../constants' import { join } from 'node:path' import { resolvePath, saveToFile } from '../../utils/filesystem' @@ -27,6 +27,14 @@ export interface SpaceComponent { content_type_asset_preview?: string } +export interface SpaceComponentGroup { + name: string + id: number + uuid: string + parent_id: number + parent_uuid: string +} + export interface ComponentsSaveOptions { path?: string filename?: string @@ -34,7 +42,34 @@ export interface ComponentsSaveOptions { suffix?: string } -export const pullComponents = async (space: string, token: string, region: string): Promise => { +export interface SpaceComponentPreset { + id: number + name: string + preset: Record + component_id: number + space_id: number + created_at: string + updated_at: string + image: string + color: string + icon: string + description: string +} + +/** + * Resolves the nested folder structure based on component group hierarchy. + * @param groupUuid - The UUID of the component group. + * @param groups - The list of all component groups. + * @returns The resolved path for the component group. + */ +const resolveGroupPath = (groupUuid: string, groups: SpaceComponentGroup[]): string => { + const group = groups.find(g => g.uuid === groupUuid) + if (!group) { return '' } + const parentPath = group.parent_uuid ? resolveGroupPath(group.parent_uuid, groups) : '' + return join(parentPath, slugify(group.name)) +} + +export const fetchComponents = async (space: string, token: string, region: string): Promise => { try { const response = await ofetch(`https://${regionsDomain[region]}/v1/spaces/${space}/components`, { headers: { @@ -48,32 +83,98 @@ export const pullComponents = async (space: string, token: string, region: strin } } -export const saveComponentsToFiles = async (space: string, components: SpaceComponent[], options: PullComponentsOptions) => { - const { filename = 'components', suffix = space, path } = options +export const fetchComponentGroups = async (space: string, token: string, region: string): Promise => { + try { + const response = await ofetch(`https://${regionsDomain[region]}/v1/spaces/${space}/component_groups`, { + headers: { + Authorization: token, + }, + }) + return response.component_groups + } + catch (error) { + handleAPIError('pull_component_groups', error as Error) + } +} +export const fetchComponentPresets = async (space: string, token: string, region: string): Promise => { try { - const data = JSON.stringify(components, null, 2) - const resolvedPath = resolvePath(path, 'components') + const response = await ofetch(`https://${regionsDomain[region]}/v1/spaces/${space}/presets`, { + headers: { + Authorization: token, + }, + }) + return response.presets + } + catch (error) { + handleAPIError('pull_component_presets', error as Error) + } +} - if (options.separateFiles) { +export const saveComponentsToFiles = async ( + space: string, + components: SpaceComponent[], + groups: SpaceComponentGroup[], + presets: SpaceComponentPreset[], + options: PullComponentsOptions, +) => { + const { filename = 'components', suffix = space, path, separateFiles } = options + const resolvedPath = resolvePath(path, 'components') + + try { + if (separateFiles) { + // Save in separate files with nested structure for (const component of components) { - try { - const filePath = join(resolvedPath, `${component.name}.${suffix}.json`) - await saveToFile(filePath, JSON.stringify(component, null, 2)) - } - catch (error) { - handleFileSystemError('write', error as Error) + const groupPath = component.component_group_uuid + ? resolveGroupPath(component.component_group_uuid, groups) + : '' + + const componentPath = join(resolvedPath, groupPath) + + // Save component definition + const componentFilePath = join(componentPath, `${component.name}.${suffix}.json`) + await saveToFile(componentFilePath, JSON.stringify(component, null, 2)) + + // Find and save associated presets + const componentPresets = presets.filter(preset => preset.component_id === component.id) + if (componentPresets.length > 0) { + const presetsFilePath = join(componentPath, `${component.name}.presets.${suffix}.json`) + await saveToFile(presetsFilePath, JSON.stringify(componentPresets, null, 2)) } } return } - // Default to saving all components to a single file - const name = `${filename}.${suffix}.json` - const filePath = join(resolvedPath, name) + // Default to saving consolidated files + const componentsFilePath = join(resolvedPath, `${filename}.${suffix}.json`) + await saveToFile(componentsFilePath, JSON.stringify(components, null, 2)) - // Check if the path exists, and create it if it doesn't - await saveToFile(filePath, data) + if (groups.length > 0) { + const groupsFilePath = join(resolvedPath, `groups.${suffix}.json`) + await saveToFile(groupsFilePath, JSON.stringify(groups, null, 2)) + } + + if (presets.length > 0) { + const presetsFilePath = join(resolvedPath, `presets.${suffix}.json`) + await saveToFile(presetsFilePath, JSON.stringify(presets, null, 2)) + } + } + catch (error) { + handleFileSystemError('write', error as Error) + } +} + +export const saveComponentPresetsToFiles = async ( + space: string, + presets: SpaceComponentPreset[], + options: PullComponentsOptions, +) => { + const { filename = 'presets', suffix = space, path } = options + + try { + const resolvedPath = resolvePath(path, 'components') + const filePath = join(resolvedPath, `${filename}.${suffix}.json`) + await saveToFile(filePath, JSON.stringify(presets, null, 2)) } catch (error) { handleFileSystemError('write', error as Error) diff --git a/src/commands/components/index.ts b/src/commands/components/index.ts index 65e43f0b..959d458a 100644 --- a/src/commands/components/index.ts +++ b/src/commands/components/index.ts @@ -3,7 +3,7 @@ import { colorPalette, commands } from '../../constants' import { session } from '../../session' import { getProgram } from '../../program' import { CommandError, handleError, konsola } from '../../utils' -import { pullComponents, saveComponentsToFiles } from './actions' +import { fetchComponentGroups, fetchComponentPresets, fetchComponents, saveComponentPresetsToFiles, saveComponentsToFiles } from './actions' import type { PullComponentsOptions } from './constants' const program = getProgram() // Get the shared singleton instance @@ -41,23 +41,41 @@ componentsCommand } try { - const components = await pullComponents(space, state.password, state.region) + // Fetch all data first + const groups = await fetchComponentGroups(space, state.password, state.region) + const components = await fetchComponents(space, state.password, state.region) + const presets = await fetchComponentPresets(space, state.password, state.region) if (!components || components.length === 0) { konsola.warn(`No components found in the space ${space}`) return } - await saveComponentsToFiles(space, components, { ...options, path }) - const msgFilename = `${filename}.${suffix}.json` + + // Save everything using the new structure + await saveComponentsToFiles(space, components, groups || [], presets || [], { ...options, path }) if (separateFiles) { if (filename !== 'components') { konsola.warn(`The --filename option is ignored when using --separate-files`) } konsola.ok(`Components downloaded successfully in ${chalk.hex(colorPalette.PRIMARY)(path ? `${path}` : './storyblok/components')}`) + } + else { + const msgFilename = `${filename}.${suffix}.json` + konsola.ok(`Components downloaded successfully in ${chalk.hex(colorPalette.PRIMARY)(path ? `${path}/${msgFilename}` : `./storyblok/components/${msgFilename}`)}`) + } + + // Fetch component groups + /* const componentGroups = await fetchComponentGroups(space, state.password, state.region) + + if (!componentGroups || componentGroups.length === 0) { + konsola.warn(`No component groups found in the space ${space}`) return } - konsola.ok(`Components downloaded successfully in ${chalk.hex(colorPalette.PRIMARY)(path ? `${path}/${msgFilename}` : `./storyblok/components/${msgFilename}`)}`) + + await saveComponentsToFiles(space, componentGroups, { ...options, path, filename: 'groups' }) + + konsola.ok(`Component groups downloaded successfully in ${chalk.hex(colorPalette.PRIMARY)(path ? `${path}/groups.${suffix}.json` : `./storyblok/components/groups.${suffix}.json`)}`) */ } catch (error) { handleError(error as Error, verbose) diff --git a/src/utils/error/api-error.ts b/src/utils/error/api-error.ts index 88b86c09..3b8f64ff 100644 --- a/src/utils/error/api-error.ts +++ b/src/utils/error/api-error.ts @@ -8,6 +8,8 @@ export const API_ACTIONS = { get_user: 'Failed to get user', pull_languages: 'Failed to pull languages', pull_components: 'Failed to pull components', + pull_component_groups: 'Failed to pull component groups', + pull_component_presets: 'Failed to pull component presets', } as const export const API_ERRORS = { diff --git a/src/utils/index.ts b/src/utils/index.ts index f601a669..a84de8ae 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -22,3 +22,13 @@ export function maskToken(token: string): string { const maskedPart = '*'.repeat(token.length - 4) return `${visiblePart}${maskedPart}` } + +export const slugify = (text: string): string => + text + .toString() + .toLowerCase() + .replace(/\s+/g, '-') // Replace spaces with - + .replace(/[^\w-]+/g, '') // Remove all non-word chars + .replace(/-{2,}/g, '-') // Replace multiple - with single - + .replace(/^-+/, '') // Trim - from start of text + .replace(/-+$/, '')