diff --git a/.changeset/young-bags-deliver.md b/.changeset/young-bags-deliver.md new file mode 100644 index 00000000000..6c99c2afb67 --- /dev/null +++ b/.changeset/young-bags-deliver.md @@ -0,0 +1,5 @@ +--- +'@kaizen/components': patch +--- + +Add codemod to upgrade V1 `Button` and `IconButton`. diff --git a/packages/components/bin/codemod.sh b/packages/components/bin/codemod.sh index 07c43c4bbac..070a86bf0aa 100755 --- a/packages/components/bin/codemod.sh +++ b/packages/components/bin/codemod.sh @@ -31,6 +31,8 @@ echo "" if npx tsx@latest $CODEMOD_PATH $TARGET_DIR; then echo "Codemod '$codemodFileName' completed successfully in directory '$transformDir'" + echo "---" + echo "Run linting and prettier to correct issues with re-writes" else echo "Codemod '$codemodFileName' could not be run in '$TARGET_DIR'" exit 1 diff --git a/packages/components/codemods/README.md b/packages/components/codemods/README.md index 200bb3296a5..7dc3dd98742 100644 --- a/packages/components/codemods/README.md +++ b/packages/components/codemods/README.md @@ -103,6 +103,51 @@ Released in `1.60.0` Removes `Popover` component props `variant` and `customIcon`. +### `upgradeV1Buttons` + +Released in `TBC` + +Migrates `Button` and `IconButton` component to `Button` V3 or `LinkButton`. + +#### Props + +- `label` becomes `children` + - eg. `` becomes `` +- `onClick` becomes `onPress` +- Variants: + - Default (undefined): + - For `Button` becomes `variant="secondary"` + - For `IconButton` becomes `variant="tertiary"` + - `primary` becomes `variant="primary"` + - `secondary` becomes `variant="tertiary"` + - `destructive` will be removed (no longer available as a variant) +- Sizes: + - Default (undefined) becomes `large` + - `small` becomes `medium` + - `regular` becomes `large` +- `reversed` becomes `isReversed` +- `classNameOverride` becomes `className` +- `data-automation-id` becomes `data-testid` +- `disabled` becomes `isDisabled` +- `newTabAndIUnderstandTheAccessibilityImplications` becomes `target="_blank"` + - `rel="noopener noreferrer"` is also added +- `component` will not be removed by the codemod, but will throw a TypeScript error as the prop itself no longer exists +- For `IconButton` only: + - `hasHiddenLabel` will be added + +#### Component transformation + +- `Button`/`IconButton` without the `href` or `component` prop will become `Button` V3 +- `Button`/`IconButton` with the `href` prop will become `LinkButton` +- `Button`/`IconButton` with the `component` prop will become `LinkButton` + +#### Imports + +All imports of V1 Buttons will now point to either: + +- `@kaizen/components/v3/actions` for `Button` +- `@kaizen/components` for `LinkButton` + ### `upgradeIconV1` Released in `1.67.0`; last updated in `1.68.1` diff --git a/packages/components/codemods/upgradeV1Buttons/index.ts b/packages/components/codemods/upgradeV1Buttons/index.ts new file mode 100644 index 00000000000..ef1fe9b7e40 --- /dev/null +++ b/packages/components/codemods/upgradeV1Buttons/index.ts @@ -0,0 +1,19 @@ +import { transformComponentsInDir } from '../utils' +import { upgradeV1Buttons } from './upgradeV1Buttons' + +const run = (): void => { + console.log('It is recommended that the `upgradeIconV1` codemod be run prior to this') + console.log('---') + console.log('~(-_- ~) Running V1 Buttons upgrade (~ -_-)~') + + const targetDir = process.argv[2] + if (!targetDir) { + process.exit(1) + } + + transformComponentsInDir(targetDir, ['IconButton', 'Button'], (tagNames) => [ + upgradeV1Buttons(tagNames), + ]) +} + +run() diff --git a/packages/components/codemods/upgradeV1Buttons/transformV1ButtonAttributes.spec.ts b/packages/components/codemods/upgradeV1Buttons/transformV1ButtonAttributes.spec.ts new file mode 100644 index 00000000000..56f331ae843 --- /dev/null +++ b/packages/components/codemods/upgradeV1Buttons/transformV1ButtonAttributes.spec.ts @@ -0,0 +1,202 @@ +import ts from 'typescript' +import { parseJsx } from '../__tests__/utils' +import { createJsxElementWithChildren, printAst } from '../utils' +import { transformV1ButtonAttributes } from './transformV1ButtonAttributes' + +export const mockedTransformer = + (kaioComponentName: string) => + (context: ts.TransformationContext) => + (rootNode: ts.Node): ts.Node => { + const visit = (node: ts.Node): ts.Node => { + if (ts.isJsxSelfClosingElement(node)) { + const { targetComponentName, newAttributes, childrenValue } = transformV1ButtonAttributes( + node, + kaioComponentName, + ) + return createJsxElementWithChildren(targetComponentName, newAttributes, childrenValue) + } + return ts.visitEachChild(node, visit, context) + } + return ts.visitNode(rootNode, visit) + } + +const transformInput = ( + sourceFile: ts.SourceFile, + kaioComponentName: string = 'Button', +): string => { + const result = ts.transform(sourceFile, [mockedTransformer(kaioComponentName)]) + const transformedSource = result.transformed[0] as ts.SourceFile + return printAst(transformedSource) +} + +describe('transformV1ButtonAttributes()', () => { + it('changes label to children', () => { + const inputAst = parseJsx('') + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + it('replaces IconButton with Button and changes label to children and adds hasHiddenLabel', () => { + const inputAst = parseJsx('') + const outputAst = parseJsx( + '', + ) + expect(transformInput(inputAst, 'IconButton')).toEqual(printAst(outputAst)) + }) + + it('replaces V1 Buttons with LinkButton if href exists', () => { + const inputAst = parseJsx('', + ) + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + it('changes reversed to isReversed', () => { + const inputAst = parseJsx(` + <> + + + + `) + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + it('changes classNameOverride to className', () => { + const inputAst = parseJsx('', + ) + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + it('changes data-automation-id to data-testid', () => { + const inputAst = parseJsx('', + ) + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + it('changes disabled to isDisabled', () => { + const inputAst = parseJsx(` + <> + + + + `) + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + it('changes newTabAndIUnderstandTheAccessibilityImplications to target="_blank" and rel="noopener noreferrer"', () => { + const inputAst = parseJsx( + '', + ) + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + describe('transform variant', () => { + it('changes default (undefined) for Button to variant secondary', () => { + const inputAst = parseJsx('') + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + it('changes default (undefined) for IconButton to variant tertiary', () => { + const inputAst = parseJsx('') + const outputAst = parseJsx( + '', + ) + expect(transformInput(inputAst, 'IconButton')).toEqual(printAst(outputAst)) + }) + + it('changes primary to variant primary', () => { + const inputAst = parseJsx('') + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + it('changes secondary to variant tertiary', () => { + const inputAst = parseJsx('') + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + it('removes destructive', () => { + const inputAst = parseJsx(` + <> + + + + + `) + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + }) + + describe('transform size', () => { + it('changes default (undefined) to large', () => { + const inputAst = parseJsx('') + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + it('changes small to medium', () => { + const inputAst = parseJsx('') + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + it('changes regular to large', () => { + const inputAst = parseJsx('') + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + + it('does not change a non-string value', () => { + const inputAst = parseJsx('') + expect(transformInput(inputAst)).toEqual(printAst(outputAst)) + }) + }) + }) +}) diff --git a/packages/components/codemods/upgradeV1Buttons/transformV1ButtonAttributes.ts b/packages/components/codemods/upgradeV1Buttons/transformV1ButtonAttributes.ts new file mode 100644 index 00000000000..fc0eb6ce0b9 --- /dev/null +++ b/packages/components/codemods/upgradeV1Buttons/transformV1ButtonAttributes.ts @@ -0,0 +1,146 @@ +import ts from 'typescript' +import { type ButtonProps as V1ButtonProps } from '~components/Button' +import { type ButtonProps as RCButtonProps } from '~components/__rc__/Button' +import { createProp, createStringProp, getPropValueText } from '../utils' + +const getNewSizeValue = ( + oldValue: Exclude, +): Exclude => { + switch (oldValue) { + case 'small': + return 'medium' + case 'regular': + return 'large' + } +} + +/** + * @returns + * - `ts.JsxAttribute` if the prop should be transformed + * - `null` if the prop should be removed + * - `undefined` if the prop should be kept as is + */ +const transformProp = ( + propName: string, + propValue: ts.JsxAttributeValue | undefined, +): ts.JsxAttribute | null | undefined => { + switch (propName) { + case 'onClick': + return createProp('onPress', propValue) + case 'reversed': + return createProp('isReversed', propValue) + case 'classNameOverride': + return createProp('className', propValue) + case 'data-automation-id': + return createProp('data-testid', propValue) + case 'fullWidth': + return createProp('isFullWidth', propValue) + case 'working': + return createProp('isPending', propValue) + case 'workingLabel': + return createProp('pendingLabel', propValue) + case 'workingLabelHidden': + return createProp('hasHiddenPendingLabel', propValue) + case 'onMouseDown': + return createProp('onPressStart', propValue) + case 'disableTabFocusAndIUnderstandTheAccessibilityImplications': + return createProp('onPressEnd', propValue) + case 'newTabAndIUnderstandTheAccessibilityImplications': + return null + case 'disabled': + return createProp('isDisabled', propValue) + case 'size': { + if (!propValue) return createStringProp('size', 'large') + + const sizeValue = getPropValueText(propValue) as Exclude + return sizeValue + ? createStringProp('size', getNewSizeValue(sizeValue)) + : createProp('size', propValue) + } + case 'primary': + return createStringProp('variant', 'primary') + case 'secondary': + return createStringProp('variant', 'tertiary') + case 'destructive': + return null + default: + return undefined + } +} + +type TransformedButtonAttributes = { + targetComponentName: string + newAttributes: ts.JsxAttributeLike[] + childrenValue: ts.JsxAttributeValue | undefined +} + +export const transformV1ButtonAttributes = ( + node: ts.JsxSelfClosingElement, + kaioComponentName: string, +): TransformedButtonAttributes => { + let childrenValue: ts.JsxAttributeValue | undefined + let hasSizeProp = false + let hasVariant = false + let hasLinkAttr = false + + const newAttributes = node.attributes.properties.reduce((acc, attr) => { + if (ts.isJsxAttribute(attr)) { + const propName = attr.name.getText() + + if (propName === 'label') { + childrenValue = attr.initializer + return acc + } + + if (propName === 'newTabAndIUnderstandTheAccessibilityImplications') { + acc.push(createStringProp('target', '_blank')) + acc.push(createStringProp('rel', 'noopener noreferrer')) + return acc + } + + if (propName === 'primary' || propName === 'secondary') { + hasVariant = true + } + + if (propName === 'size') { + hasSizeProp = true + } + + if (propName === 'href' || propName === 'component') { + hasLinkAttr = true + } + + const newProp = transformProp(propName, attr.initializer) + + if (newProp === null) return acc + + if (newProp) { + acc.push(newProp) + return acc + } + } + + acc.push(attr) + return acc + }, []) + + if (!hasVariant) { + newAttributes.push( + createStringProp('variant', kaioComponentName === 'IconButton' ? 'tertiary' : 'secondary'), + ) + } + + if (!hasSizeProp) { + newAttributes.push(createStringProp('size', 'large')) + } + + if (kaioComponentName === 'IconButton') { + newAttributes.push(createProp('hasHiddenLabel')) + } + + return { + targetComponentName: hasLinkAttr ? 'LinkButton' : 'Button', + newAttributes, + childrenValue, + } +} diff --git a/packages/components/codemods/upgradeV1Buttons/upgradeV1Buttons.spec.ts b/packages/components/codemods/upgradeV1Buttons/upgradeV1Buttons.spec.ts new file mode 100644 index 00000000000..42aefa13e3f --- /dev/null +++ b/packages/components/codemods/upgradeV1Buttons/upgradeV1Buttons.spec.ts @@ -0,0 +1,658 @@ +import { parseJsx } from '../__tests__/utils' +import { + getKaioTagNamesMapByComponentName, + printAst, + transformSource, + type TransformSourceArgs, +} from '../utils' +import { upgradeV1Buttons } from './upgradeV1Buttons' + +const transformIcons = (sourceFile: TransformSourceArgs['sourceFile']): string => { + const kaioTagNamesMap = getKaioTagNamesMapByComponentName(sourceFile, ['IconButton', 'Button']) + return transformSource({ + sourceFile, + transformers: [upgradeV1Buttons(kaioTagNamesMap!)], + }) +} + +describe('upgradeV1Buttons()', () => { + describe('to Button', () => { + it('transforms Button v1 to Button v3 when href and component prop are not set', () => { + const inputAst = parseJsx(` + import { Button } from "@kaizen/components" + export const TestComponent = () => + `) + + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms IconButton to Button v3 when href and component prop are not set', () => { + const inputAst = parseJsx(` + import { IconButton } from "@kaizen/components" + export const TestComponent = () => } label="More pls" onClick={handleClick} /> + `) + const outputAst = parseJsx(` + import { Button } from "@kaizen/components/next" + export const TestComponent = () => + `) + + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms both IconButton and Button v1 to Button v3 in the same iteration', () => { + const inputAst = parseJsx(` + import { Button, IconButton } from "@kaizen/components" + export const TestComponent = () => ( + <> + + + + ) + `) + + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms aliased V1 Buttons to Button when href and component prop are not set', () => { + const inputAst = parseJsx(` + import { IconButton as KzIconButton, Button as KzButton } from "@kaizen/components" + export const TestComponent = () => ( + <> + + + + ) + `) + const outputAst = parseJsx(` + import { Button } from "@kaizen/components/next" + export const TestComponent = () => ( + <> + + + + ) + `) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms V1 Buttons to aliased Button', () => { + const inputAst = parseJsx(` + import { IconButton, Button } from "@kaizen/components" + import { Button as ButtonAlias } from "@kaizen/components/next" + export const TestComponent = () => ( + <> + + + + ) + `) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('updates V1 Buttons from @kaizen/components/v1/actions', () => { + const inputAst = parseJsx(` + import { Button, IconButton } from "@kaizen/components/v1/actions" + export const TestComponent = () => ( + <> + + + + ) + `) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('updates V1 Buttons from @kaizen/components/v2/actions', () => { + const inputAst = parseJsx(` + import { Button, IconButton } from "@kaizen/components/v2/actions" + export const TestComponent = () => ( + <> + + + + ) + `) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('updates aliased V1 Buttons to Button', () => { + const inputAst = parseJsx(` + import { Button as KzButton, IconButton as KzIconButton } from "@kaizen/components" + export const TestComponent = () => ( + <> + + + + ) + `) + const outputAst = parseJsx(` + import { Button } from "@kaizen/components/next" + export const TestComponent = () => ( + <> + + + + ) + `) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('updates imports of multiple V1 Buttons from different KAIO imports', () => { + const inputAst = parseJsx(` + import { Button as KzButton, IconButton as KzIconButton } from "@kaizen/components" + import { Button as ButtonV1, IconButton as IconButtonV1 } from "@kaizen/components/v1/actions" + export const TestComponent = () => ( + <> + + + + + + ) + `) + const outputAst = parseJsx(` + import { Button } from "@kaizen/components/next" + export const TestComponent = () => ( + <> + + + + + + ) + `) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('does not duplicate Button import if it already exists', () => { + const inputAst = parseJsx(` + import { IconButton, Button as KzButton } from "@kaizen/components" + import { Button } from "@kaizen/components/next" + export const TestComponent = () => ( + <> + + + + + ) + `) + const outputAst = parseJsx(` + import { Button } from "@kaizen/components/next" + export const TestComponent = () => ( + <> + + + + + ) + `) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + + it('does not add Button if aliased Button exists', () => { + const inputAst = parseJsx(` + import { Button, IconButton } from "@kaizen/components" + import { Button as ButtonAlias } from "@kaizen/components/next" + export const TestComponent = () => ( + <> + + + + ) + `) + expect(transformIcons(inputAst)).toEqual(printAst(outputAst)) + }) + }) + }) + + describe('to LinkButton', () => { + it('transforms V1 Buttons to LinkButton when href prop is set', () => { + const inputAst = parseJsx(` + import { Button, IconButton } from "@kaizen/components" + export const TestComponent = () => ( + <> + ') + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms a string in brackets', () => { + const inputAst = parseJsx(` + <> + + + + `) + // const outputAst = parseJsx('') + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms a string with comments in brackets', () => { + const inputAst = parseJsx(``) + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms a template literal with variables', () => { + /* eslint-disable no-template-curly-in-string */ + const inputAst = parseJsx('') + /* eslint-enable no-template-curly-in-string */ + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms a variable', () => { + const inputAst = parseJsx('') + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms a ternary', () => { + const inputAst = parseJsx( + '', + ) + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms a JSX element', () => { + const inputAst = parseJsx('') + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms a JSX self-closing element', () => { + const inputAst = parseJsx('') + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) + + it('transforms a JSX fragment', () => { + const inputAst = parseJsx('') + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) + + it('adds a comment if no value for children has been passed in', () => { + const inputAst = parseJsx('`) + expect(testCreateJsxElementWithChildren(inputAst)).toEqual(printAst(outputAst)) + }) +}) diff --git a/packages/components/codemods/utils/createJsxElementWithChildren.ts b/packages/components/codemods/utils/createJsxElementWithChildren.ts new file mode 100644 index 00000000000..95579ba3cde --- /dev/null +++ b/packages/components/codemods/utils/createJsxElementWithChildren.ts @@ -0,0 +1,55 @@ +import ts from 'typescript' + +const createJsxChildren = (childrenValue: ts.JsxAttributeValue): ts.JsxChild => { + if (ts.isStringLiteral(childrenValue)) { + return ts.factory.createJsxText(childrenValue.text) + } + + if (ts.isJsxExpression(childrenValue)) { + const value = childrenValue.expression + + if (value) { + if (ts.isStringLiteral(value)) { + // Tests for {"string"}, {'string'} + const regexExpContainsOnlyQuotedString = new RegExp(/^\{(["']).*(\1)\}$/g) + + if (regexExpContainsOnlyQuotedString.test(childrenValue.getFullText())) { + return ts.factory.createJsxText(value.text) + } + } + + if (ts.isJsxElement(value) || ts.isJsxSelfClosingElement(value) || ts.isJsxFragment(value)) { + return value + } + } + + return childrenValue + } + + return childrenValue +} + +/** + * Use this to replace a self-closing JSX element to a JSX element with children + */ +export const createJsxElementWithChildren = ( + tagName: string, + attributes: ts.JsxAttributeLike[], + childrenValue: ts.JsxAttributeValue | undefined, +): ts.JsxElement => { + const tagNameId = ts.factory.createIdentifier(tagName) + const fallbackChildren = [ + ts.factory.createJsxText('\n'), + ts.factory.createJsxText( + '/* @todo Children required but a value was not found during the codemod */', + ), + ts.factory.createJsxText('\n'), + ] + const children = childrenValue ? [createJsxChildren(childrenValue)] : fallbackChildren + + return ts.factory.createJsxElement( + ts.factory.createJsxOpeningElement(tagNameId, [], ts.factory.createJsxAttributes(attributes)), + children, + ts.factory.createJsxClosingElement(tagNameId), + ) +} diff --git a/packages/components/codemods/utils/createProp.spec.ts b/packages/components/codemods/utils/createProp.spec.ts index 3a5c6ba8cd4..2cecdfd9290 100644 --- a/packages/components/codemods/utils/createProp.spec.ts +++ b/packages/components/codemods/utils/createProp.spec.ts @@ -1,32 +1,88 @@ import ts from 'typescript' import { parseJsx } from '../__tests__/utils/parseJsx' -import { createStyleProp } from './createProp' +import { createProp, createStyleProp } from './createProp' import { printAst } from './printAst' import { transformSource, type TransformSourceArgs } from './transformSource' import { updateJsxElementWithNewProps } from './updateJsxElementWithNewProps' -export const mockedTransformer: ts.TransformerFactory = (context) => (rootNode) => { +export const mockTransformer: ts.TransformerFactory = (context) => (rootNode) => { const visit = (node: ts.Node): ts.Node => { if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { - if (node.tagName.getText() === 'Pancakes') { - const newAttributes = node.attributes.properties.map((attr) => { - if (ts.isJsxAttribute(attr)) { - if (attr.name.getText() === 'replaceWithExistingValue') { - return createStyleProp({ width: attr.initializer! }) - } + const newAttributes = node.attributes.properties.map((attr) => { + if (ts.isJsxAttribute(attr)) { + return createProp(`${attr.name.getText()}New`, attr.initializer) + } + return attr + }) + return updateJsxElementWithNewProps(node, newAttributes) + } + return ts.visitEachChild(node, visit, context) + } + return ts.visitNode(rootNode, visit) as ts.SourceFile +} + +const testCreateProp = (sourceFile: TransformSourceArgs['sourceFile']): string => + transformSource({ + sourceFile, + transformers: [mockTransformer], + }) + +describe('createProp()', () => { + it('creates a prop with the pre-existing value', () => { + const inputAst = parseJsx('') + const outputAst = parseJsx('') + expect(testCreateProp(inputAst)).toEqual(printAst(outputAst)) + }) - if (attr.name.getText() === 'replaceWithStringValue') { - return createStyleProp({ width: '100px' }) - } + it('creates a prop and transforms true to undefined', () => { + const inputAst = parseJsx(` + export const TestComponent = () => ( + <> + + + + + + + + ) + `) + const outputAst = parseJsx(` + export const TestComponent = () => ( + <> + + + + + + + + ) + `) + expect(testCreateProp(inputAst)).toEqual(printAst(outputAst)) + }) +}) + +export const styleTransformer: ts.TransformerFactory = (context) => (rootNode) => { + const visit = (node: ts.Node): ts.Node => { + if (ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) { + const newAttributes = node.attributes.properties.map((attr) => { + if (ts.isJsxAttribute(attr)) { + if (attr.name.getText() === 'replaceWithExistingValue') { + return createStyleProp({ width: attr.initializer! }) + } + + if (attr.name.getText() === 'replaceWithStringValue') { + return createStyleProp({ width: '100px' }) + } - if (attr.name.getText() === 'replaceWithNumberValue') { - return createStyleProp({ width: 100 }) - } + if (attr.name.getText() === 'replaceWithNumberValue') { + return createStyleProp({ width: 100 }) } - return attr - }) - return updateJsxElementWithNewProps(node, newAttributes) - } + } + return attr + }) + return updateJsxElementWithNewProps(node, newAttributes) } return ts.visitEachChild(node, visit, context) } @@ -36,7 +92,7 @@ export const mockedTransformer: ts.TransformerFactory = (context) const testCreateStyleProp = (sourceFile: TransformSourceArgs['sourceFile']): string => transformSource({ sourceFile, - transformers: [mockedTransformer], + transformers: [styleTransformer], }) describe('createStyleProp()', () => { diff --git a/packages/components/codemods/utils/createProp.ts b/packages/components/codemods/utils/createProp.ts index a11fcbe1522..413e1104dd3 100644 --- a/packages/components/codemods/utils/createProp.ts +++ b/packages/components/codemods/utils/createProp.ts @@ -3,7 +3,14 @@ import ts from 'typescript' export const createProp = ( name: string, value?: ts.JsxAttributeValue | undefined, -): ts.JsxAttribute => ts.factory.createJsxAttribute(ts.factory.createIdentifier(name), value) +): ts.JsxAttribute => { + // Transforms `propName={true}` to `propName` + if (value && ts.isJsxExpression(value) && value.expression?.kind === ts.SyntaxKind.TrueKeyword) { + return ts.factory.createJsxAttribute(ts.factory.createIdentifier(name), undefined) + } + + return ts.factory.createJsxAttribute(ts.factory.createIdentifier(name), value) +} export const createStringProp = (name: string, value: string): ts.JsxAttribute => createProp(name, ts.factory.createStringLiteral(value)) diff --git a/packages/components/codemods/utils/getKaioTagName.ts b/packages/components/codemods/utils/getKaioTagName.ts index 835b5dd9efa..14725abbff4 100644 --- a/packages/components/codemods/utils/getKaioTagName.ts +++ b/packages/components/codemods/utils/getKaioTagName.ts @@ -5,10 +5,17 @@ type ImportModuleNamedImports = { namedImports: ts.NodeArray } -const getKaioNamedImports = (visitedNode: ts.Node): ImportModuleNamedImports | undefined => { +const getKaioNamedImports = ( + visitedNode: ts.Node, + importSource?: string, +): ImportModuleNamedImports | undefined => { if (ts.isImportDeclaration(visitedNode)) { const moduleSpecifier = (visitedNode.moduleSpecifier as ts.StringLiteral).text - if (moduleSpecifier.includes('@kaizen/components')) { + const hasMatch = importSource + ? moduleSpecifier === importSource + : moduleSpecifier.includes('@kaizen/components') + + if (hasMatch) { const namedBindings = visitedNode.importClause?.namedBindings if (namedBindings && ts.isNamedImports(namedBindings)) { return { @@ -42,12 +49,13 @@ const getNamesFromSpecifier = (importSpecifier: ts.ImportSpecifier): ImportSpeci */ export const getKaioTagName = ( node: ts.Node, - importSpecifierTarget: string, + componentName: string, + importSource?: string, ): string | undefined => { let alias: string | undefined const visitNode = (visitedNode: ts.Node): string | undefined => { - const kaioNamedImports = getKaioNamedImports(visitedNode) + const kaioNamedImports = getKaioNamedImports(visitedNode, importSource) if (!kaioNamedImports) { return ts.forEachChild(visitedNode, visitNode) @@ -56,7 +64,7 @@ export const getKaioTagName = ( kaioNamedImports.namedImports.find((importSpecifier) => { const { tagName, originalName } = getNamesFromSpecifier(importSpecifier) - if (originalName === importSpecifierTarget) { + if (originalName === componentName) { alias = tagName return true } diff --git a/packages/components/codemods/utils/index.ts b/packages/components/codemods/utils/index.ts index 0e3d698e010..ebb02188357 100644 --- a/packages/components/codemods/utils/index.ts +++ b/packages/components/codemods/utils/index.ts @@ -1,3 +1,4 @@ +export * from './createJsxElementWithChildren' export * from './createProp' export * from './getPropValueText' export * from './getKaioTagName' diff --git a/packages/components/src/__rc__/Button/_docs/Button--api-specification.mdx b/packages/components/src/__rc__/Button/_docs/Button--api-specification.mdx index c337ad0c816..0736cc5aafc 100644 --- a/packages/components/src/__rc__/Button/_docs/Button--api-specification.mdx +++ b/packages/components/src/__rc__/Button/_docs/Button--api-specification.mdx @@ -1,21 +1,24 @@ import { Canvas, Meta, Controls, ArgTypes, DocsStory } from '@storybook/blocks' -import { ResourceLinks, KAIOInstallation, LinkTo } from '~storybook/components' +import { ResourceLinks, KAIOInstallation, LinkTo, Installation } from '~storybook/components' import * as exampleStories from './Button.docs.stories' import * as specStories from './Button.spec.stories' - + -# Button API Specification (v3) +# Button API Specification (next) Updated Nov 19, 2024 - + ## Overview @@ -69,7 +72,7 @@ Reversed variants are handled via the `ReversedColors` Provider. To enable the reversed theme, you will need to wrap the component or application in the `ReversedColors` provider, ie: ```tsx -import { Button } from '@kaizen/components/v3/actions' +import { Button } from '@kaizen/components/next' import { ReversedColors } from '@kaizen/components/v3/utilities' // application code diff --git a/packages/components/src/__rc__/Button/_docs/Button--migration-guide.mdx b/packages/components/src/__rc__/Button/_docs/Button--migration-guide.mdx new file mode 100644 index 00000000000..253fe495fe4 --- /dev/null +++ b/packages/components/src/__rc__/Button/_docs/Button--migration-guide.mdx @@ -0,0 +1,81 @@ +import { Canvas, Meta, Controls, Story } from '@storybook/blocks' +import { ResourceLinks, KAIOInstallation } from '~storybook/components' + + + +# Button Migration Guide (next) + +This is a short guide to assist in migration from the old to new `Button` and `LinkButton` component. + +## Key API differences + +Below is a list of notable changes when migrating to the new `Button` and `LinkButton` component: + +- `label` is now handled as `Children` . +- Variants, such as `primary` and `secondary`, are now controlled as by the single `variant` prop and some values will be remapped in the codemod. + - `primary` `boolean` becomes `variant="primary"`. + - `secondary` `boolean` becomes `variant="tertiary"`. + - `destructive` `boolean` is removed and will default to the `primary` variant. +- `size` props have been adjusted to include `small`, `medium` and `large` and some values will be remapped in the codemod. + - `small` becomes `medium` + - `regular` becomes `large` +- `onClick` and other event handlers now reflect the RAC event ecosystems, ie: `onPress`. + - You can see more about the RAC event ecosystem [here](https://react-spectrum.adobe.com/react-aria/Button.html#events). +- `Button` and `LinkButton` now exist as separate components. + - The intent is to more closely align to the semantic roles of an anchor or button and should be used accordingly. +- `LinkButton` handles native navigation via `href` out of the box but additional config can be used to allow for client side routing - [see here](/docs/components-linkbutton-api-specification--docs#client-side-routing). + - `onPress` can still be used in place of `onClick` for pushing to routers and tracking events. +- `IconButton` has been superseded by icon props and the [icon-only pattern](/docs/components-button-button-next-api-specification--docs#icon-only-button-and-hashiddenlabel). +- `working`, `workingLabel` and `workingLabelHidden` has been updated to `isPending`, `pendingLabel` and `hasHiddenPendingLabel` to better reflect the state of the button. + - This is not available in the `LinkButton` component +- `badge` prop has been removed and should be handled within the `Children` where required. +- Reversed styles should be handled by the `ReversedColors` Provider but for ease of migration, the `isReversed` prop `boolean` exists. + +## Codemod + +To assist in migration we have created the `upgradeV1Buttons` codemod. + +This will loop through the given directory and update all instances of Button to the latest implementation. You can refer to this [README](https://github.com/cultureamp/kaizen-design-system/blob/main/packages/components/codemods/README.md#kaizen-codemods) on how to run kaizen codemods using the CLI within your repository, ie: + +```bash +pnpm kaizen-codemod src upgradeV1Buttons +``` + +Note that there are cases where an props that no longer exist will be left in the codebase to throw type error and provide feedback to the engineer. This is intentional and identifies area's where manual updates are required. + +### Pre-requisites + +It is also recommended that the `upgradeIconV1` codemod is run before `upgradeV1Buttons` to ensure that all icons are updated to the new `Icon` component that uses the Material icons CDN. There is likely to be a number of visual diffs in this migration so it is recommended to do this separately if viable. Seem more about this [here](/docs/components-icon-icon-future-api-specification--docs#set-up). + +### Codemod gotchas + +If you're facing any issues not captured below, reach out to the [#help_design_system](https://cultureamp.slack.com/archives/C0189KBPM4Y) channel on Slack. + +#### `icon` props and sizing + +While the `icon` prop supports any `JSX` element, only the latest [Icon component](/docs/components-icon-icon-future-api-specification--docs) will be able to handle relative sizing and spacing automatically within the Button. We recommend running the `upgradeIconV1` codemod before this to convert all icons to the latest implementation. See the guidance here on using the [Material Icons CDN](/docs/guides-app-starter--docs#5-link-to-google-material-symbols-cdn). + +#### `component` props type errors + +Based off Metabase queries, `component` render props is used in consuming repositories to wrap Button content with a routing solution, such as NextJS's `Link` component. To ensure a safe migration, the codemod will update any usages to a `LinkButton` with the `component` prop still passed in. This will cause an intentional type error to provide feedback and make it easier to find in the codebase for a manual update. This should be able to be converted to use client side routing by following the [LinkButton API docs](https://cultureamp.design/?path=/docs/components-linkbutton-api-specification--docs). + +#### `badge` props type errors + +The codemod will continue to passed `badge` props into the new implementation so it will throw a type error and provide feedback to engineers. This will need to be manually composed within the `Children` if required, ie: + +``` +import { Badge } from "@kaizen/components" +import { Button } from "@kaizen/components/next" + + +``` + +#### Link related props type errors + +If no `href` or `component` props are passed to the component you may get type errors for having anchor related props passed into a Button, ie: `target`, `rel`. + +This will cause a type error that can be corrected by either using the `LinkButton` (if intended) or removing all anchor related props as they should exist on semantic buttons. + +## More information + +For more information on the about each of the new API's, we recommend referring the [Button](/docs/components-button-button-next-api-specification--docs) or [LinkButton](/docs/components-linkbutton-api-specification--docs) API specifications. diff --git a/packages/components/src/__rc__/Button/_docs/Button--usage-guidelines.mdx b/packages/components/src/__rc__/Button/_docs/Button--usage-guidelines.mdx index 0aaae2bcf2b..95222972725 100644 --- a/packages/components/src/__rc__/Button/_docs/Button--usage-guidelines.mdx +++ b/packages/components/src/__rc__/Button/_docs/Button--usage-guidelines.mdx @@ -1,24 +1,27 @@ import { Canvas, Meta, Controls } from '@storybook/blocks' -import { ResourceLinks, KAIOInstallation, DosAndDonts, DoOrDont } from '~storybook/components' +import { ResourceLinks, Installation, DosAndDonts, DoOrDont } from '~storybook/components' import * as Button from './Button.docs.stories' import ButtonIconOnlySpec from './assets/button_icon_only_spec.png' import ButtonIconSpec from './assets/button_icon_spec.png' import ButtonSpec from './assets/button_spec.png' import ButtonAnatomy from './assets/button_anatomy.png' - + -# Button (v3) +# Button (next) Updated July 12, 2024 - + ## Overview diff --git a/packages/components/src/__rc__/Button/_docs/Button.docs.stories.tsx b/packages/components/src/__rc__/Button/_docs/Button.docs.stories.tsx index dd0d3b1d876..324063b5e18 100644 --- a/packages/components/src/__rc__/Button/_docs/Button.docs.stories.tsx +++ b/packages/components/src/__rc__/Button/_docs/Button.docs.stories.tsx @@ -17,7 +17,7 @@ import { ReversedColors } from '~components/__utilities__/v3' import { Button } from '../index' const meta = { - title: 'Components/Button/Button (v3)', + title: 'Components/Button/Button (next)', component: Button, args: { children: 'Label', diff --git a/packages/components/src/__rc__/Button/_docs/Button.spec.stories.tsx b/packages/components/src/__rc__/Button/_docs/Button.spec.stories.tsx index 28f4b6497ba..592387cf0e8 100644 --- a/packages/components/src/__rc__/Button/_docs/Button.spec.stories.tsx +++ b/packages/components/src/__rc__/Button/_docs/Button.spec.stories.tsx @@ -8,7 +8,7 @@ import { Button } from '../index' const onPressEvent = fn() const meta = { - title: 'Components/Button/Button (v3)/Button (v3) tests', + title: 'Components/Button/Button (next)/Button (next) tests', component: Button, args: { children: 'Label', diff --git a/packages/components/src/__rc__/Button/_docs/Button.stickersheet.stories.tsx b/packages/components/src/__rc__/Button/_docs/Button.stickersheet.stories.tsx index 26a17082825..8f58cfb0042 100644 --- a/packages/components/src/__rc__/Button/_docs/Button.stickersheet.stories.tsx +++ b/packages/components/src/__rc__/Button/_docs/Button.stickersheet.stories.tsx @@ -6,7 +6,7 @@ import { StickerSheet, type StickerSheetStory } from '~storybook/components/Stic import { Button, type ButtonSizes, type ButtonVariants } from '../index' export default { - title: 'Components/Button/Button (v3)', + title: 'Components/Button/Button (next)', parameters: { chromatic: { disable: false }, controls: { disable: true },