Skip to content

Commit

Permalink
feat: enhance component push with comprehensive resource synchronization
Browse files Browse the repository at this point in the history
- Added support for pushing component groups, presets, and internal tags
- Implemented upsert methods for component groups and presets
- Improved error handling for resource conflicts and name collisions
- Added recursive property removal utility for clean preset updates
- Updated component push command to handle consolidated and separate file scenarios
- Enhanced error messaging and spinner feedback for different resource types
  • Loading branch information
alvarosabu committed Jan 29, 2025
1 parent 030d5d3 commit a3ff665
Show file tree
Hide file tree
Showing 5 changed files with 234 additions and 25 deletions.
35 changes: 35 additions & 0 deletions src/commands/components/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Testing checklist

## Pushing components `storyblok components push`

## General

- [ ] It should show the command title
- [ ] It should trow an error if the user is not logged in `You are currently not logged in. Please login first to get your user info.`
-
### `-s=TARGET_SPACE_ID`

- [ ] It should read the components files and related resources from **consolidated files**[^1] `.storyblok/components/<TARGET_SPACE_ID>/`
- [ ] It should upsert tags if consolidated files are found `.storyblok/components/<TARGET_SPACE_ID>/tags.json`
- [ ] It should upsert presets if consolidated files are found `.storyblok/components/<TARGET_SPACE_ID>/presets.json`
- [ ] It should upsert groups if consolidated files are found `.storyblok/components/<TARGET_SPACE_ID>/groups.json`
- [ ] It should upsert deep nested groups if consolidated files are found `.storyblok/components/<TARGET_SPACE_ID>/groups.json`
- [ ] It should upsert the components to the target space `.storyblok/components/<TARGET_SPACE_ID>/components.json`

#### Error handling
- [ ] It should throw and error if the space is not provided: `Please provide the target space as argument --space YOUR_SPACE_ID. `

### `-f=SOURCE_SPACE_ID`

- [ ] It should use the target space as source if no source space is provided
- [ ] It should read the components files from `.storyblok/components/<SOURCE_SPACE_ID>/`
- [ ] It should upsert the components to the target space from the source space

#### Error handling

- [ ] It should throw an error if the source space does not exist: `The space folder 'SOURCE_SPACE_ID' doesn't exist yet. Please run 'storyblok components pull -s=SOURCE_SPACE_ID' first to fetch the components.`
- [ ] It should throw a warning if the source space does not have any components (ex components.json is empty or doesn't exist): `No components found that meet the filter criteria. Please make sure you have pulled the components first and that the filter is correct.`

### `--separate-files`

[^1] Consolidated files are the default way to store components. They are a single file for each resource (components.json, groups.json, presets.json, tags.json).
102 changes: 98 additions & 4 deletions src/commands/components/actions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { handleAPIError, handleFileSystemError } from '../../utils'
import { FileSystemError, handleAPIError, handleFileSystemError } from '../../utils'
import type { RegionCode } from '../../constants'
import { join, parse } from 'node:path'
import { resolvePath, saveToFile } from '../../utils/filesystem'
Expand Down Expand Up @@ -136,7 +136,7 @@ export const fetchComponentPresets = async (space: string, token: string, region
}
}

export const pushComponentPreset = async (space: string, componentPreset: SpaceComponentPreset, token: string, region: RegionCode): Promise<SpaceComponentPreset | undefined> => {
export const pushComponentPreset = async (space: string, componentPreset: { preset: Partial<SpaceComponentPreset> }, token: string, region: RegionCode): Promise<SpaceComponentPreset | undefined> => {
try {
const url = getStoryblokUrl(region)
const response = await customFetch<{
Expand Down Expand Up @@ -270,6 +270,18 @@ export const readComponentsFiles = async (
options: ReadComponentsOptions): Promise<SpaceData> => {
const { filter, separateFiles, path, from } = options
const resolvedPath = resolvePath(path, `components/${from}`)

// Check if the path exists first
try {
await readdir(resolvedPath)
}
catch (error) {
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
throw new FileSystemError('file_not_found', 'read', error as Error, `The space folder '${from}' doesn't exist yet. Please run 'storyblok components pull -s=${from}' first to fetch the components.`)
}
throw error
}

const regex = filter ? new RegExp(filter) : null

const spaceData: SpaceData = {
Expand Down Expand Up @@ -398,6 +410,44 @@ export const updateComponent = async (space: string, componentId: number, compon
}
}

export const updateComponentGroup = async (space: string, groupId: number, 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/${groupId}`, {
method: 'PUT',
headers: {
Authorization: token,
},
body: JSON.stringify(componentGroup),
})
return response.component_group
}
catch (error) {
handleAPIError('update_component_group', error as Error, `Failed to update component group ${componentGroup.name}`)
}
}

export const updateComponentPreset = async (space: string, presetId: number, componentPreset: { preset: Partial<SpaceComponentPreset> }, token: string, region: RegionCode): Promise<SpaceComponentPreset | undefined> => {
try {
const url = getStoryblokUrl(region)
const response = await customFetch<{
preset: SpaceComponentPreset
}>(`${url}/spaces/${space}/presets/${presetId}`, {
method: 'PUT',
headers: {
Authorization: token,
},
body: JSON.stringify(componentPreset),
})
return response.preset
}
catch (error) {
handleAPIError('update_component_preset', error as Error, `Failed to update component preset ${componentPreset.name}`)
}
}

export const updateComponentInternalTag = async (space: string, tagId: number, componentInternalTag: SpaceComponentInternalTag, token: string, region: RegionCode): Promise<SpaceComponentInternalTag | undefined> => {
try {
const url = getStoryblokUrl(region)
Expand All @@ -423,7 +473,8 @@ export const upsertComponent = async (space: string, component: SpaceComponent,
}
catch (error) {
if (error instanceof APIError && error.code === 422) {
if (error.response?.data?.name && error.response?.data?.name[0] === 'has already been taken') {
const responseData = error.response?.data as { [key: string]: string[] } | undefined
if (responseData?.name?.[0] === 'has already been taken') {
// Find existing component by name
const existingComponent = await fetchComponent(space, component.name, token, region)
if (existingComponent) {
Expand All @@ -442,7 +493,8 @@ export const upsertComponentInternalTag = async (space: string, tag: SpaceCompon
}
catch (error) {
if (error instanceof APIError && error.code === 422) {
if (error.response?.data?.name && error.response?.data?.name[0] === 'has already been taken') {
const responseData = error.response?.data as { [key: string]: string[] } | undefined
if (responseData?.name?.[0] === 'has already been taken') {
// Find existing tag by name
const existingTags = await fetchComponentInternalTags(space, token, region)
const existingTag = existingTags?.find(t => t.name === tag.name)
Expand All @@ -455,3 +507,45 @@ export const upsertComponentInternalTag = async (space: string, tag: SpaceCompon
throw error
}
}

export const upsertComponentGroup = async (space: string, group: SpaceComponentGroup, token: string, region: RegionCode): Promise<SpaceComponentGroup | undefined> => {
try {
return await pushComponentGroup(space, group, token, region)
}
catch (error) {
if (error instanceof APIError && error.code === 422) {
const responseData = error.response?.data as { [key: string]: string[] } | undefined
if (responseData?.name?.[0] === 'has already been taken') {
// Find existing group by name
const existingGroups = await fetchComponentGroups(space, token, region)
const existingGroup = existingGroups?.find(g => g.name === group.name)
if (existingGroup) {
// Update existing group
return await updateComponentGroup(space, existingGroup.id, group, token, region)
}
}
}
throw error
}
}

export const upsertComponentPreset = async (space: string, preset: Partial<SpaceComponentPreset>, token: string, region: RegionCode): Promise<SpaceComponentPreset | undefined> => {
try {
return await pushComponentPreset(space, { preset }, token, region)
}
catch (error) {
if (error instanceof APIError && error.code === 422) {
const responseData = error.response?.data as { [key: string]: string[] } | undefined
if (responseData?.name?.[0] === 'has already been taken') {
// Find existing preset by name
const existingPresets = await fetchComponentPresets(space, token, region)
const existingPreset = existingPresets?.find(p => p.name === preset.name)
if (existingPreset) {
// Update existing preset
return await updateComponentPreset(space, existingPreset.id, { preset }, token, region)
}
}
}
throw error
}
}
91 changes: 76 additions & 15 deletions src/commands/components/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ import chalk from 'chalk'
import { colorPalette, commands } from '../../constants'
import { session } from '../../session'
import { getProgram } from '../../program'
import { CommandError, handleError, konsola } from '../../utils'
import { fetchComponent, fetchComponentGroups, fetchComponentInternalTags, fetchComponentPresets, fetchComponents, readComponentsFiles, saveComponentsToFiles, upsertComponent, upsertComponentInternalTag } from './actions'
import { CommandError, handleError, konsola, removePropertyRecursively, removeUidRecursively } from '../../utils'
import { fetchComponent, fetchComponentGroups, fetchComponentInternalTags, fetchComponentPresets, fetchComponents, readComponentsFiles, saveComponentsToFiles, upsertComponent, upsertComponentGroup, upsertComponentInternalTag, upsertComponentPreset } from './actions'
import type { PullComponentsOptions, PushComponentsOptions, SpaceComponentInternalTag } from './constants'

import { Spinner } from '@topcli/spinner'
Expand All @@ -14,7 +14,7 @@ export const componentsCommand = program
.command('components')
.alias('comp')
.description(`Manage your space's block schema`)
.requiredOption('-s, --space <space>', 'space ID')
.option('-s, --space <space>', 'space ID')
.option('-p, --path <path>', 'path to save the file. Default is .storyblok/components')

componentsCommand
Expand Down Expand Up @@ -122,15 +122,15 @@ componentsCommand
componentsCommand
.command('push [componentName]')
.description(`Push your space's components schema as json`)
.requiredOption('-f, --from <from>', 'source space id')
.option('-f, --from <from>', 'source space id')
.option('--fi, --filter <filter>', 'glob filter to apply to the components before pushing')
.option('--sf, --separate-files', 'Read from separate files instead of consolidated files')
.action(async (componentName: string | undefined, options: PushComponentsOptions) => {
konsola.title(` ${commands.COMPONENTS} `, colorPalette.COMPONENTS, componentName ? `Pushing component ${componentName}...` : 'Pushing components...')
// Global options
const verbose = program.opts().verbose
const { space, path } = componentsCommand.opts()
const { filter } = options
const { from, filter, separateFiles } = options

const { state, initializeSession } = session()
await initializeSession()
Expand All @@ -140,10 +140,15 @@ componentsCommand
return
}
if (!space) {
handleError(new CommandError(`Please provide the space as argument --space YOUR_SPACE_ID.`), verbose)
handleError(new CommandError(`Please provide the target space as argument --space TARGET_SPACE_ID.`), verbose)
return
}

if (!from) {
// If no source space is provided, use the target space as source
options.from = space
}

const { password, region } = state

try {
Expand All @@ -166,16 +171,47 @@ componentsCommand
failed: [] as Array<{ name: string, error: unknown }>,
}

if (!separateFiles) {
// If separate files are not used, we need to upsert the tags first
await Promise.all(spaceData.internalTags.map(async (tag) => {
const consolidatedSpinner = new Spinner()
consolidatedSpinner.start('Upserting tags...')
try {
await upsertComponentInternalTag(space, tag, password, region)
consolidatedSpinner.succeed(`Tag-> ${chalk.hex(colorPalette.COMPONENTS)(tag.name)} - Completed in ${consolidatedSpinner.elapsedTime.toFixed(2)}ms`)
}
catch (error) {
consolidatedSpinner.failed(`Tag-> ${chalk.hex(colorPalette.COMPONENTS)(tag.name)} - Failed`)
results.failed.push({ name: tag.name, error })
}
}))
// Upsert groups
await Promise.all(spaceData.groups.map(async (group) => {
const consolidatedSpinner = new Spinner()
consolidatedSpinner.start('Upserting groups...')
try {
await upsertComponentGroup(space, group, password, region)
consolidatedSpinner.succeed(`Group-> ${chalk.hex(colorPalette.COMPONENTS)(group.name)} - Completed in ${consolidatedSpinner.elapsedTime.toFixed(2)}ms`)
}
catch (error) {
consolidatedSpinner.failed(`Group-> ${chalk.hex(colorPalette.COMPONENTS)(group.name)} - Failed`)
results.failed.push({ name: group.name, error })
}
}))
}

await Promise.all(spaceData.components.map(async (component) => {
const spinner = new Spinner()
.start(`${chalk.hex(colorPalette.COMPONENTS)(component.name)} - Pushing...`)
try {
const processedTags: { ids: string[], tags: SpaceComponentInternalTag[] } = { ids: [], tags: [] }

if (component.internal_tag_ids?.length > 0) {
spinner.text = `Pushing ${chalk.hex(colorPalette.COMPONENTS)(component.name)} internal tags...`
if (component.internal_tag_ids?.length > 0 && separateFiles) {
// spinner.text = `Pushing ${chalk.hex(colorPalette.COMPONENTS)(component.name)} internal tags...`
// Process tags sequentially to ensure order
for (const tagId of component.internal_tag_ids) {
await Promise.all(component.internal_tag_ids.map(async (tagId) => {
const internalTagsSpinner = new Spinner()
internalTagsSpinner.start(`Pushing ${chalk.hex(colorPalette.COMPONENTS)(component.name)} internal tags...`)
const tag = spaceData.internalTags.find(tag => tag.id === Number(tagId))

if (tag) {
Expand All @@ -184,15 +220,15 @@ componentsCommand
if (updatedTag) {
processedTags.tags.push(updatedTag)
processedTags.ids.push(updatedTag.id.toString())
internalTagsSpinner.succeed(`Tag-> ${chalk.hex(colorPalette.COMPONENTS)(tag.name)} - Completed in ${internalTagsSpinner.elapsedTime.toFixed(2)}ms`)
}
}
catch (error) {
const spinnerFailedMessage = `${chalk.hex(colorPalette.COMPONENTS)(component.name)} - Failed`
spinner.failed(spinnerFailedMessage)
internalTagsSpinner.failed(`Tag-> ${chalk.hex(colorPalette.COMPONENTS)(tag.name)} - Failed`)
results.failed.push({ name: tag.name, error })
}
}
}
}))
}

// Create a new component object with the processed tags
Expand All @@ -201,12 +237,37 @@ componentsCommand
internal_tag_ids: processedTags.ids,
internal_tags_list: processedTags.tags,
}
await upsertComponent(space, componentToUpdate, password, region)
spinner.succeed(`${chalk.hex(colorPalette.COMPONENTS)(component.name)} - Completed in ${spinner.elapsedTime.toFixed(2)}ms`)
const updatedComponent = await upsertComponent(space, componentToUpdate, password, region)
if (updatedComponent) {
const relatedPresets = spaceData.presets.filter(preset => preset.component_id === component.id)
if (relatedPresets.length > 0) {
await Promise.all(relatedPresets.map(async (preset) => {
const presetSpinner = new Spinner()
presetSpinner.start(`Upserting ${chalk.hex(colorPalette.COMPONENTS)(preset.name)}...`)
try {
const presetToUpdate = {
name: preset.name,
preset: removePropertyRecursively(
removePropertyRecursively(preset.preset, '_uid'),
'component',
),
component_id: updatedComponent.id,
}
await upsertComponentPreset(space, presetToUpdate, password, region)
presetSpinner.succeed(`Preset-> ${chalk.hex(colorPalette.COMPONENTS)(preset.name)} - Completed in ${presetSpinner.elapsedTime.toFixed(2)}ms`)
}
catch (error) {
presetSpinner.failed(`Preset-> ${chalk.hex(colorPalette.COMPONENTS)(preset.name)} - Failed`)
results.failed.push({ name: preset.name, error })
}
}))
}
}
spinner.succeed(`Component-> ${chalk.hex(colorPalette.COMPONENTS)(component.name)} - Completed in ${spinner.elapsedTime.toFixed(2)}ms`)
results.successful.push(component.name)
}
catch (error) {
const spinnerFailedMessage = `${chalk.hex(colorPalette.COMPONENTS)(component.name)} - Failed`
const spinnerFailedMessage = `Component-> ${chalk.hex(colorPalette.COMPONENTS)(component.name)} - Failed`
spinner.failed(spinnerFailedMessage)
results.failed.push({ name: component.name, error })
}
Expand Down
13 changes: 7 additions & 6 deletions src/utils/error/api-error.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,13 @@ export class APIError extends Error {
this.messageStack.push(customMessage || API_ERRORS[errorId])

if (this.code === 422) {
Object.entries(this.response?.data || {}).forEach(([key, error]: [string, string[]]) => {
if (key === 'name' && error[0] === 'has already been taken') {
this.message = 'A component with this name already exists'
}
if (Array.isArray(error)) {
error.forEach((e: string) => {
const responseData = this.response?.data as { [key: string]: string[] } | undefined
if (responseData?.name?.[0] === 'has already been taken') {
this.message = 'A component with this name already exists'
}
Object.entries(responseData || {}).forEach(([key, errors]) => {
if (Array.isArray(errors)) {
errors.forEach((e) => {
this.messageStack.push(`${key}: ${e}`)
})
}
Expand Down
18 changes: 18 additions & 0 deletions src/utils/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,21 @@ export const slugify = (text: string): string =>
.replace(/-{2,}/g, '-') // Replace multiple - with single -
.replace(/^-+/, '') // Trim - from start of text
.replace(/-+$/, '')

export const removePropertyRecursively = (obj: Record<string, any>, property: string): Record<string, any> => {
if (typeof obj !== 'object' || obj === null) {
return obj
}

if (Array.isArray(obj)) {
return obj.map(item => removePropertyRecursively(item, property))
}

const result: Record<string, any> = {}
for (const [key, value] of Object.entries(obj)) {
if (key !== property) {
result[key] = removePropertyRecursively(value, property)
}
}
return result
}

0 comments on commit a3ff665

Please sign in to comment.