Skip to content

Commit

Permalink
feat: add support for component internal tags
Browse files Browse the repository at this point in the history
- Implemented new actions for fetching and pushing component internal tags
- Updated SpaceData and SpaceComponent interfaces to include internal tags
- Enhanced component push process to handle internal tag synchronization
- Added filesystem support for saving and reading internal tags
- Updated error handling for new internal tag operations
  • Loading branch information
alvarosabu committed Jan 27, 2025
1 parent aa462f8 commit 14fde8c
Show file tree
Hide file tree
Showing 4 changed files with 184 additions and 31 deletions.
152 changes: 135 additions & 17 deletions src/commands/components/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@ import { handleAPIError, handleFileSystemError } from '../../utils'
import type { RegionCode } from '../../constants'
import { join, parse } from 'node:path'
import { resolvePath, saveToFile } from '../../utils/filesystem'
import type { ReadComponentsOptions, SaveComponentsOptions, SpaceComponent, SpaceComponentGroup, SpaceComponentPreset, SpaceData } from './constants'
import type { ReadComponentsOptions, SaveComponentsOptions, SpaceComponent, SpaceComponentGroup, SpaceComponentInternalTag, SpaceComponentPreset, SpaceData } from './constants'
import { getStoryblokUrl } from '../../utils/api-routes'
import { customFetch, delay } from '../../utils/fetch'
import { customFetch } from '../../utils/fetch'
import { readdir, readFile } from 'node:fs/promises'
import * as timers from 'node:timers/promises'

// Component actions
export const fetchComponents = async (space: string, token: string, region: RegionCode): Promise<SpaceComponent[] | undefined> => {
try {
const url = getStoryblokUrl(region)
Expand Down Expand Up @@ -42,6 +43,36 @@ export const fetchComponent = async (space: string, componentName: string, token
}
}

export const pushComponent = async (space: string, component: SpaceComponent, token: string, region: RegionCode): Promise<SpaceComponent | undefined> => {
try {
const url = getStoryblokUrl(region)

const response = await customFetch<{
component: SpaceComponent
}>(`${url}/spaces/${space}/components`, {
method: 'POST',
headers: {
Authorization: token,
},
body: JSON.stringify(component),
})

return response.component
}
catch (error) {
handleAPIError('push_component', error as Error, `Failed to push component ${component.name}`)
}
}

export const fakePushComponent = async (component: SpaceComponent, ratio: number = 0.5): Promise<SpaceComponent | undefined> => {
await timers.setTimeout(Math.random() * 1000 + 1000)
if (Math.random() < ratio) {
throw new Error('Random failure')
}
return component
}

// Component group actions
export const fetchComponentGroups = async (space: string, token: string, region: RegionCode): Promise<SpaceComponentGroup[] | undefined> => {
try {
const url = getStoryblokUrl(region)
Expand All @@ -59,6 +90,34 @@ export const fetchComponentGroups = async (space: string, token: string, region:
}
}

export const pushComponentGroup = async (space: string, componentGroup: SpaceComponentGroup, token: string, region: RegionCode): Promise<SpaceComponentGroup | undefined> => {
try {
const url = getStoryblokUrl(region)
const response = await customFetch<{
component_group: SpaceComponentGroup
}>(`${url}/spaces/${space}/component_groups`, {
method: 'POST',
headers: {
Authorization: token,
},
body: JSON.stringify(componentGroup),
})
return response.component_group
}
catch (error) {
handleAPIError('push_component_group', error as Error, `Failed to push component group ${componentGroup.name}`)
}
}

export const fakePushComponentGroup = async (componentGroup: SpaceComponentGroup, ratio: number = 0.5): Promise<SpaceComponentGroup | undefined> => {
await timers.setTimeout(Math.random() * 1000 + 1000)
if (Math.random() < ratio) {
throw new Error('Random failure')
}
return componentGroup
}

// Component preset actions
export const fetchComponentPresets = async (space: string, token: string, region: RegionCode): Promise<SpaceComponentPreset[] | undefined> => {
try {
const url = getStoryblokUrl(region)
Expand All @@ -76,42 +135,85 @@ export const fetchComponentPresets = async (space: string, token: string, region
}
}

export const pushComponent = async (space: string, component: SpaceComponent, token: string, region: RegionCode): Promise<SpaceComponent | undefined> => {
export const pushComponentPreset = async (space: string, componentPreset: SpaceComponentPreset, token: string, region: RegionCode): Promise<SpaceComponentPreset | undefined> => {
try {
const url = getStoryblokUrl(region)

const response = await customFetch<{
component: SpaceComponent
}>(`${url}/spaces/${space}/components`, {
preset: SpaceComponentPreset
}>(`${url}/spaces/${space}/presets`, {
method: 'POST',
headers: {
Authorization: token,
},
body: JSON.stringify(component),
body: JSON.stringify(componentPreset),
})
return response.preset
}
catch (error) {
handleAPIError('push_component_preset', error as Error, `Failed to push component preset ${componentPreset.name}`)
}
}

return response.component
export const fakePushComponentPreset = async (componentPreset: SpaceComponentPreset, ratio: number = 0.5): Promise<SpaceComponentPreset | undefined> => {
await timers.setTimeout(Math.random() * 1000 + 1000)
if (Math.random() < ratio) {
throw new Error('Random failure')
}
return componentPreset
}

// Component internal tags
export const fetchComponentInternalTags = async (space: string, token: string, region: RegionCode): Promise<SpaceComponentInternalTag[] | undefined> => {
try {
const url = getStoryblokUrl(region)
const response = await customFetch<{
internal_tags: SpaceComponentInternalTag[]
}>(`${url}/spaces/${space}/internal_tags`, {
headers: {
Authorization: token,
},
})
return response.internal_tags
}
catch (error) {
await delay(2000)
handleAPIError('push_component', error as Error, `Failed to push component ${component.name}`)
handleAPIError('pull_component_internal_tags', error as Error)
}
}

export const pushComponentInternalTag = async (space: string, componentInternalTag: SpaceComponentInternalTag, token: string, region: RegionCode): Promise<SpaceComponentInternalTag | undefined> => {
try {
const url = getStoryblokUrl(region)
const response = await customFetch<{
internal_tag: SpaceComponentInternalTag
}>(`${url}/spaces/${space}/internal_tags`, {
method: 'POST',
headers: {
Authorization: token,
},
body: JSON.stringify(componentInternalTag),
})
return response.internal_tag
}
catch (error) {
handleAPIError('push_component_internal_tag', error as Error, `Failed to push component internal tag ${componentInternalTag.name}`)
}
}

export const fakePushComponent = async (component: SpaceComponent): Promise<SpaceComponent | undefined> => {
export const fakePushComponentInternalTag = async (componentInternalTag: SpaceComponentInternalTag, ratio: number = 0.5): Promise<SpaceComponentInternalTag | undefined> => {
await timers.setTimeout(Math.random() * 1000 + 1000)
if (Math.random() < 0.5) {
if (Math.random() < ratio) {
throw new Error('Random failure')
}
return component
return componentInternalTag
}

// Filesystem actions
export const saveComponentsToFiles = async (
space: string,
spaceData: SpaceData,
options: SaveComponentsOptions,
) => {
const { components, groups, presets } = spaceData
const { components, groups, presets, internalTags } = spaceData
const { filename = 'components', suffix, path, separateFiles } = options
const resolvedPath = resolvePath(path, `components/${space}`)

Expand All @@ -128,9 +230,13 @@ export const saveComponentsToFiles = async (
const presetsFilePath = join(resolvedPath, suffix ? `${component.name}.preset.${suffix}.json` : `${component.name}.preset.json`)
await saveToFile(presetsFilePath, JSON.stringify(componentPresets, null, 2))
}
// Save groups
// Always save groups in a consolidated file
const groupsFilePath = join(resolvedPath, suffix ? `groups.${suffix}.json` : `groups.json`)
await saveToFile(groupsFilePath, JSON.stringify(groups, null, 2))

// Always save internal tags in a consolidated file
const internalTagsFilePath = join(resolvedPath, suffix ? `tags.${suffix}.json` : `tags.json`)
await saveToFile(internalTagsFilePath, JSON.stringify(internalTags, null, 2))
}
return
}
Expand All @@ -148,6 +254,11 @@ export const saveComponentsToFiles = async (
const presetsFilePath = join(resolvedPath, suffix ? `presets.${suffix}.json` : `presets.json`)
await saveToFile(presetsFilePath, JSON.stringify(presets, null, 2))
}

if (internalTags.length > 0) {
const internalTagsFilePath = join(resolvedPath, suffix ? `tags.${suffix}.json` : `tags.json`)
await saveToFile(internalTagsFilePath, JSON.stringify(internalTags, null, 2))
}
}
catch (error) {
handleFileSystemError('write', error as Error)
Expand All @@ -164,19 +275,20 @@ export const readComponentsFiles = async (
components: [],
groups: [],
presets: [],
internalTags: [],
}
// Add regex patterns to match file structures
const componentsPattern = /^components(?:\..+)?\.json$/
const groupsPattern = /^groups(?:\..+)?\.json$/
const presetsPattern = /^presets(?:\..+)?\.json$/

const internalTagsPattern = /^tags(?:\..+)?\.json$/
try {
if (!separateFiles) {
// Read from consolidated files
const files = await readdir(resolvedPath, { recursive: !separateFiles })

for (const file of files) {
if (!file.endsWith('.json') || !componentsPattern.test(file) && !groupsPattern.test(file) && !presetsPattern.test(file)) { continue }
if (!file.endsWith('.json') || !componentsPattern.test(file) && !groupsPattern.test(file) && !presetsPattern.test(file) && !internalTagsPattern.test(file)) { continue }

try {
const content = await readFile(join(resolvedPath, file), 'utf-8')
Expand All @@ -193,6 +305,9 @@ export const readComponentsFiles = async (
else if (presetsPattern.test(file)) {
spaceData.presets = data
}
else if (internalTagsPattern.test(file)) {
spaceData.internalTags = data
}
}
catch (error) {
// Ignore file not found errors
Expand Down Expand Up @@ -230,6 +345,9 @@ export const readComponentsFiles = async (
else if (groupsPattern.test(file)) {
spaceData.groups = data
}
else if (internalTagsPattern.test(file)) {
spaceData.internalTags = data
}
else {
// Regular component file
const component = Array.isArray(data) ? data[0] : data
Expand Down
11 changes: 9 additions & 2 deletions src/commands/components/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ export interface SpaceComponent {
real_name?: string
component_group_uuid?: string
color: null
internal_tags_list: string[]
interntal_tags_ids: number[]
internal_tags_list: SpaceComponentInternalTag[]
internal_tag_ids: string[]
content_type_asset_preview?: string
}

Expand All @@ -44,10 +44,17 @@ export interface SpaceComponentPreset {
description: string
}

export interface SpaceComponentInternalTag {
id: number
name: string
object_type?: 'asset' | 'component'
}

export interface SpaceData {
components: SpaceComponent[]
groups: SpaceComponentGroup[]
presets: SpaceComponentPreset[]
internalTags: SpaceComponentInternalTag[]
}
/**
* Interface representing the options for the `pull-components` command.
Expand Down
48 changes: 36 additions & 12 deletions src/commands/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { colorPalette, commands } from '../../constants'
import { session } from '../../session'
import { getProgram } from '../../program'
import { APIError, CommandError, handleError, konsola } from '../../utils'
import { fakePushComponent, fetchComponent, fetchComponentGroups, fetchComponentPresets, fetchComponents, pushComponent, readComponentsFiles, saveComponentsToFiles } from './actions'
import { fakePushComponent, fakePushComponentInternalTag, fetchComponent, fetchComponentGroups, fetchComponentInternalTags, fetchComponentPresets, fetchComponents, pushComponent, pushComponentInternalTag, readComponentsFiles, saveComponentsToFiles } from './actions'
import type { PullComponentsOptions, PushComponentsOptions } from './constants'

import { Spinner } from '@topcli/spinner'
Expand Down Expand Up @@ -44,23 +44,32 @@ componentsCommand
}

try {
const spinner = new Spinner()
// Fetch components groups
const spinnerGroups = new Spinner()
.start(`Fetching ${chalk.hex(colorPalette.COMPONENTS)('components groups')}`)

// Fetch all data first
const groups = await fetchComponentGroups(space, state.password, state.region)
spinner.succeed()
const spinner2 = new Spinner()
spinnerGroups.succeed(`${chalk.hex(colorPalette.COMPONENTS)('Groups')} - Completed in ${spinnerGroups.elapsedTime.toFixed(2)}ms`)

// Fetch components presets
const spinnerPresets = new Spinner()
.start(`Fetching ${chalk.hex(colorPalette.COMPONENTS)('components presets')}`)

const presets = await fetchComponentPresets(space, state.password, state.region)
spinner2.succeed()
spinnerPresets.succeed(`${chalk.hex(colorPalette.COMPONENTS)('Presets')} - Completed in ${spinnerPresets.elapsedTime.toFixed(2)}ms`)

const spinner3 = new Spinner()
.start(`Fetching ${chalk.hex(colorPalette.COMPONENTS)('components')}`)
// Fetch components internal tags
const spinnerInternalTags = new Spinner()
.start(`Fetching ${chalk.hex(colorPalette.COMPONENTS)('components internal tags')}`)

const internalTags = await fetchComponentInternalTags(space, state.password, state.region)
spinnerInternalTags.succeed(`${chalk.hex(colorPalette.COMPONENTS)('Tags')} - Completed in ${spinnerInternalTags.elapsedTime.toFixed(2)}ms`)

// Save everything using the new structure
let components
const spinnerComponents = new Spinner()
.start(`Fetching ${chalk.hex(colorPalette.COMPONENTS)('components')}`)

if (componentName) {
const component = await fetchComponent(space, componentName, state.password, state.region)
if (!component) {
Expand All @@ -76,18 +85,19 @@ componentsCommand
return
}
}
spinner3.succeed()
spinnerComponents.succeed(`${chalk.hex(colorPalette.COMPONENTS)('Components')} - Completed in ${spinnerComponents.elapsedTime.toFixed(2)}ms`)
await saveComponentsToFiles(
space,
{ components, groups: groups || [], presets: presets || [] },
{ components, groups: groups || [], presets: presets || [], internalTags: internalTags || [] },
{ ...options, path, separateFiles: separateFiles || !!componentName },
)

konsola.br()
if (separateFiles) {
if (filename !== 'components') {
konsola.warn(`The --filename option is ignored when using --separate-files`)
}
const filePath = path ? `${path}/` : `.storyblok/components/${space}/`

konsola.ok(`Components downloaded successfully to ${chalk.hex(colorPalette.PRIMARY)(filePath)}`)
}
else if (componentName) {
Expand Down Expand Up @@ -156,7 +166,21 @@ componentsCommand
const spinner = new Spinner()
.start(`${chalk.hex(colorPalette.COMPONENTS)(component.name)} - Pushing...`)
try {
await fakePushComponent(component)
if (component.internal_tag_ids.length > 0) {
spinner.text = `Pushing ${chalk.hex(colorPalette.COMPONENTS)(component.name)} internal tags...`
await Promise.all(component.internal_tag_ids.map(async (tagId) => {
const tag = spaceData.internalTags.find(tag => tag.id === Number(tagId))
if (tag) {
try {
await pushComponentInternalTag(space, tag, state.password, state.region)
}
catch (error) {
konsola.warn(`Failed to push internal tag ${tag.name}`)
}
}
}))
}
await pushComponent(space, component, state.password, state.region)
// await pushComponent(space, component, state.password, state.region)
spinner.succeed(`${chalk.hex(colorPalette.COMPONENTS)(component.name)} - Completed in ${spinner.elapsedTime.toFixed(2)}ms`)
}
Expand Down
4 changes: 4 additions & 0 deletions src/utils/error/api-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,11 @@ export const API_ACTIONS = {
pull_components: 'Failed to pull components',
pull_component_groups: 'Failed to pull component groups',
pull_component_presets: 'Failed to pull component presets',
pull_component_internal_tags: 'Failed to pull component internal tags',
push_component: 'Failed to push component',
push_component_group: 'Failed to push component group',
push_component_preset: 'Failed to push component preset',
push_component_internal_tag: 'Failed to push component internal tag',
} as const

export const API_ERRORS = {
Expand Down

0 comments on commit 14fde8c

Please sign in to comment.