Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix/radio #4875

Draft
wants to merge 2 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/bright-pens-take.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@ultraviolet/ui": patch
---

Fix `<Radio />` component to have correct id on label
36 changes: 36 additions & 0 deletions packages/cli/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
{
"name": "@ultraviolet/cli",
"version": "1.0.0",
"description": "CLI tools for Ultraviolet Design System",
"main": "dist/index.js",
"bin": {
"uv": "dist/index.js"
},
"scripts": {
"build": "vite build --config vite.config.ts",
"start": "tsx ./src/index.ts"
},
"repository": {
"type": "git",
"url": "github.com:scaleway/ultraviolet.git"
},
"keywords": [
"cli"
],
"author": "Scaleway",
"license": "Apache-2.0",
"type": "module",
"dependencies": {
"@babel/parser": "7.26.10",
"@babel/traverse": "7.26.10",
"@babel/types": "7.26.10",
"ast-types": "0.14.2",
"commander": "13.1.0",
"recast": "0.23.11",
"semver": "7.7.1"
},
"devDependencies": {
"@types/babel__generator": "7.6.8",
"@types/babel__traverse": "7.20.6"
}
}
46 changes: 46 additions & 0 deletions packages/cli/src/commands/migrate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
import * as semver from 'semver'

// Function to get the local version of @ultraviolet/ui
function getUltravioletUiVersion(directory: string): string | null {
const packageJsonPath = path.resolve(
`${directory}/node_modules/@ultraviolet/ui/package.json`,
)
if (fs.existsSync(packageJsonPath)) {
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8'))

Check failure on line 11 in packages/cli/src/commands/migrate.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe assignment of an `any` value
return packageJson.version

Check failure on line 12 in packages/cli/src/commands/migrate.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe member access .version on an `any` value

Check failure on line 12 in packages/cli/src/commands/migrate.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe return of a value of type `any`

Check failure on line 12 in packages/cli/src/commands/migrate.ts

View workflow job for this annotation

GitHub Actions / lint

Expected blank line before this statement
}
return null

Check failure on line 14 in packages/cli/src/commands/migrate.ts

View workflow job for this annotation

GitHub Actions / lint

Expected blank line before this statement
}

// Function to dynamically import and run the correct migration file
export async function migrate(directory: string): Promise<void> {
const version = getUltravioletUiVersion(directory)
if (version) {
const majorVersion = semver.major(version) + 1

Check failure on line 21 in packages/cli/src/commands/migrate.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe member access .major on an `error` typed value

Check failure on line 21 in packages/cli/src/commands/migrate.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe call of a(n) `error` type typed value

Check failure on line 21 in packages/cli/src/commands/migrate.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe assignment of an error typed value
try {
const migrationModulePath = `./migrations/migrateToV${majorVersion}`
console.log(`Loading migration script: ${migrationModulePath}`)
const migrationModule = await import(migrationModulePath)

Check failure on line 25 in packages/cli/src/commands/migrate.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe assignment of an `any` value
const migrateFunctionName = `migrateToV${majorVersion}`
if (
migrationModule &&
typeof migrationModule[migrateFunctionName] === 'function'

Check failure on line 29 in packages/cli/src/commands/migrate.ts

View workflow job for this annotation

GitHub Actions / lint

Unsafe member access [migrateFunctionName] on an `any` value
) {
migrationModule[migrateFunctionName](directory)
} else {
console.error(
`Migration function ${migrateFunctionName} not found in ${migrationModulePath}.`,
)
}
} catch (error) {
console.error(
`Error loading migration script for @ultraviolet/ui@${majorVersion}.x.x:`,
error,
)
}
} else {
console.error('@ultraviolet/ui is not installed in the project.')
}
}
185 changes: 185 additions & 0 deletions packages/cli/src/commands/migrations/migrateToV2.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,185 @@
import * as fs from 'node:fs'
import * as path from 'node:path'
import { parse } from '@babel/parser'
import traverse from '@babel/traverse'
import * as t from '@babel/types'
import { parse as recastParse, print as recastPrint } from 'recast'

const kebabCaseToPasarCase = (kebabCase: string) =>
kebabCase
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join('')

// Function to convert icon name to component name
const convertIconName = (iconName: string) =>
`${kebabCaseToPasarCase(iconName)}Icon`

// Define the components and their deprecated props
const components: { [key: string]: string[] } = {
Button: ['icon', 'iconPosition', 'iconVariant'],
Badge: ['icon'],
Bullet: ['icon', 'iconVariant'],
AvatarV2: ['icon'],
Separator: ['icon'],
Tag: ['icon'],
} as const

// Define the new sizes mapping
const sizeMapping: { [key: string]: string } = {
small: 'small',
large: 'medium',
xlarge: 'xlarge',
xxlarge: 'xxlarge',
} as const

// Function to update the component usage
function updateComponentUsage(fileContent: string): string {
const ast = recastParse(fileContent, {
parser: {
parse(source: string) {
return parse(source, {
sourceType: 'module',
plugins: ['jsx', 'typescript'],
})
},
},
})

const imports: Set<string> = new Set()

traverse(ast, {
JSXOpeningElement(path) {
const openingElement = path.node
const componentName = (openingElement.name as t.JSXIdentifier).name

if (components[componentName]) {
components[componentName].forEach(deprecatedProp => {
const attributeIndex = openingElement.attributes.findIndex(
attr =>
t.isJSXAttribute(attr) &&
t.isJSXIdentifier(attr.name) &&
attr.name.name === deprecatedProp,
)

if (attributeIndex !== -1) {
const attribute = openingElement.attributes[
attributeIndex
] as t.JSXAttribute

if (
deprecatedProp === 'icon' &&
t.isStringLiteral(attribute.value)
) {
const iconName = attribute.value.value
const hasOutlinedVariant = openingElement.attributes.some(
attr =>
t.isJSXAttribute(attr) &&
t.isJSXIdentifier(attr.name) &&
attr.name.name === 'iconVariant' &&
t.isStringLiteral(attr.value) &&
attr.value.value === 'outlined',
)
const newName = `${convertIconName(iconName)}${hasOutlinedVariant ? 'Outline' : ''}`
imports.add(newName)

// Remove the deprecated icon attribute
openingElement.attributes.splice(attributeIndex, 1)

// Add the new icon component as a child
const newIconElement = t.jsxElement(
t.jsxOpeningElement(t.jsxIdentifier(newName), [], true),
null,
[],
true,
)

if (path.parentPath.isJSXElement()) {
const parentElement = path.parentPath.node
parentElement.children.unshift(newIconElement)
}
} else {
// Remove the deprecated attribute
openingElement.attributes.splice(attributeIndex, 1)
}
}
})
}
},
})

if (imports.size > 0) {
const importDeclaration = t.importDeclaration(
Array.from(imports).map(importName =>
t.importSpecifier(t.identifier(importName), t.identifier(importName)),
),
t.stringLiteral('@ultraviolet/icons'),
)

ast.program.body.unshift(importDeclaration)
}

return recastPrint(ast).code
}

// Function to update the size usage
function updateSizeUsage(fileContent: string): string {
const ast = recastParse(fileContent, {
parser: {
parse(source: string) {
return parse(source, {
sourceType: 'module',
plugins: ['jsx', 'typescript'],
})
},
},
})

traverse(ast, {
JSXAttribute(path) {
if (t.isJSXIdentifier(path.node.name) && path.node.name.name === 'size') {
const attributeValue = path.node.value
if (t.isStringLiteral(attributeValue)) {
const newSize = sizeMapping[attributeValue.value]
if (newSize) {
path.node.value = t.stringLiteral(newSize)
}
}
}
},
})

return recastPrint(ast).code
}

// Function to process each file in the codebase
function processFiles(directory: string): void {
fs.readdirSync(directory).forEach(file => {
const filePath = path.join(directory, file)
const stat = fs.statSync(filePath)

if (stat.isDirectory()) {
if (file !== 'node_modules') {
processFiles(filePath)
}
} else if (
filePath.endsWith('.js') ||
filePath.endsWith('.ts') ||
filePath.endsWith('.jsx') ||
filePath.endsWith('.tsx')
) {
let fileContent = fs.readFileSync(filePath, 'utf8')

fileContent = updateComponentUsage(fileContent)
fileContent = updateSizeUsage(fileContent)

fs.writeFileSync(filePath, fileContent, 'utf8')
}
})
}

// Export the migrate function
export function migrateToV2(directory: string): void {
processFiles(directory)
console.log('Migration to v2 completed successfully.')
}
21 changes: 21 additions & 0 deletions packages/cli/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { Command } from 'commander'
import { migrate } from './commands/migrate'
import { readFileSync } from 'node:fs'
import { join } from 'node:path'

const packageJson = JSON.parse(
readFileSync(join(process.cwd(), 'package.json'), 'utf-8'),
)
const program = new Command()

program.name('uv').description('Ultraviolet CLI').version(packageJson.version)

program
.command('migrate')
.description('Migrate from one version to another')
.argument('<directory>', 'Directory from where to start the migration')
.action((directory: string) => {
migrate(directory)
})

program.parse(process.argv)
8 changes: 8 additions & 0 deletions packages/cli/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.json",
"compilerOptions": {
"baseUrl": "."
},
"include": ["src", "../../global.d.ts", "emotion.d.ts", "vitest.setup.ts"],
"exclude": ["node_modules", "coverage", "dist"]
}
4 changes: 4 additions & 0 deletions packages/cli/vite.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { defineConfig, mergeConfig } from 'vite'
import { defaultConfig } from '../../vite.config'

export default mergeConfig(defineConfig(defaultConfig), {})
2 changes: 1 addition & 1 deletion packages/ui/src/components/Radio/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -245,7 +245,7 @@ export const Radio = forwardRef<HTMLInputElement, RadioProps>(
{label}
</StyledTextLabel>
) : (
<StyledLabel htmlFor={id}>{label}</StyledLabel>
<StyledLabel htmlFor={localId}>{label}</StyledLabel>
)}
</>
) : null}
Expand Down
Loading
Loading