diff --git a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/illustratedmessage.test.ts.snap b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/illustratedmessage.test.ts.snap
index d13016d6fc2..c901dba63a6 100644
--- a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/illustratedmessage.test.ts.snap
+++ b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/illustratedmessage.test.ts.snap
@@ -1,14 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
-exports[`Does nothing 1`] = `
+exports[`Rewrites illustration import to S2 with name change 1`] = `
"import { IllustratedMessage, Heading, Content } from "@react-spectrum/s2";
-import NotFound from '@spectrum-icons/illustrations/NotFound';
+import NoSearchResults from "@react-spectrum/s2/illustrations/linear/NoSearchResults";
-
+
No results
Try another search
"
`;
+
+exports[`Rewrites illustration import to S2 with same name 1`] = `
+"import { IllustratedMessage, Heading, Content } from "@react-spectrum/s2";
+import Upload from "@react-spectrum/s2/illustrations/linear/Upload";
+
+
+
+
+ Upload a file
+ Drag and drop to upload
+
+
"
+`;
diff --git a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/imports.test.ts.snap b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/imports.test.ts.snap
index fa6477d4477..850b4f778aa 100644
--- a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/imports.test.ts.snap
+++ b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/imports.test.ts.snap
@@ -1,10 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[`does not leave a comment on dynamic s2 imports 1`] = `"const LazyButton = React.lazy(() => import('@react-spectrum/s2'))"`;
+
exports[`leaves a comment on dynamic imports 1`] = `
"const LazyButton = React.lazy(() => // TODO(S2-upgrade): check this dynamic import
import('@react-spectrum/button'))"
`;
+exports[`should handle empty files safely 1`] = `""`;
+
exports[`should keep import aliases 1`] = `
"import { Button as RSPButton } from "@react-spectrum/s2";
@@ -31,8 +35,6 @@ import { Button } from "@react-spectrum/s2";
exports[`should leave unimplemented components untouched in namespace imports 1`] = `
"import * as RSP from '@adobe/react-spectrum';
-import * as RSP1 from "@react-spectrum/s2";
-
<>
Test
Foo
@@ -58,7 +60,7 @@ import { ListBox, Item } from '@adobe/react-spectrum';
`;
exports[`should not import Section from S2 1`] = `
-"import { MenuSection, MenuItem, Menu } from "@react-spectrum/s2";
+"import { MenuSection, Header, MenuItem, Menu } from "@react-spectrum/s2";
import { Section, Item, ListBox } from '@adobe/react-spectrum';
@@ -101,7 +103,7 @@ import { Button, StatusLight } from "@react-spectrum/s2";
`;
exports[`should remove unused Item/Section import even if name taken in different scope 1`] = `
-"import { MenuSection, MenuItem, Menu } from "@react-spectrum/s2";
+"import { MenuSection, Header, MenuItem, Menu } from "@react-spectrum/s2";
function foo() {
let Item = 'something else';
@@ -119,7 +121,7 @@ function foo() {
`;
exports[`should remove unused Item/Section import if aliased 1`] = `
-"import { MenuSection, MenuItem, Menu } from "@react-spectrum/s2";
+"import { MenuSection, Header, MenuItem, Menu } from "@react-spectrum/s2";
import {Section, Item} from 'elsewhere';
diff --git a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/menu.test.ts.snap b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/menu.test.ts.snap
index ad8c4f9bbbd..bf4bf187f32 100644
--- a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/menu.test.ts.snap
+++ b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/menu.test.ts.snap
@@ -159,7 +159,6 @@ exports[`Renames ContextualHelpTrigger to UnavailableMenuItemTrigger and Dialog
Menu,
MenuTrigger,
Button,
- Dialog,
Heading,
Content,
} from "@react-spectrum/s2";
@@ -184,12 +183,11 @@ exports[`Static - Renames Item to MenuItem, Section to MenuSection 1`] = `
"import {
MenuItem,
MenuSection,
+ Header as Header1,
Menu,
MenuTrigger,
SubmenuTrigger,
Button,
- Header,
- Heading,
} from "@react-spectrum/s2";
@@ -205,7 +203,7 @@ exports[`Static - Renames Item to MenuItem, Section to MenuSection 1`] = `
-
+ Section heading
@@ -217,12 +215,11 @@ exports[`Static - Renames key to id 1`] = `
"import {
MenuItem,
MenuSection,
+ Header as Header1,
Menu,
MenuTrigger,
SubmenuTrigger,
Button,
- Header,
- Heading,
} from "@react-spectrum/s2";
@@ -238,7 +235,7 @@ exports[`Static - Renames key to id 1`] = `
-
+ Section heading
diff --git a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/picker.test.ts.snap b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/picker.test.ts.snap
index d244b730bc7..29264af6bb8 100644
--- a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/picker.test.ts.snap
+++ b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/picker.test.ts.snap
@@ -140,7 +140,7 @@ let props = {validationState: 'invalid'};
`;
exports[`handles sections 1`] = `
-"import { PickerSection, PickerItem, Picker } from "@react-spectrum/s2";
+"import { PickerSection, Header, PickerItem, Picker } from "@react-spectrum/s2";
Item one
diff --git a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/subset.test.ts.snap b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/subset.test.ts.snap
index 4d313138b30..890842abf47 100644
--- a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/subset.test.ts.snap
+++ b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/subset.test.ts.snap
@@ -10,6 +10,28 @@ exports[`Should not update components that are not provided to --components opti
"
`;
+exports[`Should only update ComboBox related shared components provided to --components option 1`] = `
+"import { ComboBoxSection, Header, ComboBoxItem, ComboBox } from "@react-spectrum/s2";
+import { Menu, MenuTrigger, Button, Section, Item } from '@adobe/react-spectrum';
+
+<>
+
+
+ Dog
+ Cat
+
+
+
+
+
+
+>"
+`;
+
exports[`Should only update components provided to --components option 1`] = `
"import { TextArea } from '@adobe/react-spectrum';
@@ -22,6 +44,93 @@ import { Button } from "@react-spectrum/s2";
"
`;
+exports[`Should update ActionGroup related components provided to --components option 1`] = `
+"import { ActionButtonGroup, ActionButton } from "@react-spectrum/s2";
+
+
+ onAction("add")}>Add
+ onAction("delete")}>Delete
+ onAction("edit")}>Edit
+"
+`;
+
+exports[`Should update DialogTrigger related components provided to --components option 1`] = `
+"import { Button, Heading, Content } from '@adobe/react-spectrum';
+
+import { DialogTrigger, Dialog } from "@react-spectrum/s2";
+
+
+
+
+"
+`;
+
+exports[`Should update Menu related components provided to --components option 1`] = `
+"import {
+ UnavailableMenuItemTrigger,
+ ContextualHelpPopover,
+ MenuItem,
+ MenuSection,
+ Header,
+ Menu,
+ MenuTrigger,
+ SubmenuTrigger,
+} from "@react-spectrum/s2";
+
+import { Breadcrumbs, Item, Button, Heading, Content } from '@adobe/react-spectrum';
+
+<>
+
+
+
+
+
+ - Home
+
+>"
+`;
+
+exports[`Should update Tabs related components provided to --components option 1`] = `
+"import { Tab, TabPanel, Tabs, TabList } from "@react-spectrum/s2";
+
+
+ Founding of Rome
+ Monarchy and Republic
+ Arma virumque cano, Troiae qui primus ab oris.Senatus Populusque Romanus."
+`;
+
+exports[`Should update TooltipTrigger related components provided to --components option 1`] = `
+"import { ActionButton } from '@adobe/react-spectrum';
+
+import { TooltipTrigger, Tooltip } from "@react-spectrum/s2";
+
+
+
+ Change Name
+"
+`;
+
exports[`Should update multiple components provided to --components option 1`] = `
"import { Button, TextArea } from "@react-spectrum/s2";
@@ -31,3 +140,24 @@ exports[`Should update multiple components provided to --components option 1`] =
"
`;
+
+exports[`Should update related TableView components provided to --components option 1`] = `
+"import { Cell, Column, Row, TableBody, TableHeader, TableView } from "@react-spectrum/s2";
+
+
+
+ Test
+ Blah
+
+
+
+ | Test1 |
+ One |
+
+
+ | Test2 |
+ One |
+
+
+"
+`;
diff --git a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/tabs.test.ts.snap b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/tabs.test.ts.snap
index d69fa9a6648..0725e2952c3 100644
--- a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/tabs.test.ts.snap
+++ b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/tabs.test.ts.snap
@@ -1,8 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[`Converts dynamic TabList Item to Tab 1`] = `
+"import { Tab, Collection, TabPanel, Tabs, TabList } from "@react-spectrum/s2";
+
+let tabs = [{name: 'Tab 1', children: 'Tab Body 1'}];
+
+
+ {(item) => (
+
+ {item.name}
+
+ )}
+
+ {(item) => (
+
+ {item.children}
+
+ )}
+ "
+`;
+
exports[`Move items from Tabs to TabList 1`] = `
-"import { Collection, TabPanel, Tabs, TabList } from "@react-spectrum/s2";
-import { Item } from '@adobe/react-spectrum';
+"import { Tab, Collection, TabPanel, Tabs, TabList } from "@react-spectrum/s2";
let items = [
{name: 'Tab 1', children: 'Tab Body 1'},
@@ -15,9 +34,9 @@ let items = [
{(item) => (
- -
+
{item.name}
-
+
)}
{(item) => (
@@ -96,6 +115,17 @@ let props = {isQuiet: true};
"
`;
+exports[`Removes TabPanels from v3 import when s2 import exists first 1`] = `
+"import { Tabs as S2Tabs, Tab, TabPanel, Tabs as RSPTabs, TabList } from '@react-spectrum/s2';
+
+<>
+
+
+ A
+ A panel
+>"
+`;
+
exports[`Update to use new API 1`] = `
"import { Tab, TabPanel, Tabs, TabList } from "@react-spectrum/s2";
diff --git a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/well.test.ts.snap b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/well.test.ts.snap
index 09bf02b3554..74450eaa09a 100644
--- a/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/well.test.ts.snap
+++ b/packages/dev/codemods/src/s1-to-s2/__tests__/__snapshots__/well.test.ts.snap
@@ -1,8 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Updates Well to be div with style macro 1`] = `
-"import { Well } from "@react-spectrum/s2";
-import { style } from "@react-spectrum/s2/style" with { type: "macro" };
+"import { style } from "@react-spectrum/s2/style" with { type: "macro" };
{
+ let fullPath = path.join(currentDir, entry);
+ let stats = statSync(fullPath);
+ if (stats.isDirectory()) {
+ return listFiles(root, fullPath);
+ }
+
+ let relativePath = path.relative(root, fullPath).split(path.sep).join('/');
+ return [normalizeFixtureRelativePath(relativePath)];
+ });
+}
+
+function expectProjectToMatchFixture(projectDir: string, fixtureName: string) {
+ let expectedDir = path.join(FIXTURES_ROOT, fixtureName, 'output');
+ let actualFiles = listFiles(projectDir);
+ let expectedFiles = listFiles(expectedDir);
+
+ expect(actualFiles).toEqual(expectedFiles);
+
+ for (let relativePath of expectedFiles) {
+ let actualContent = normalizeText(readFileSync(path.join(projectDir, relativePath), 'utf8'));
+ let fixtureRelativePath = relativePath.replace(/package\.json$/, 'package.fixture.json');
+ let expectedContent = normalizeText(readFileSync(path.join(expectedDir, fixtureRelativePath), 'utf8'));
+ expect(actualContent).toBe(expectedContent);
+ }
+}
+
+function copyFixtureProject(sourceDir: string, destinationDir: string) {
+ mkdirSync(destinationDir, {recursive: true});
+
+ for (let entry of readdirSync(sourceDir).sort()) {
+ let sourcePath = path.join(sourceDir, entry);
+ let destinationEntry = entry === 'package.fixture.json' ? 'package.json' : entry;
+ let destinationPath = path.join(destinationDir, destinationEntry);
+ let stats = statSync(sourcePath);
+
+ if (stats.isDirectory()) {
+ copyFixtureProject(sourcePath, destinationPath);
+ } else {
+ mkdirSync(path.dirname(destinationPath), {recursive: true});
+ writeFileSync(destinationPath, readFileSync(sourcePath));
+ }
+ }
+}
+
+function createFakeYarn(tempRoot: string) {
+ let fakeBinDir = path.join(tempRoot, 'fake-bin');
+ let packageManagerLog = path.join(tempRoot, 'package-manager.log');
+
+ mkdirSync(fakeBinDir, {recursive: true});
+ writeFileSync(
+ path.join(fakeBinDir, 'yarn'),
+ '#!/bin/sh\nprintf "%s\\n" "$*" >> "$FAKE_PM_LOG"\nexit 0\n'
+ );
+ chmodSync(path.join(fakeBinDir, 'yarn'), 0o755);
+
+ return {fakeBinDir, packageManagerLog};
+}
+
+async function runFixtureCLI(options: {
+ fixtureName: string,
+ args?: string[],
+ interactive?: boolean
+}) {
+ let {
+ fixtureName,
+ args = [],
+ interactive = false
+ } = options;
+
+ let tempRoot = mkdtempSync(path.join(os.tmpdir(), 's1-to-s2-cli-'));
+ tempDirs.push(tempRoot);
+
+ let projectDir = path.join(tempRoot, 'project');
+ copyFixtureProject(path.join(FIXTURES_ROOT, fixtureName, 'input'), projectDir);
+
+ let {fakeBinDir, packageManagerLog} = createFakeYarn(tempRoot);
+ let env = {
+ ...process.env,
+ FORCE_COLOR: '0',
+ NODE_ENV: 'test',
+ FAKE_PM_LOG: packageManagerLog,
+ PATH: `${fakeBinDir}${path.delimiter}${process.env.PATH ?? ''}`
+ };
+
+ let child = spawn(process.execPath, [CLI_PATH, 's1-to-s2', '--path', 'src', ...args], {
+ cwd: projectDir,
+ env,
+ stdio: ['pipe', 'pipe', 'pipe']
+ });
+
+ let stdout = '';
+ let stderr = '';
+ let startPromptHandled = false;
+ let upgradePromptHandled = false;
+
+ let maybeRespondToPrompt = () => {
+ if (!interactive) {
+ return;
+ }
+
+ if (!startPromptHandled && stdout.includes(PROMPT_TO_START)) {
+ startPromptHandled = true;
+ child.stdin.write('\n');
+ }
+
+ if (!upgradePromptHandled && stdout.includes(PROMPT_TO_UPGRADE)) {
+ upgradePromptHandled = true;
+ child.stdin.write('\n');
+ child.stdin.end();
+ }
+ };
+
+ child.stdout.on('data', (chunk) => {
+ stdout += chunk.toString();
+ maybeRespondToPrompt();
+ });
+
+ child.stderr.on('data', (chunk) => {
+ stderr += chunk.toString();
+ });
+
+ if (!interactive) {
+ child.stdin.end();
+ }
+
+ let exitCode = await new Promise((resolve, reject) => {
+ let timeout = setTimeout(() => {
+ child.kill('SIGKILL');
+ reject(new Error(`CLI test timed out for fixture ${fixtureName}.`));
+ }, 15000);
+
+ child.on('error', (error) => {
+ clearTimeout(timeout);
+ reject(error);
+ });
+
+ child.on('close', (code) => {
+ clearTimeout(timeout);
+ resolve(code ?? -1);
+ });
+ });
+
+ let packageManagerCalls = existsSync(packageManagerLog)
+ ? normalizeText(readFileSync(packageManagerLog, 'utf8')).trim().split('\n').filter(Boolean)
+ : [];
+
+ return {
+ exitCode,
+ stdout: normalizeText(stdout),
+ stderr: normalizeText(stderr),
+ projectDir,
+ packageManagerCalls
+ };
+}
+
+beforeAll(() => {
+ rmSync(CODEMODS_DIST_DIR, {recursive: true, force: true});
+
+ let buildResult = spawnSync(process.execPath, [require.resolve('typescript/bin/tsc'), '-p', CODEMODS_TSCONFIG], {
+ cwd: REPO_ROOT,
+ env: {
+ ...process.env,
+ FORCE_COLOR: '0'
+ },
+ encoding: 'utf8'
+ });
+
+ if (buildResult.status !== 0) {
+ throw new Error(
+ 'Failed to build @react-spectrum/codemods for CLI e2e tests.\n' +
+ `${buildResult.stdout}\n${buildResult.stderr}`
+ );
+ }
+});
+
+afterEach(() => {
+ for (let tempDir of tempDirs) {
+ rmSync(tempDir, {recursive: true, force: true});
+ }
+
+ tempDirs = [];
+});
+
+test('runs the shipped assistant in agent mode', async () => {
+ let result = await runFixtureCLI({
+ fixtureName: 'full-project',
+ args: ['--agent']
+ });
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stderr).toBe('');
+ expect(result.stdout).toContain('Running s1-to-s2 in agent mode (non-interactive, transform-only).');
+ expect(result.stdout).toContain('Upgrade complete!');
+ expect(result.stdout).toContain('Next steps:');
+ expect(result.stdout).not.toContain(PROMPT_TO_START);
+ expect(result.stdout).not.toContain(PROMPT_TO_UPGRADE);
+ expect(result.packageManagerCalls).toEqual([]);
+ expectProjectToMatchFixture(result.projectDir, 'full-project');
+});
+
+test('respects --components in agent mode', async () => {
+ let result = await runFixtureCLI({
+ fixtureName: 'subset-project',
+ args: ['--agent', '--components', 'Button']
+ });
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stderr).toBe('');
+ expect(result.stdout).toContain('Running s1-to-s2 in agent mode (non-interactive, transform-only).');
+ expect(result.stdout).toContain('Upgrade complete!');
+ expect(result.stdout).not.toContain(PROMPT_TO_START);
+ expect(result.stdout).not.toContain(PROMPT_TO_UPGRADE);
+ expect(result.packageManagerCalls).toEqual([]);
+ expectProjectToMatchFixture(result.projectDir, 'subset-project');
+});
+
+test('runs the interactive assistant flow against a real project fixture', async () => {
+ let result = await runFixtureCLI({
+ fixtureName: 'full-project',
+ interactive: true
+ });
+
+ expect(result.exitCode).toBe(0);
+ expect(result.stderr).toBe('');
+ expect(result.stdout).toContain('Welcome to the React Spectrum v3 to Spectrum 2 upgrade assistant!');
+ expect(result.stdout).toContain(PROMPT_TO_START);
+ expect(result.stdout).toContain('Installing @react-spectrum/s2 using yarn...');
+ expect(result.stdout).toContain('Successfully installed @react-spectrum/s2!');
+ expect(result.stdout).toContain('Parcel detected in package.json. Macros are supported by default in v2.12.0 and newer.');
+ expect(result.stdout).toContain(PROMPT_TO_UPGRADE);
+ expect(result.stdout).toContain('Upgrade complete!');
+ expect(result.stdout).toContain('Next steps:');
+ expect(result.stdout).not.toContain('Running s1-to-s2 in agent mode');
+ expect(result.packageManagerCalls).toEqual(['add @react-spectrum/s2@latest']);
+ expectProjectToMatchFixture(result.projectDir, 'full-project');
+});
diff --git a/packages/dev/codemods/src/s1-to-s2/__tests__/dropzone.test.ts b/packages/dev/codemods/src/s1-to-s2/__tests__/dropzone.test.ts
index 64d1739f139..27bccfa1c57 100644
--- a/packages/dev/codemods/src/s1-to-s2/__tests__/dropzone.test.ts
+++ b/packages/dev/codemods/src/s1-to-s2/__tests__/dropzone.test.ts
@@ -6,7 +6,7 @@ const test = (name: string, input: string) => {
defineSnapshotTest(transform, {}, input, name);
};
-test('Does nothing', `
+test('Rewrites illustration import to S2', `
import {DropZone, IllustratedMessage, Heading} from '@adobe/react-spectrum';
import Upload from '@spectrum-icons/illustrations/Upload';
diff --git a/packages/dev/codemods/src/s1-to-s2/__tests__/illustratedmessage.test.ts b/packages/dev/codemods/src/s1-to-s2/__tests__/illustratedmessage.test.ts
index e9fdda91f01..dd5f3395cf0 100644
--- a/packages/dev/codemods/src/s1-to-s2/__tests__/illustratedmessage.test.ts
+++ b/packages/dev/codemods/src/s1-to-s2/__tests__/illustratedmessage.test.ts
@@ -6,7 +6,7 @@ const test = (name: string, input: string) => {
defineSnapshotTest(transform, {}, input, name);
};
-test('Does nothing', `
+test('Rewrites illustration import to S2 with name change', `
import {IllustratedMessage, Heading, Content} from '@adobe/react-spectrum';
import NotFound from '@spectrum-icons/illustrations/NotFound';
@@ -18,3 +18,16 @@ import NotFound from '@spectrum-icons/illustrations/NotFound';
`);
+
+test('Rewrites illustration import to S2 with same name', `
+import {IllustratedMessage, Heading, Content} from '@adobe/react-spectrum';
+import Upload from '@spectrum-icons/illustrations/Upload';
+
+
+
+
+ Upload a file
+ Drag and drop to upload
+
+
+`);
diff --git a/packages/dev/codemods/src/s1-to-s2/__tests__/imports.test.ts b/packages/dev/codemods/src/s1-to-s2/__tests__/imports.test.ts
index 3e0a6406e34..8ba0fe3624c 100644
--- a/packages/dev/codemods/src/s1-to-s2/__tests__/imports.test.ts
+++ b/packages/dev/codemods/src/s1-to-s2/__tests__/imports.test.ts
@@ -18,6 +18,9 @@ import {Button} from '@react-spectrum/button';
`);
+test('should handle empty files safely', `
+`);
+
test('should leave unimplemented components untouched', `
import {Button, Fake} from '@adobe/react-spectrum';
@@ -64,6 +67,10 @@ test('leaves a comment on dynamic imports', `
const LazyButton = React.lazy(() => import('@react-spectrum/button'))
`);
+test('does not leave a comment on dynamic s2 imports', `
+const LazyButton = React.lazy(() => import('@react-spectrum/s2'))
+`);
+
test('should not import Item from S2', `
import {Menu, ListBox, Item} from '@adobe/react-spectrum';
diff --git a/packages/dev/codemods/src/s1-to-s2/__tests__/subset.test.ts b/packages/dev/codemods/src/s1-to-s2/__tests__/subset.test.ts
index f56c737c83e..27246ce1344 100644
--- a/packages/dev/codemods/src/s1-to-s2/__tests__/subset.test.ts
+++ b/packages/dev/codemods/src/s1-to-s2/__tests__/subset.test.ts
@@ -35,3 +35,123 @@ import {Button, TextArea} from '@adobe/react-spectrum';
`, 'TableView');
+
+testSubset('Should update related TableView components provided to --components option', `
+import {Cell, Column, Row, TableBody, TableHeader, TableView} from '@adobe/react-spectrum';
+
+
+
+ Test
+ Blah
+
+
+
+ | Test1 |
+ One |
+
+
+ | Test2 |
+ One |
+
+
+
+`, 'TableView');
+
+testSubset('Should only update ComboBox related shared components provided to --components option', `
+import {ComboBox, Menu, MenuTrigger, Button, Section, Item} from '@adobe/react-spectrum';
+
+<>
+
+
+
+
+
+
+
+
+
+>
+`, 'ComboBox');
+
+testSubset('Should update Menu related components provided to --components option', `
+import {Breadcrumbs, Menu, MenuTrigger, SubmenuTrigger, ContextualHelpTrigger, Item, Section, Button, Dialog, Heading, Content} from '@adobe/react-spectrum';
+
+<>
+
+
+
+ - Undo
+
+ - Share
+
+ - SMS
+
+
+
+
+ - Cut
+
+
+
+
+
+
+ - Home
+
+>
+`, 'Menu');
+
+testSubset('Should update Tabs related components provided to --components option', `
+import {Tabs, TabList, TabPanels, Item} from '@adobe/react-spectrum';
+
+
+
+ - Founding of Rome
+ - Monarchy and Republic
+
+
+ - Arma virumque cano, Troiae qui primus ab oris.
+ - Senatus Populusque Romanus.
+
+
+`, 'Tabs');
+
+testSubset('Should update DialogTrigger related components provided to --components option', `
+import {DialogTrigger, Button, Dialog, Heading, Content, Divider} from '@adobe/react-spectrum';
+
+
+
+
+
+`, 'DialogTrigger');
+
+testSubset('Should update TooltipTrigger related components provided to --components option', `
+import {ActionButton, TooltipTrigger, Tooltip} from '@adobe/react-spectrum';
+
+
+
+ Change Name
+
+`, 'TooltipTrigger');
+
+testSubset('Should update ActionGroup related components provided to --components option', `
+import {ActionGroup, Item} from '@adobe/react-spectrum';
+
+
+ - Add
+ - Delete
+ - Edit
+
+`, 'ActionGroup');
diff --git a/packages/dev/codemods/src/s1-to-s2/__tests__/tabs.test.ts b/packages/dev/codemods/src/s1-to-s2/__tests__/tabs.test.ts
index 409a0503106..c6b8af6b70f 100644
--- a/packages/dev/codemods/src/s1-to-s2/__tests__/tabs.test.ts
+++ b/packages/dev/codemods/src/s1-to-s2/__tests__/tabs.test.ts
@@ -254,3 +254,43 @@ let items = [
`);
+
+test('Converts dynamic TabList Item to Tab', `
+import {Tabs, TabList, TabPanels, Item} from '@adobe/react-spectrum';
+
+let tabs = [{name: 'Tab 1', children: 'Tab Body 1'}];
+
+
+
+ {(item) => (
+ -
+ {item.name}
+
+ )}
+
+
+ {(item) => (
+ -
+ {item.children}
+
+ )}
+
+
+`);
+
+test('Removes TabPanels from v3 import when s2 import exists first', `
+import {Tabs as S2Tabs} from '@react-spectrum/s2';
+import {Tabs as RSPTabs, TabList, TabPanels, Item} from '@adobe/react-spectrum';
+
+<>
+
+
+
+ - A
+
+
+ - A panel
+
+
+>
+`);
diff --git a/packages/dev/codemods/src/s1-to-s2/src/codemods/codemod.ts b/packages/dev/codemods/src/s1-to-s2/src/codemods/codemod.ts
index ce3467f356a..6f87c34027b 100644
--- a/packages/dev/codemods/src/s1-to-s2/src/codemods/codemod.ts
+++ b/packages/dev/codemods/src/s1-to-s2/src/codemods/codemod.ts
@@ -1,8 +1,9 @@
/* eslint-disable max-depth */
-import {addComment} from './shared/utils';
+import {addComment, getName, removeUnusedImports} from './shared/utils';
import {API, FileInfo} from 'jscodeshift';
import {getComponents} from '../getComponents';
import {iconMap} from './icons/iconMap';
+import {illustrationMap} from './illustrations/illustrationMap';
import {parse as recastParse} from 'recast';
import * as t from '@babel/types';
import transformStyleProps from './shared/styleProps';
@@ -37,6 +38,138 @@ interface Options {
components?: string
}
+interface RelatedComponentGroup {
+ components?: string[],
+ scopedComponents?: Record
+}
+
+interface ComponentSelection {
+ components: Set,
+ explicitComponents: Set,
+ scopedComponents: Map>
+}
+
+const relatedComponentGroups: Record = {
+ ActionMenu: {
+ scopedComponents: {
+ Item: ['ActionMenu']
+ }
+ },
+ Breadcrumbs: {
+ scopedComponents: {
+ Item: ['Breadcrumbs']
+ }
+ },
+ ComboBox: {
+ scopedComponents: {
+ Item: ['ComboBox'],
+ Section: ['ComboBox']
+ }
+ },
+ DialogContainer: {
+ components: ['Dialog']
+ },
+ DialogTrigger: {
+ components: ['Dialog']
+ },
+ Menu: {
+ components: ['ContextualHelpTrigger', 'MenuTrigger', 'SubmenuTrigger'],
+ scopedComponents: {
+ Item: ['Menu'],
+ Section: ['Menu']
+ }
+ },
+ Picker: {
+ scopedComponents: {
+ Item: ['Picker'],
+ Section: ['Picker']
+ }
+ },
+ TableView: {
+ components: ['Cell', 'Column', 'Row', 'TableBody', 'TableHeader']
+ },
+ Tabs: {
+ components: ['TabList', 'TabPanels']
+ },
+ TagGroup: {
+ scopedComponents: {
+ Item: ['TagGroup']
+ }
+ },
+ TooltipTrigger: {
+ components: ['Tooltip']
+ }
+};
+
+function addScopedParents(scopedComponents: Map>, component: string, parents: string[]) {
+ let existingParents = scopedComponents.get(component) ?? new Set();
+ for (let parent of parents) {
+ existingParents.add(parent);
+ }
+ scopedComponents.set(component, existingParents);
+}
+
+function getComponentSelection(components?: string): ComponentSelection {
+ if (!components) {
+ return {
+ components: new Set(availableComponents),
+ explicitComponents: new Set(availableComponents),
+ scopedComponents: new Map()
+ };
+ }
+
+ let explicitComponents = new Set(
+ components.split(',').map(s => s.trim()).filter(Boolean)
+ );
+ let expandedComponents = new Set(explicitComponents);
+ let scopedComponents = new Map>();
+
+ for (let component of explicitComponents) {
+ let relatedComponents = relatedComponentGroups[component];
+ if (!relatedComponents) {
+ continue;
+ }
+
+ for (let relatedComponent of relatedComponents.components ?? []) {
+ expandedComponents.add(relatedComponent);
+ }
+
+ for (let [relatedComponent, parents] of Object.entries(relatedComponents.scopedComponents ?? {})) {
+ expandedComponents.add(relatedComponent);
+ if (!explicitComponents.has(relatedComponent)) {
+ addScopedParents(scopedComponents, relatedComponent, parents);
+ }
+ }
+ }
+
+ return {
+ components: new Set([...expandedComponents].filter(component => availableComponents.has(component))),
+ explicitComponents: new Set([...explicitComponents].filter(component => availableComponents.has(component))),
+ scopedComponents
+ };
+}
+
+function shouldTransformElement(
+ componentName: string,
+ path: NodePath,
+ selection: ComponentSelection
+): boolean {
+ if (selection.explicitComponents.has(componentName)) {
+ return true;
+ }
+
+ let allowedParents = selection.scopedComponents.get(componentName);
+ if (!allowedParents || allowedParents.size === 0) {
+ return true;
+ }
+
+ return !!path.findParent((parentPath) =>
+ t.isJSXElement(parentPath.node)
+ && t.isJSXIdentifier(parentPath.node.openingElement.name)
+ && allowedParents.has(getName(path, parentPath.node.openingElement.name))
+ );
+}
+
export default function transformer(file: FileInfo, api: API, options: Options):string {
let j = api.jscodeshift.withParser({
parse(source: string) {
@@ -46,7 +179,8 @@ export default function transformer(file: FileInfo, api: API, options: Options):
}
});
let root = j(file.source);
- let componentsToTransform = options.components ? new Set(options.components.split(',').filter(s => availableComponents.has(s))) : availableComponents;
+ let selection = getComponentSelection(options.components);
+ let componentsToTransform = selection.components;
let v3ComponentsToRename = new Set(Object.keys(renamedComponents));
let S2ComponentsToImport = new Set();
@@ -55,8 +189,13 @@ export default function transformer(file: FileInfo, api: API, options: Options):
let elements: [string, NodePath][] = [];
let lastImportPath: NodePath | null = null;
let iconImports: Map, newName: string | null}> = new Map();
+ let illustrationImports: Map, newName: string | null}> = new Map();
+ let programPath: NodePath | null = null;
const leadingComments = root.find(j.Program).get('body', 0).node.leadingComments;
traverse(root.paths()[0].node, {
+ Program(path) {
+ programPath = path;
+ },
ImportDeclaration(path) {
if (path.node.source.value === '@adobe/react-spectrum' || (path.node.source.value.startsWith('@react-spectrum/') && path.node.source.value !== '@react-spectrum/s2')) {
lastImportPath = path;
@@ -72,7 +211,10 @@ export default function transformer(file: FileInfo, api: API, options: Options):
if (propName && path.parentPath!.parentPath?.parentPath?.isJSXElement()) {
if (componentsToTransform.has(propName)) {
importedComponents.set(propName, clonedSpecifier);
- elements.push([propName, path.parentPath!.parentPath.parentPath]);
+ let elementPath = path.parentPath!.parentPath.parentPath as NodePath;
+ if (shouldTransformElement(propName, elementPath, selection)) {
+ elements.push([propName, elementPath]);
+ }
} else if (v3ComponentsToRename.has(propName)) {
S2ComponentsToImport.add(renamedComponents[propName]);
elements.push([propName, path.parentPath!.parentPath.parentPath]);
@@ -112,7 +254,10 @@ export default function transformer(file: FileInfo, api: API, options: Options):
bindings.push(binding);
for (let path of binding.referencePaths) {
if (path.parentPath?.isJSXOpeningElement() && path.parentPath.parentPath.isJSXElement()) {
- elements.push([specifier.imported.name, path.parentPath.parentPath]);
+ let elementPath = path.parentPath.parentPath as NodePath;
+ if (shouldTransformElement(specifier.imported.name, elementPath, selection)) {
+ elements.push([specifier.imported.name, elementPath]);
+ }
}
}
}
@@ -134,6 +279,21 @@ export default function transformer(file: FileInfo, api: API, options: Options):
} else {
iconImports.set(localName, {path, newName: null});
}
+ } else if (path.node.source.value.startsWith('@spectrum-icons/illustrations/')) {
+ let illustrationName = path.node.source.value.split('/').pop();
+ if (!illustrationName) {return;}
+
+ let specifier = path.node.specifiers[0];
+ if (!specifier || !t.isImportDefaultSpecifier(specifier)) {return;}
+
+ let localName = specifier.local.name;
+
+ if (illustrationMap.has(illustrationName)) {
+ let newIllustrationName = illustrationMap.get(illustrationName)!;
+ illustrationImports.set(localName, {path, newName: newIllustrationName});
+ } else {
+ illustrationImports.set(localName, {path, newName: null});
+ }
}
},
Import(path) {
@@ -147,7 +307,9 @@ export default function transformer(file: FileInfo, api: API, options: Options):
return;
}
- if (arg.value !== '@adobe/react-spectrum' && !arg.value.startsWith('@react-spectrum/')) {
+ let isV3ImportSource = arg.value === '@adobe/react-spectrum'
+ || (arg.value.startsWith('@react-spectrum/') && arg.value !== '@react-spectrum/s2');
+ if (!isV3ImportSource) {
return;
}
@@ -162,6 +324,12 @@ export default function transformer(file: FileInfo, api: API, options: Options):
addComment(path.node, ` TODO(S2-upgrade): A Spectrum 2 equivalent to '${name.name}' was not found. Please update this icon manually.`);
}
}
+ if (t.isJSXIdentifier(name) && illustrationImports.has(name.name)) {
+ let illustrationInfo = illustrationImports.get(name.name)!;
+ if (illustrationInfo.newName === null) {
+ addComment(path.node, ` TODO(S2-upgrade): A Spectrum 2 equivalent to '${name.name}' was not found. Please update this illustration manually.`);
+ }
+ }
}
});
@@ -191,6 +359,29 @@ export default function transformer(file: FileInfo, api: API, options: Options):
}
});
+ illustrationImports.forEach((illustrationInfo, localName) => {
+ let {path, newName} = illustrationInfo;
+ if (newName) {
+ let newImportSource = `@react-spectrum/s2/illustrations/linear/${newName}`;
+
+ let newLocalName = localName;
+ if (localName === path.node.source.value.split('/').pop() && localName !== newName) {
+ let binding = path.scope.getBinding(localName);
+ if (binding && !path.scope.hasBinding(newName)) {
+ newLocalName = newName;
+ binding.referencePaths.forEach(refPath => {
+ if (t.isJSXIdentifier(refPath.node)) {
+ refPath.node.name = newName;
+ }
+ });
+ }
+ }
+
+ path.node.source = t.stringLiteral(newImportSource);
+ path.node.specifiers = [t.importDefaultSpecifier(t.identifier(newLocalName))];
+ }
+ });
+
let hasMacros = false;
let usedLightDark = false;
elements.forEach(([elementName, path]) => {
@@ -255,16 +446,31 @@ export default function transformer(file: FileInfo, api: API, options: Options):
if (existingImport.length) {
let importDecl = existingImport.get();
- for (let specifier of importDecl.node.specifiers) {
- if (specifier.type === 'ImportSpecifier'
- && importedComponents.has(specifier.imported.name)) {
- importSpecifiers.add(specifier);
- }
- }
+ let existingSpecifiers = importDecl.value.specifiers;
// add importSpecifiers to existing import
importDecl.value.specifiers = [...importDecl.value.specifiers, ...[...importSpecifiers].filter(specifier => {
- // @ts-ignore
- return specifier.imported.name !== 'Item' && ![...importDecl.value.specifiers].find(s => s.imported.name === specifier.imported.name);
+ if (t.isImportSpecifier(specifier) && t.isIdentifier(specifier.imported)) {
+ if (specifier.imported.name === 'Item') {
+ return false;
+ }
+
+ let importedName = specifier.imported.name;
+ let localName = specifier.local?.name || importedName;
+ return !existingSpecifiers.find((s: t.ImportSpecifier | t.ImportDefaultSpecifier | t.ImportNamespaceSpecifier) =>
+ t.isImportSpecifier(s)
+ && t.isIdentifier(s.imported)
+ && s.imported.name === importedName
+ && (s.local?.name || s.imported.name) === localName
+ );
+ }
+
+ if (t.isImportNamespaceSpecifier(specifier)) {
+ return !existingSpecifiers.find((s: t.ImportSpecifier | t.ImportDefaultSpecifier | t.ImportNamespaceSpecifier) =>
+ t.isImportNamespaceSpecifier(s) && s.local.name === specifier.local.name
+ );
+ }
+
+ return false;
})];
} else {
if (importSpecifiers.size > 0) {
@@ -300,6 +506,10 @@ export default function transformer(file: FileInfo, api: API, options: Options):
});
}
+ if (programPath) {
+ removeUnusedImports(programPath, ['@react-spectrum/s2']);
+ }
+
root.find(j.Program).get('body', 0).node.comments = leadingComments;
return root.toSource().replace(/assert\s*\{\s*type:\s*"macro"\s*\}/g, 'with { type: "macro" }');
}
diff --git a/packages/dev/codemods/src/s1-to-s2/src/codemods/components/ActionGroup/transform.ts b/packages/dev/codemods/src/s1-to-s2/src/codemods/components/ActionGroup/transform.ts
index 035ab57424b..ac34bb351b5 100644
--- a/packages/dev/codemods/src/s1-to-s2/src/codemods/components/ActionGroup/transform.ts
+++ b/packages/dev/codemods/src/s1-to-s2/src/codemods/components/ActionGroup/transform.ts
@@ -1,4 +1,4 @@
-import {addComponentImport} from '../../shared/utils';
+import {addComponentImport, removeComponentImportIfUnused} from '../../shared/utils';
import {commentOutProp} from '../../shared/transforms';
import {getComponents} from '../../../getComponents';
import {NodePath} from '@babel/traverse';
@@ -18,6 +18,8 @@ let availableComponents = getComponents();
* - Convert dynamic collections render function to items.map.
*/
export default function transformActionGroup(path: NodePath): void {
+ let program = path.findParent((p) => t.isProgram(p.node)) as NodePath;
+
// Comment out overflowMode
commentOutProp(path, {propName: 'overflowMode'});
@@ -43,13 +45,11 @@ export default function transformActionGroup(path: NodePath): void
let localName = newComponentName;
if (availableComponents.has(newComponentName)) {
- let program = path.findParent((p) => t.isProgram(p.node)) as NodePath;
localName = addComponentImport(program, newComponentName);
}
let localChildName = childComponentName;
if (availableComponents.has(childComponentName)) {
- let program = path.findParent((p) => t.isProgram(p.node)) as NodePath;
localChildName = addComponentImport(program, childComponentName);
}
@@ -185,4 +185,6 @@ export default function transformActionGroup(path: NodePath): void
if (path.node.closingElement) {
path.node.closingElement.name = t.jsxIdentifier(localName);
}
+
+ removeComponentImportIfUnused(program, 'Item');
}
diff --git a/packages/dev/codemods/src/s1-to-s2/src/codemods/components/ContextualHelpTrigger/transform.ts b/packages/dev/codemods/src/s1-to-s2/src/codemods/components/ContextualHelpTrigger/transform.ts
index ba514b3a203..732736790e2 100644
--- a/packages/dev/codemods/src/s1-to-s2/src/codemods/components/ContextualHelpTrigger/transform.ts
+++ b/packages/dev/codemods/src/s1-to-s2/src/codemods/components/ContextualHelpTrigger/transform.ts
@@ -1,4 +1,4 @@
-import {addComponentImport, getName} from '../../shared/utils';
+import {addComponentImport, getName, removeComponentImportIfUnused} from '../../shared/utils';
import {getComponents} from '../../../getComponents';
import {NodePath} from '@babel/traverse';
import * as t from '@babel/types';
@@ -34,4 +34,6 @@ export default function transformContextualHelpTrigger(path: NodePath
): void {
+ let program = path.findParent((p) => t.isProgram(p.node)) as NodePath;
let typePath = path.get('openingElement').get('attributes').find((attr) => t.isJSXAttribute(attr.node) && attr.node.name.name === 'type') as NodePath | undefined;
let type = typePath?.node.value?.type === 'StringLiteral' ? typePath.node.value?.value : 'modal';
let newComponentName = 'Dialog';
@@ -47,7 +48,6 @@ export function updateDialogChild(
let localName = newComponentName;
if (newComponentName !== 'Dialog' && availableComponents.has(newComponentName)) {
- let program = path.findParent((p) => t.isProgram(p.node)) as NodePath;
localName = addComponentImport(program, newComponentName);
}
@@ -65,6 +65,24 @@ export function updateDialogChild(
dialog.node.openingElement.attributes.push(...props);
}
});
+
+ path.traverse({
+ JSXElement(childPath) {
+ if (
+ t.isJSXIdentifier(childPath.node.openingElement.name)
+ && getName(childPath as NodePath, childPath.node.openingElement.name) === 'Divider'
+ && t.isJSXElement(childPath.parentPath.node)
+ && t.isJSXIdentifier(childPath.parentPath.node.openingElement.name)
+ ) {
+ let parentName = getName(childPath as NodePath, childPath.parentPath.node.openingElement.name);
+ if (parentName === 'Dialog' || parentName === 'Popover' || parentName === 'FullscreenDialog') {
+ childPath.remove();
+ }
+ }
+ }
+ });
+
+ removeComponentImportIfUnused(program, 'Divider');
}
/**
diff --git a/packages/dev/codemods/src/s1-to-s2/src/codemods/components/Tabs/transform.ts b/packages/dev/codemods/src/s1-to-s2/src/codemods/components/Tabs/transform.ts
index d29e52987d5..c00bfbd3a93 100644
--- a/packages/dev/codemods/src/s1-to-s2/src/codemods/components/Tabs/transform.ts
+++ b/packages/dev/codemods/src/s1-to-s2/src/codemods/components/Tabs/transform.ts
@@ -1,16 +1,17 @@
-import {getName, removeComponentImport} from '../../shared/utils';
+import {getName, removeComponentImport, removeComponentImportIfUnused} from '../../shared/utils';
import {NodePath} from '@babel/traverse';
import {removeProp, updateComponentWithinCollection, updateToNewComponentName} from '../../shared/transforms';
import * as t from '@babel/types';
function transformTabList(tabListPath: NodePath): t.JSXElement {
- tabListPath.get('children').forEach(itemPath => {
- if (
- t.isJSXElement(itemPath.node) &&
- t.isJSXIdentifier(itemPath.node.openingElement.name) &&
- getName(itemPath as NodePath, itemPath.node.openingElement.name) === 'Item'
- ) {
- updateComponentWithinCollection(itemPath as NodePath, {parentComponentName: 'TabList', newComponentName: 'Tab'});
+ tabListPath.traverse({
+ JSXElement(itemPath) {
+ if (
+ t.isJSXIdentifier(itemPath.node.openingElement.name) &&
+ getName(itemPath as NodePath, itemPath.node.openingElement.name) === 'Item'
+ ) {
+ updateComponentWithinCollection(itemPath as NodePath, {parentComponentName: 'TabList', newComponentName: 'Tab'});
+ }
}
});
return tabListPath.node;
@@ -96,4 +97,6 @@ export default function transformTabs(path: NodePath): void {
// Remove isQuiet
removeProp(path, {propName: 'isQuiet'});
+
+ removeComponentImportIfUnused(program, 'Item');
}
diff --git a/packages/dev/codemods/src/s1-to-s2/src/codemods/illustrations/illustrationMap.ts b/packages/dev/codemods/src/s1-to-s2/src/codemods/illustrations/illustrationMap.ts
new file mode 100644
index 00000000000..4983c94a419
--- /dev/null
+++ b/packages/dev/codemods/src/s1-to-s2/src/codemods/illustrations/illustrationMap.ts
@@ -0,0 +1,11 @@
+export const illustrationMap: Map = new Map([
+ ['Error', 'Error'],
+ ['File', 'Document'],
+ ['Folder', 'FolderClose'],
+ ['NoSearchResults', 'NoSearchResults'],
+ ['NotFound', 'NoSearchResults'],
+ ['Timeout', 'Clock'],
+ ['Unauthorized', 'LockClose'],
+ ['Unavailable', 'Error'],
+ ['Upload', 'Upload']
+]);
diff --git a/packages/dev/codemods/src/s1-to-s2/src/codemods/shared/transforms.ts b/packages/dev/codemods/src/s1-to-s2/src/codemods/shared/transforms.ts
index ea758136c24..d0cf4f5e5c1 100644
--- a/packages/dev/codemods/src/s1-to-s2/src/codemods/shared/transforms.ts
+++ b/packages/dev/codemods/src/s1-to-s2/src/codemods/shared/transforms.ts
@@ -533,6 +533,7 @@ export function movePropToNewChildComponentName(
getName(path, path.parentPath.node.openingElement.name) === parentComponentName
) {
let propValue: t.JSXAttribute['value'] | void;
+ let localName = newChildComponentName;
path.node.openingElement.attributes =
path.node.openingElement.attributes.filter((attr) => {
if (t.isJSXAttribute(attr) && attr.name.name === propName) {
@@ -543,10 +544,14 @@ export function movePropToNewChildComponentName(
});
if (propValue) {
+ if (availableComponents.has(newChildComponentName)) {
+ let program = path.findParent((p) => t.isProgram(p.node)) as NodePath;
+ localName = addComponentImport(program, newChildComponentName);
+ }
path.node.children.unshift(
t.jsxElement(
- t.jsxOpeningElement(t.jsxIdentifier(newChildComponentName), []),
- t.jsxClosingElement(t.jsxIdentifier(newChildComponentName)),
+ t.jsxOpeningElement(t.jsxIdentifier(localName), []),
+ t.jsxClosingElement(t.jsxIdentifier(localName)),
[t.isStringLiteral(propValue) ? t.jsxText(propValue.value) : propValue]
)
);
diff --git a/packages/dev/codemods/src/s1-to-s2/src/codemods/shared/utils.ts b/packages/dev/codemods/src/s1-to-s2/src/codemods/shared/utils.ts
index e3a4e6df920..7e198d4b530 100644
--- a/packages/dev/codemods/src/s1-to-s2/src/codemods/shared/utils.ts
+++ b/packages/dev/codemods/src/s1-to-s2/src/codemods/shared/utils.ts
@@ -74,6 +74,20 @@ export function addComment(node: any, comment: string): void {
}
export function addComponentImport(path: NodePath, newComponentName: string): string {
+ let existingImport = path.node.body.find((node) => t.isImportDeclaration(node) && node.source.value === '@react-spectrum/s2');
+ if (existingImport && t.isImportDeclaration(existingImport)) {
+ let existingSpecifier = existingImport.specifiers.find((specifier) => {
+ return (
+ t.isImportSpecifier(specifier) &&
+ specifier.imported.type === 'Identifier' &&
+ specifier.imported.name === newComponentName
+ );
+ });
+ if (existingSpecifier && t.isImportSpecifier(existingSpecifier)) {
+ return existingSpecifier.local?.name ?? newComponentName;
+ }
+ }
+
// If newComponentName variable already exists in scope, alias new import to avoid conflict.
let existingBinding = path.scope.getBinding(newComponentName);
let localName = newComponentName;
@@ -87,19 +101,7 @@ export function addComponentImport(path: NodePath, newComponentName:
localName = newName;
}
- let existingImport = path.node.body.find((node) => t.isImportDeclaration(node) && node.source.value === '@react-spectrum/s2');
if (existingImport && t.isImportDeclaration(existingImport)) {
- let specifier = existingImport.specifiers.find((specifier) => {
- return (
- t.isImportSpecifier(specifier) &&
- specifier.imported.type === 'Identifier' &&
- specifier.imported.name === newComponentName
- );
- });
- if (specifier) {
- // Already imported
- return localName;
- }
existingImport.specifiers.push(
t.importSpecifier(t.identifier(localName), t.identifier(newComponentName))
);
@@ -119,20 +121,88 @@ export function addComponentImport(path: NodePath, newComponentName:
}
export function removeComponentImport(path: NodePath, component: string): void {
- let existingImport = path.node.body.find((node) => t.isImportDeclaration(node) && node.source.value === '@adobe/react-spectrum' || t.isImportDeclaration(node) && node.source.value.startsWith('@react-spectrum/'));
- if (existingImport && t.isImportDeclaration(existingImport)) {
- let specifier = existingImport.specifiers.find((specifier) => {
- return (
- t.isImportSpecifier(specifier) &&
- specifier.imported.type === 'Identifier' &&
- specifier.imported.name === component
+ let imports = path.node.body.filter((node): node is t.ImportDeclaration => {
+ return t.isImportDeclaration(node)
+ && (
+ node.source.value === '@adobe/react-spectrum'
+ || (node.source.value.startsWith('@react-spectrum/') && node.source.value !== '@react-spectrum/s2')
);
+ });
+
+ for (let importDecl of imports) {
+ let previousLength = importDecl.specifiers.length;
+ importDecl.specifiers = importDecl.specifiers.filter((specifier) => {
+ return !(
+ t.isImportSpecifier(specifier)
+ && specifier.imported.type === 'Identifier'
+ && specifier.imported.name === component
+ );
+ });
+
+ if (importDecl.specifiers.length === 0 && previousLength > 0) {
+ path.node.body = path.node.body.filter((node) => node !== importDecl);
+ }
+ }
+}
+
+export function removeComponentImportIfUnused(path: NodePath, component: string): void {
+ path.scope.crawl();
+
+ let imports = path.node.body.filter((node): node is t.ImportDeclaration => {
+ return t.isImportDeclaration(node)
+ && (
+ node.source.value === '@adobe/react-spectrum'
+ || node.source.value.startsWith('@react-spectrum/')
+ );
+ });
+
+ for (let importDecl of imports) {
+ let previousLength = importDecl.specifiers.length;
+ importDecl.specifiers = importDecl.specifiers.filter((specifier) => {
+ if (
+ t.isImportSpecifier(specifier)
+ && specifier.imported.type === 'Identifier'
+ && specifier.imported.name === component
+ ) {
+ let localName = specifier.local?.name ?? specifier.imported.name;
+ let binding = path.scope.getBinding(localName);
+ return !!binding?.referencePaths.length;
+ }
+
+ return true;
});
- if (specifier) {
- existingImport.specifiers = existingImport.specifiers.filter((s) => s !== specifier);
- if (existingImport.specifiers.length === 0) {
- path.node.body = path.node.body.filter((node) => node !== existingImport);
+
+ if (importDecl.specifiers.length === 0 && previousLength > 0) {
+ path.node.body = path.node.body.filter((node) => node !== importDecl);
+ }
+ }
+}
+
+export function removeUnusedImports(path: NodePath, sources: string[]): void {
+ path.scope.crawl();
+
+ let sourceSet = new Set(sources);
+ let imports = path.node.body.filter((node): node is t.ImportDeclaration => {
+ return t.isImportDeclaration(node) && sourceSet.has(node.source.value);
+ });
+
+ for (let importDecl of imports) {
+ let previousLength = importDecl.specifiers.length;
+ importDecl.specifiers = importDecl.specifiers.filter((specifier) => {
+ if (
+ t.isImportSpecifier(specifier)
+ || t.isImportDefaultSpecifier(specifier)
+ || t.isImportNamespaceSpecifier(specifier)
+ ) {
+ let binding = path.scope.getBinding(specifier.local.name);
+ return !!binding?.referencePaths.length;
}
+
+ return true;
+ });
+
+ if (importDecl.specifiers.length === 0 && previousLength > 0) {
+ path.node.body = path.node.body.filter((node) => node !== importDecl);
}
}
}
diff --git a/packages/dev/codemods/src/s1-to-s2/src/getComponents.ts b/packages/dev/codemods/src/s1-to-s2/src/getComponents.ts
index 7398553633a..e894c50b022 100644
--- a/packages/dev/codemods/src/s1-to-s2/src/getComponents.ts
+++ b/packages/dev/codemods/src/s1-to-s2/src/getComponents.ts
@@ -1,6 +1,6 @@
+import {existsSync, readFileSync} from 'fs';
import {parse} from '@babel/parser';
const path = require('path');
-import {readFileSync} from 'fs';
import traverse from '@babel/traverse';
// These are exported but there are no codemods written for them yet.
@@ -15,8 +15,19 @@ const skipped = new Set([
export function getComponents(): Set {
// Determine list of available components in S2 from index.ts
let availableComponents = new Set();
- const packagePath = require.resolve('@react-spectrum/s2');
- const indexPath = path.join(path.dirname(packagePath), process.env.NODE_ENV === 'test' ? 'src/index.ts' : '../src/index.ts');
+ let indexPath: string;
+
+ try {
+ const packagePath = require.resolve('@react-spectrum/s2');
+ indexPath = path.join(path.dirname(packagePath), process.env.NODE_ENV === 'test' ? 'src/index.ts' : '../src/index.ts');
+ } catch {
+ const workspaceIndexPath = path.resolve(__dirname, '../../../../../@react-spectrum/s2/src/index.ts');
+ if (!existsSync(workspaceIndexPath)) {
+ throw new Error('Could not resolve @react-spectrum/s2 source for codemods.');
+ }
+ indexPath = workspaceIndexPath;
+ }
+
let index = parse(readFileSync(indexPath, 'utf8'), {sourceType: 'module', plugins: ['typescript']});
traverse(index, {
ExportNamedDeclaration(path) {
diff --git a/packages/dev/codemods/src/s1-to-s2/src/index.ts b/packages/dev/codemods/src/s1-to-s2/src/index.ts
index ee42521583c..5c5b889a625 100644
--- a/packages/dev/codemods/src/s1-to-s2/src/index.ts
+++ b/packages/dev/codemods/src/s1-to-s2/src/index.ts
@@ -7,7 +7,29 @@ import {transform} from './transform.js';
import {waitForKeypress} from './utils/waitForKeypress.js';
const boxen = require('boxen');
+function printNextSteps(nextSteps: string[]) {
+ console.log(boxen(
+ `Next steps:\n\n ${nextSteps.map((step, i) => `${i + 1}. ${step}`).join('\n\n\n')}`,
+ {borderStyle: 'round', padding: 1, borderColor: 'green'}
+ ));
+}
+
export async function s1_to_s2(options: S1ToS2CodemodOptions): Promise {
+ if (options.agent) {
+ logger.info('Running s1-to-s2 in agent mode (non-interactive, transform-only).');
+ logger.info('Upgrading components...');
+ await transform(options);
+ logger.success('Upgrade complete!');
+ printNextSteps([
+ `Ensure ${chalk.bold('@react-spectrum/s2')} is installed.`,
+ `If your bundler is not Parcel v2.12.0+, configure the Spectrum 2 style macro support. See: ${chalk.underline('https://react-spectrum.adobe.com/getting-started#framework-setup')}`,
+ `Add ${chalk.bold('import \'@react-spectrum/s2/page.css\';')} to your entry component if needed.`,
+ `Search for ${chalk.bold('TODO(S2-upgrade)')} and resolve remaining manual migration updates.`,
+ `Reference the migration guide: ${chalk.underline('https://react-spectrum.adobe.com/migrating')}`
+ ]);
+ return;
+ }
+
console.log(boxen(
'Welcome to the React Spectrum v3 to Spectrum 2 upgrade assistant!\n\n' +
'This tool will:\n\n' +
@@ -53,7 +75,7 @@ export async function s1_to_s2(options: S1ToS2CodemodOptions): Promise {
` - Vite: ${chalk.underline('https://github.com/adobe/react-spectrum/tree/main/examples/s2-vite-project')}\n` +
` - Rollup: ${chalk.underline('https://github.com/adobe/react-spectrum/tree/main/examples/s2-rollup-starter-app')}\n` +
` - ESBuild: ${chalk.underline('https://github.com/adobe/react-spectrum/tree/main/examples/s2-esbuild-starter-app')}\n\n` +
- `or view documentation here: ${chalk.underline('https://react-spectrum.adobe.com/s2/index.html?path=/docs/intro--docs#configuring-your-bundler')}`
+ `or view documentation here: ${chalk.underline('https://react-spectrum.adobe.com/getting-started#framework-setup')}`
);
}
@@ -63,14 +85,10 @@ export async function s1_to_s2(options: S1ToS2CodemodOptions): Promise {
`${chalk.bold('TODO(S2-upgrade)')}\n\n` +
'You should be able to search your codebase and handle these manually. \n\n' +
'We also recommend running your project\'s code formatter (i.e. Prettier, ESLint) after the upgrade process to clean up any extraneous formatting from the codemod.\n\n' +
- `For additional help, reference the Spectrum 2 Migration Guide: ${chalk.underline('https://react-spectrum.adobe.com/s2/index.html?path=/docs/migrating--docs')}`
+ `For additional help, reference the Spectrum 2 Migration Guide: ${chalk.underline('https://react-spectrum.adobe.com/migrating')}`
);
- console.log(boxen(
- `Next steps:\n\n ${nextSteps.map((step, i) => `${i + 1}. ${step}`).join('\n\n\n')}`,
- {borderStyle: 'round', padding: 1, borderColor: 'green'}
- ));
+ printNextSteps(nextSteps);
process.exit(0);
}
-
diff --git a/packages/dev/codemods/src/s1-to-s2/src/transform.ts b/packages/dev/codemods/src/s1-to-s2/src/transform.ts
index a410568d8ce..b17bae51763 100644
--- a/packages/dev/codemods/src/s1-to-s2/src/transform.ts
+++ b/packages/dev/codemods/src/s1-to-s2/src/transform.ts
@@ -5,10 +5,7 @@ import {S1ToS2CodemodOptions} from '../..';
const transformPath = path.join(__dirname, 'codemods', 'codemod.js');
export async function transform(options: S1ToS2CodemodOptions): Promise> {
- let {
- path: filePath = '.',
- ...rest
- } = options;
-
- return await jscodeshift(transformPath, [filePath], rest);
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
+ const {path: filePath = '.', agent, ...jscodeshiftOptions} = options;
+ return await jscodeshift(transformPath, [filePath], jscodeshiftOptions);
}
diff --git a/packages/dev/codemods/tsconfig.json b/packages/dev/codemods/tsconfig.json
index 400010f4b37..cc7fd39c9d6 100644
--- a/packages/dev/codemods/tsconfig.json
+++ b/packages/dev/codemods/tsconfig.json
@@ -8,5 +8,5 @@
"moduleResolution": "node",
},
"include": ["src/**/*"],
- "exclude": ["node_modules", "**/*.test.ts", "**/__mocks__"]
+ "exclude": ["node_modules", "**/*.test.ts", "**/__mocks__", "**/__testfixtures__"]
}
diff --git a/rules/README.md b/rules/README.md
index 2885e79a276..007eddad3b0 100644
--- a/rules/README.md
+++ b/rules/README.md
@@ -14,7 +14,7 @@ For React Spectrum (Spectrum 2) (i.e. `@react-spectrum/s2`).
### `style-macro.mdc`
-For the React Spectrum S2 [style macro](https://react-spectrum.adobe.com/s2/index.html?path=/docs/style-macro--docs).
+For the React Spectrum S2 [style macro](https://react-spectrum.adobe.com/styling).
### `react-spectrum-v3-to-s2-migration.mdc`
diff --git a/yarn.lock b/yarn.lock
index 0d63538a20f..5e85ee238df 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -88,7 +88,7 @@ __metadata:
languageName: node
linkType: hard
-"@adobe/react-spectrum@workspace:packages/@adobe/react-spectrum":
+"@adobe/react-spectrum@npm:^3.46.2, @adobe/react-spectrum@workspace:packages/@adobe/react-spectrum":
version: 0.0.0-use.local
resolution: "@adobe/react-spectrum@workspace:packages/@adobe/react-spectrum"
dependencies:
@@ -6649,6 +6649,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@react-spectrum/codemods@workspace:packages/dev/codemods"
dependencies:
+ "@adobe/react-spectrum": "npm:^3.46.2"
"@babel/parser": "npm:^7.24.5"
"@babel/traverse": "npm:^7.24.5"
"@babel/types": "npm:^7.24.5"