diff --git a/tooling/antd-codemods/README.md b/tooling/antd-codemods/README.md index 02b51a6d34a1..c3c23311f8f3 100644 --- a/tooling/antd-codemods/README.md +++ b/tooling/antd-codemods/README.md @@ -33,6 +33,127 @@ a `CoreTypography`-aliased import alongside the surviving antd one. The `Typography.Title` `level` → `size` convention (`LEVEL_SIZE_MAP` in the transform) was approved 2026-07-30. +## `antd-button-to-core.js` + +In any file that imports `Button` from `'antd'` (single or double quotes; +sibling named imports like `import { Button, Modal } from 'antd'` are +preserved), and/or imports the legacy default `ButtonGroup` from the antd +subpath (`antd/es/button/button-group` or `antd/lib/button/button-group`). +Mapping rules approved 2026-07-30 — see +`.context/wave1-prep/button-gap-check.md` and the mapping guide +(`docs/antd-migration/button.md`) for the full survey data behind them. + +| antd | Core | Notes | +|---|---|---| +| `type="primary"` | `color="primary"` | direct | +| `type="default"` | `color="secondary"` | direct | +| `type="text"` | `color="tertiary"` | direct | +| `type="link"` | `color="link-gray"` | direct | +| no `type` prop at all | `color="secondary"` | antd's implicit default type is `"default"`, which maps to `secondary` — core's own default is `"primary"`, so this must be made explicit rather than omitted | +| `danger` + a base type | folds into `color`: `primary`→`primary-destructive`, `default`→`secondary-destructive`, `text`→`tertiary-destructive`, `link`→`link-destructive` | not a rename — `danger` is dropped, `type` is dropped, `color` carries both | +| bare `danger` (no `type`) | `color="secondary-destructive"` | antd's implicit default type is `"default"` | +| `ghost` (boolean) or `type="ghost"` | `color="tertiary"` | approved 2026-07-30; **always reported via a `converted-with-warnings -> ghost-remap` console.warn** even though the element still converts, so sweep reviewers eyeball every site | +| `size="small"` / `"middle"` / `"large"` | `size="xs"` / `"sm"` / `"md"` | approved 2026-07-30 | +| `size={expr}` (dynamic) | — | skipped, see below | +| `disabled` (bare or `={expr}`) | `isDisabled` | rename, same form | +| `loading` (bare or `={expr}`) | `isLoading` | rename, same form | +| `loading={{ delay }}` (object) | — | skipped, see below | +| `icon={...}` (any value form) | `iconLeading={...}` | rename | +| `htmlType="X"` | native `type="X"` | processed **after** the visual `type`→`color` rewrite on the same element so the two never collide (core's `type` is the native HTML type, the inverse of antd's meaning) | +| `block` | merged into `className` as `tw:w-full` (creates `className` if absent) | needs visual QA per the mapping guide | +| `shape="circle"` / `"round"` | — | skipped, see below | +| `Button.Group` | — | skipped, see below | +| `ButtonGroup` (subpath import) | — | skipped, see below; the subpath import itself is never touched | +| `ref={...}` | unchanged | kept as-is — core `Button` is `forwardRef`-wrapped | +| `href` / `target` / `onClick` / `className` / `data-testid` / `style` / `autoFocus` / `title` | unchanged | native passthrough | + +### Deliberate skips (left as antd, reported via `console.warn`) + +Same convention as the Typography transform: when an element is skipped, +the **whole element** is left completely untouched — still antd — and a +single line is printed per file listing every skip, e.g.: + + [antd-button-to-core] src/components/Foo.tsx: needs hand-finish -> Button(shape-unsupported), Button.Group(button-group) + +Skip categories: + +- **`shape="circle"` / `shape="round"`** — `shape-unsupported`. Core's + icon-only auto-detection gives a rounded square, never a true circle or + pill — no mechanical equivalent for either value. +- **`type={expr}`** (dynamic, non-literal) — `dynamic-type`. +- **`type="..."` with a value outside `primary`/`default`/`text`/`link`/`ghost`** + (e.g. the unused `dashed`) — `unsupported-type`. +- **`ghost={expr}`** where `expr` isn't a literal boolean — `dynamic-ghost`. +- **`danger={expr}`** where `expr` isn't a literal boolean — + `dynamic-danger`. +- **`size={expr}`** (dynamic, non-literal) — `dynamic-size`. 48 combined + occurrences across repos per the gap-check survey — the single largest + hand-finish category. +- **`size="..."` with a value outside `small`/`middle`/`large`** — + `unsupported-size`. +- **`loading={{ delay }}`** (object/debounce form) — `loading-object`. + Unused in practice, but no core equivalent, so hand-finish rather than + guess. +- **`block` combined with a non-literal `className`** (e.g. + `className={someVar}`) — `dynamic-classname`. The transform can only + safely merge `tw:w-full` into a string literal. +- **`Button.Group`** (member expression off the antd `Button` import) — + `button-group`. Core `ButtonGroup` is a single-select toggle group; + antd's legacy usage groups independent action buttons — semantic + mismatch, redesign per site as a flex row of `Button`s. +- **`ButtonGroup` from the `antd/{es,lib}/button/button-group` subpath + default import** — `button-group-subpath`. Reported the same way; the + subpath import declaration itself is never modified, only warned about. + +A **`ghost`/`type="ghost"` element is not a skip** — it converts to +`color="tertiary"` and is separately flagged via a +`converted-with-warnings -> ghost-remap` warning, distinct from the +hand-finish skip line, since the mapping is approved and mechanical but +still worth a reviewer's eyes per site. + +### Partial-conversion files + +If a file has at least one skipped `Button`/`Button.Group` element, the +antd `Button` import **stays**, and the transform adds a second import +aliasing the core component to avoid a name collision: + + import { Button } from 'antd'; + import { Button as CoreButton } from '@openmetadata/ui-core-components'; + +Converted elements in that file use ``; skipped elements keep +using `;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + 'type="primary" becomes color="primary"' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + 'type="default" becomes color="secondary"' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + 'type="text" becomes color="tertiary"' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + 'type="link" becomes color="link-gray"' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + 'no `type` prop: antd default type is "default" -> explicit color="secondary" (core default is "primary")' +); + +// -- `danger` fold-in, per base type, plus bare danger -- + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + 'danger + type="primary" -> color="primary-destructive"' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + 'danger + type="default" -> color="secondary-destructive"' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + 'danger + type="text" -> color="tertiary-destructive"' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + 'danger + type="link" -> color="link-destructive"' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + 'bare `danger` (no type): antd default type is "default" -> color="secondary-destructive"' +); + +// -- `ghost` boolean and `type="ghost"` literal -- + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + '`ghost` boolean modifier -> color="tertiary" (approved 2026-07-30), reported via ghost-remap warn' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + 'type="ghost" literal -> color="tertiary" (same mapping as boolean ghost)' +); + +// -- `size` literal mapping + dynamic-size skip -- + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + 'size="small" -> size="xs"' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + 'size="middle" -> size="sm"' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + 'size="large" -> size="md"' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = ({ sz }) => ;`, + `import { Button } from 'antd';\nconst App = ({ sz }) => ;`, + 'size={dynamicExpr}: left untouched, reported as needs-hand-finish (dynamic-size)' +); + +// -- `disabled` / `loading` renames, both bare and ={expr} forms -- + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + 'bare `disabled` -> bare `isDisabled`' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + '`disabled={expr}` -> `isDisabled={expr}`' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + 'bare `loading` -> bare `isLoading`' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + '`loading={expr}` -> `isLoading={expr}`' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from 'antd';\nconst App = () => ;`, + '`loading={{ delay }}` object form: left untouched, reported (loading-object)' +); + +// -- `htmlType` + `type` on the same element (ordering) -- + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + '`htmlType` + `type` on the same element: never collide, both rewritten correctly' +); + +// -- `icon` rename -- + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + '`icon={...}` -> `iconLeading={...}` (any value form)' +); + +// -- `block` into existing/absent className + dynamic-className skip -- + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + '`block` with no className: creates className="tw:w-full"' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + '`block` with an existing literal className: merges tw:w-full' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from 'antd';\nconst App = () => ;`, + '`block` with a non-literal className: left untouched, reported (dynamic-classname)' +); + +// -- `shape` skips -- + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from 'antd';\nconst App = () => ;`, + 'shape="circle": left untouched, reported (shape-unsupported)' +); + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from 'antd';\nconst App = () => ;`, + 'shape="round": left untouched, reported (shape-unsupported)' +); + +// -- `Button.Group` skip -- + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => Hi;`, + `import { Button } from 'antd';\nconst App = () => Hi;`, + '`Button.Group`: left untouched entirely, reported (button-group)' +); + +// -- `ButtonGroup`-from-subpath skip -- + +defineInlineTest( + transform, + OPTS, + `import ButtonGroup from 'antd/lib/button/button-group';\nconst App = () => Hi;`, + `import ButtonGroup from 'antd/lib/button/button-group';\nconst App = () => Hi;`, + '`ButtonGroup` from the antd subpath import: left untouched, reported (button-group-subpath), import untouched' +); + +// -- `ref` preserved (forwardRef landed, no skip) -- + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + '`ref={...}` is kept as-is (Button is forwardRef-wrapped) and the element still converts' +); + +// -- Partial conversion: CoreButton alias, both imports kept -- + +defineInlineTest( + transform, + OPTS, + `import { Button } from 'antd';\nconst App = () => (\n <>\n \n \n \n);`, + `import { Button } from 'antd';\nimport { Button as CoreButton } from '@openmetadata/ui-core-components';\nconst App = () => (\n <>\n Ok\n \n \n);`, + 'partial conversion: one skip forces the CoreButton alias, antd Button import stays for the skipped element' +); + +// -- Full conversion: import swap + license header preserved -- + +defineInlineTest( + transform, + OPTS, + `/*\n * License header\n */\nimport { Button } from 'antd';\nconst App = () => ;`, + `/*\n * License header\n */\nimport { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + 'preserves a license header comment when the antd import is fully removed' +); + +// -- Double-quote imports -- + +defineInlineTest( + transform, + OPTS, + `import { Button } from "antd";\nconst App = () => ;`, + `import { Button } from '@openmetadata/ui-core-components';\nconst App = () => ;`, + 'double-quoted antd import is matched the same as single-quoted' +); + +// -- Multi-specifier antd import keeps siblings -- + +defineInlineTest( + transform, + OPTS, + `import { Button, Modal } from 'antd';\nconst App = () => (\n <>\n \n \n \n);`, + `import { Modal } from 'antd';\nimport { Button } from '@openmetadata/ui-core-components';\nconst App = () => (\n <>\n \n \n \n);`, + 'multi-specifier antd import keeps sibling specifiers (Modal) on antd' +); + +// -- Merges into an existing core import when fully converted -- + +defineInlineTest( + transform, + OPTS, + `import { Card } from '@openmetadata/ui-core-components';\nimport { Button } from 'antd';\nconst App = () => (\n <>\n \n \n \n);`, + `import { Card, Button } from '@openmetadata/ui-core-components';\nconst App = () => (\n <>\n \n \n \n);`, + 'merges into an existing core import when fully converted' +); + +// -- No-op when the file doesn't import Button from antd at all -- + +defineInlineTest( + transform, + OPTS, + `import { Modal } from 'antd';\nconst App = () => ;`, + `import { Modal } from 'antd';\nconst App = () => ;`, + 'no-op when the file does not import Button from antd (or the subpath) at all' +); + +describe('console.warn reporting', () => { + let warnSpy; + + beforeEach(() => { + warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + it('warns with the file path and skip reason for a dynamic size', () => { + transform( + { + path: 'src/components/Foo.tsx', + source: `import { Button } from 'antd';\nconst App = ({ sz }) => ;`, + }, + { jscodeshift: require('jscodeshift').withParser('tsx') } + ); + + expect(warnSpy).toHaveBeenCalledTimes(1); + const [message] = warnSpy.mock.calls[0]; + expect(message).toContain('src/components/Foo.tsx'); + expect(message).toContain('dynamic-size'); + }); + + it('warns for the loading={{ delay }} object form', () => { + transform( + { + path: 'src/components/Bar.tsx', + source: `import { Button } from 'antd';\nconst App = () => ;`, + }, + { jscodeshift: require('jscodeshift').withParser('tsx') } + ); + + expect(warnSpy).toHaveBeenCalledTimes(1); + const [message] = warnSpy.mock.calls[0]; + expect(message).toContain('loading-object'); + }); + + it('warns for shape-unsupported', () => { + transform( + { + path: 'src/components/Baz.tsx', + source: `import { Button } from 'antd';\nconst App = () => ;`, + }, + { jscodeshift: require('jscodeshift').withParser('tsx') } + ); + + expect(warnSpy).toHaveBeenCalledTimes(1); + const [message] = warnSpy.mock.calls[0]; + expect(message).toContain('shape-unsupported'); + }); + + it('warns for Button.Group', () => { + transform( + { + path: 'src/components/Qux.tsx', + source: `import { Button } from 'antd';\nconst App = () => Hi;`, + }, + { jscodeshift: require('jscodeshift').withParser('tsx') } + ); + + expect(warnSpy).toHaveBeenCalledTimes(1); + const [message] = warnSpy.mock.calls[0]; + expect(message).toContain('button-group'); + }); + + it('warns for the ButtonGroup subpath import without touching it', () => { + transform( + { + path: 'src/components/Quux.tsx', + source: `import ButtonGroup from 'antd/lib/button/button-group';\nconst App = () => Hi;`, + }, + { jscodeshift: require('jscodeshift').withParser('tsx') } + ); + + expect(warnSpy).toHaveBeenCalledTimes(1); + const [message] = warnSpy.mock.calls[0]; + expect(message).toContain('button-group-subpath'); + }); + + it('warns with ghost-remap for a converted (not skipped) ghost element', () => { + transform( + { + path: 'src/components/Ghost.tsx', + source: `import { Button } from 'antd';\nconst App = () => ;`, + }, + { jscodeshift: require('jscodeshift').withParser('tsx') } + ); + + expect(warnSpy).toHaveBeenCalledTimes(1); + const [message] = warnSpy.mock.calls[0]; + expect(message).toContain('converted-with-warnings'); + expect(message).toContain('ghost-remap'); + }); + + it('does not warn when everything converts cleanly', () => { + transform( + { + path: 'src/components/Clean.tsx', + source: `import { Button } from 'antd';\nconst App = () => ;`, + }, + { jscodeshift: require('jscodeshift').withParser('tsx') } + ); + + expect(warnSpy).not.toHaveBeenCalled(); + }); +}); diff --git a/tooling/antd-codemods/transforms/antd-button-to-core.js b/tooling/antd-codemods/transforms/antd-button-to-core.js new file mode 100644 index 000000000000..6cc115dfcccc --- /dev/null +++ b/tooling/antd-codemods/transforms/antd-button-to-core.js @@ -0,0 +1,478 @@ +'use strict'; + +/** + * Converts antd `Button` (and flags `Button.Group` / the + * `antd/{es,lib}/button/button-group` subpath) to the core `Button` from + * `@openmetadata/ui-core-components`. + * + * jscodeshift -t transforms/antd-button-to-core.js --parser=tsx + * + * See README.md for the full mapping table and the list of constructs this + * transform deliberately leaves untouched for hand-finishing. Mirrors the + * structure and conventions of transforms/antd-typography-to-core.js. + */ + +const CORE_MODULE = '@openmetadata/ui-core-components'; +const ANTD_MODULE = 'antd'; +const CORE_LOCAL_ALIAS = 'CoreButton'; + +// Approved 2026-07-30 (see docs/antd-migration/button.md). +const SIZE_MAP = { small: 'xs', middle: 'sm', large: 'md' }; + +// antd `type` (visual variant) -> core `color`, non-destructive. +const BASE_COLOR_MAP = { + primary: 'primary', + default: 'secondary', + text: 'tertiary', + link: 'link-gray', +}; + +// antd `type` + `danger` -> core `color`, destructive variant. +const DESTRUCTIVE_COLOR_MAP = { + primary: 'primary-destructive', + default: 'secondary-destructive', + text: 'tertiary-destructive', + link: 'link-destructive', +}; + +const SUBPATH_IMPORT_RE = /^antd\/(?:es|lib)\/button\/button-group$/; + +// Reads the resolved literal value of a JSX attribute, distinguishing a +// present-but-dynamic value from a present-and-literal one so callers can +// tell "safe to convert" apart from "must skip and hand-finish". +function readAttrLiteral(attr) { + if (!attr) { + return { present: false }; + } + if (attr.value == null) { + // `