diff --git a/new-packages/ts-project/__tests__/source-edits/edit-invoke.test.ts b/new-packages/ts-project/__tests__/source-edits/edit-invoke.test.ts new file mode 100644 index 00000000..1655afd9 --- /dev/null +++ b/new-packages/ts-project/__tests__/source-edits/edit-invoke.test.ts @@ -0,0 +1,308 @@ +import { expect, test } from 'vitest'; +import { createTestProject, testdir, ts } from '../utils'; + +test(`should be possible to update invoke's source`, async () => { + const tmpPath = await testdir({ + 'tsconfig.json': JSON.stringify({}), + 'index.ts': ts` + import { createMachine } from "xstate"; + + createMachine({ + invoke: { + src: "callDavid", + }, + }); + `, + }); + + const project = await createTestProject(tmpPath); + + const textEdits = project.editDigraph( + { + fileName: 'index.ts', + machineIndex: 0, + }, + { + type: 'edit_invoke', + path: [], + invokeIndex: 0, + source: 'callAn(d)e(r)s', + }, + ); + expect(await project.applyTextEdits(textEdits)).toMatchInlineSnapshot(` + { + "index.ts": "import { createMachine } from "xstate"; + + createMachine({ + invoke: { + src: "callAn(d)e(r)s", + }, + });", + } + `); +}); + +test(`should be possible to add an ID to the existing invoke`, async () => { + const tmpPath = await testdir({ + 'tsconfig.json': JSON.stringify({}), + 'index.ts': ts` + import { createMachine } from "xstate"; + + createMachine({ + invoke: { + src: "callDavid", + }, + }); + `, + }); + + const project = await createTestProject(tmpPath); + + const textEdits = project.editDigraph( + { + fileName: 'index.ts', + machineIndex: 0, + }, + { + type: 'edit_invoke', + path: [], + invokeIndex: 0, + id: 'importantCall', + }, + ); + expect(await project.applyTextEdits(textEdits)).toMatchInlineSnapshot(` + { + "index.ts": "import { createMachine } from "xstate"; + + createMachine({ + invoke: { + src: "callDavid", + id: "importantCall", + }, + });", + } + `); +}); + +test(`should be possible to update invoke's ID`, async () => { + const tmpPath = await testdir({ + 'tsconfig.json': JSON.stringify({}), + 'index.ts': ts` + import { createMachine } from "xstate"; + + createMachine({ + invoke: { + src: "callDavid", + id: "importantCall", + }, + }); + `, + }); + + const project = await createTestProject(tmpPath); + + const textEdits = project.editDigraph( + { + fileName: 'index.ts', + machineIndex: 0, + }, + { + type: 'edit_invoke', + path: [], + invokeIndex: 0, + id: 'veryImportantCall', + }, + ); + expect(await project.applyTextEdits(textEdits)).toMatchInlineSnapshot(` + { + "index.ts": "import { createMachine } from "xstate"; + + createMachine({ + invoke: { + src: "callDavid", + id: "veryImportantCall", + }, + });", + } + `); +}); + +test(`should be possible to update invoke's source and ID at the same time`, async () => { + const tmpPath = await testdir({ + 'tsconfig.json': JSON.stringify({}), + 'index.ts': ts` + import { createMachine } from "xstate"; + + createMachine({ + invoke: { + src: "callDavid", + id: "importantCall", + }, + }); + `, + }); + + const project = await createTestProject(tmpPath); + + const textEdits = project.editDigraph( + { + fileName: 'index.ts', + machineIndex: 0, + }, + { + type: 'edit_invoke', + path: [], + invokeIndex: 0, + source: 'callAn(d)e(r)s', + id: 'veryImportantCall', + }, + ); + expect(await project.applyTextEdits(textEdits)).toMatchInlineSnapshot(` + { + "index.ts": "import { createMachine } from "xstate"; + + createMachine({ + invoke: { + src: "callAn(d)e(r)s", + id: "veryImportantCall", + }, + });", + } + `); +}); + +test(`should be possible to remove invoke's ID`, async () => { + const tmpPath = await testdir({ + 'tsconfig.json': JSON.stringify({}), + 'index.ts': ts` + import { createMachine } from "xstate"; + + createMachine({ + invoke: { + src: "callDavid", + id: "importantCall", + }, + }); + `, + }); + + const project = await createTestProject(tmpPath); + + const textEdits = project.editDigraph( + { + fileName: 'index.ts', + machineIndex: 0, + }, + { + type: 'edit_invoke', + path: [], + invokeIndex: 0, + id: null, + }, + ); + expect(await project.applyTextEdits(textEdits)).toMatchInlineSnapshot(` + { + "index.ts": "import { createMachine } from "xstate"; + + createMachine({ + invoke: { + src: "callDavid", + }, + });", + } + `); +}); + +test(`should remove all \`id\` props when removing invoke's ID`, async () => { + const tmpPath = await testdir({ + 'tsconfig.json': JSON.stringify({}), + 'index.ts': ts` + import { createMachine } from "xstate"; + + createMachine({ + invoke: { + src: "callDavid", + id: "importantCall", + id: "veryImportantCall", + }, + }); + `, + }); + + const project = await createTestProject(tmpPath); + + const textEdits = project.editDigraph( + { + fileName: 'index.ts', + machineIndex: 0, + }, + { + type: 'edit_invoke', + path: [], + invokeIndex: 0, + id: null, + }, + ); + expect(await project.applyTextEdits(textEdits)).toMatchInlineSnapshot(` + { + "index.ts": "import { createMachine } from "xstate"; + + createMachine({ + invoke: { + src: "callDavid", + }, + });", + } + `); +}); + +test(`should be possible to update nth invoke's source`, async () => { + const tmpPath = await testdir({ + 'tsconfig.json': JSON.stringify({}), + 'index.ts': ts` + import { createMachine } from "xstate"; + + createMachine({ + invoke: [ + { + src: "callDavid", + }, + { + src: "callAnders", + }, + ], + }); + `, + }); + + const project = await createTestProject(tmpPath); + + const textEdits = project.editDigraph( + { + fileName: 'index.ts', + machineIndex: 0, + }, + { + type: 'edit_invoke', + path: [], + invokeIndex: 1, + source: 'callAn(d)e(r)s', + }, + ); + expect(await project.applyTextEdits(textEdits)).toMatchInlineSnapshot(` + { + "index.ts": "import { createMachine } from "xstate"; + + createMachine({ + invoke: [ + { + src: "callDavid", + }, + { + src: "callAn(d)e(r)s", + }, + ], + });", + } + `); +}); diff --git a/new-packages/ts-project/__tests__/utils.ts b/new-packages/ts-project/__tests__/utils.ts index a1e861ea..8bc2d0d0 100644 --- a/new-packages/ts-project/__tests__/utils.ts +++ b/new-packages/ts-project/__tests__/utils.ts @@ -592,8 +592,25 @@ function produceNewDigraphUsingEdit( break; } case 'remove_invoke': - case 'edit_invoke': throw new Error(`Not implemented: ${edit.type}`); + case 'edit_invoke': { + const node = findNodeByStatePath(digraphDraft, edit.path); + const blockId = node.data.invoke[edit.invokeIndex]; + const block = digraphDraft.blocks[blockId]; + if (edit.source) { + block.sourceId = edit.source; + digraphDraft.implementations.actors[edit.source] ??= { + type: 'actor', + id: edit.source, + name: edit.source, + }; + } + if ('id' in edit) { + assert(block.blockType === 'actor'); + block.properties.id = edit.id ?? ''; + } + break; + } case 'set_description': { const node = findNodeByStatePath(digraphDraft, edit.statePath); if (edit.transitionPath) { diff --git a/new-packages/ts-project/src/codeChanges.ts b/new-packages/ts-project/src/codeChanges.ts index 57705b98..4f3e4f49 100644 --- a/new-packages/ts-project/src/codeChanges.ts +++ b/new-packages/ts-project/src/codeChanges.ts @@ -14,6 +14,7 @@ import { findLast, findProperty, first, + getPropertyKey, last, } from './utils'; @@ -407,16 +408,24 @@ export function createCodeChanges(ts: typeof import('typescript')) { priority, }); }, - removeProperty: (property: PropertyAssignment) => { - changes.push({ - type: 'remove_property', - sourceFile: property.getSourceFile(), - range: { - start: property.getStart(), - end: property.getEnd(), - }, - property, - }); + removeObjectProperty: (object: ObjectLiteralExpression, name: string) => { + for (const property of object.properties) { + if ( + !ts.isPropertyAssignment(property) || + getPropertyKey(undefined, ts, property) !== name + ) { + continue; + } + changes.push({ + type: 'remove_property', + sourceFile: property.getSourceFile(), + range: { + start: property.getStart(), + end: property.getEnd(), + }, + property, + }); + } }, replacePropertyName: (property: PropertyAssignment, name: string) => { changes.push({ diff --git a/new-packages/ts-project/src/index.ts b/new-packages/ts-project/src/index.ts index e46fc2c5..e5fe520b 100644 --- a/new-packages/ts-project/src/index.ts +++ b/new-packages/ts-project/src/index.ts @@ -639,19 +639,17 @@ function createProjectMachine({ break; } if (patch.path[2] === 'data' && patch.path[3] === 'initial') { + if (patch.value === undefined) { + codeChanges.removeObjectProperty(stateNode, 'initial'); + break; + } + const initialProp = findProperty( undefined, host.ts, stateNode, 'initial', ); - if (patch.value === undefined) { - // this check is defensive, it should always be there - if (initialProp) { - codeChanges.removeProperty(initialProp); - } - break; - } if (initialProp) { codeChanges.replaceRange(sourceFile, { @@ -688,18 +686,16 @@ function createProjectMachine({ ); } if (patch.path[2] === 'data' && patch.path[3] === 'type') { + if (patch.value === 'normal') { + codeChanges.removeObjectProperty(stateNode, 'type'); + break; + } const typeProp = findProperty( undefined, host.ts, stateNode, 'type', ); - if (patch.value === 'normal') { - if (typeProp) { - codeChanges.removeProperty(typeProp); - } - break; - } if (typeProp) { codeChanges.replaceRange(sourceFile, { @@ -720,18 +716,17 @@ function createProjectMachine({ ); } if (patch.path[2] === 'data' && patch.path[3] === 'history') { + if (patch.value === undefined || patch.value === 'shallow') { + codeChanges.removeObjectProperty(stateNode, 'history'); + break; + } + const historyProp = findProperty( undefined, host.ts, stateNode, 'history', ); - if (patch.value === undefined || patch.value === 'shallow') { - if (historyProp) { - codeChanges.removeProperty(historyProp); - } - break; - } if (historyProp) { codeChanges.replaceRange(sourceFile, { @@ -756,18 +751,17 @@ function createProjectMachine({ patch.path[2] === 'data' && patch.path[3] === 'description' ) { + if (!patch.value) { + codeChanges.removeObjectProperty(stateNode, 'description'); + break; + } + const descriptionProp = findProperty( undefined, host.ts, stateNode, 'description', ); - if (!patch.value) { - if (descriptionProp) { - codeChanges.removeProperty(descriptionProp); - } - break; - } const element = c.string(patch.value, { allowMultiline: true, @@ -828,6 +822,9 @@ function createProjectMachine({ const block = currentState.digraph!.blocks[blockId]; switch (block.blockType) { case 'action': { + if (patch.path[2] !== 'sourceId') { + break; + } const node = currentState.digraph!.nodes[block.parentId]; if (!node) { // there is no way to know where to look for this parent @@ -927,9 +924,88 @@ function createProjectMachine({ break; } case 'actor': { + const node = currentState.digraph!.nodes[block.parentId]; + const stateNode = findNodeByAstPath( + host.ts, + createMachineCall, + currentState.astPaths.nodes[node.uniqueId], + ); + assert(host.ts.isObjectLiteralExpression(stateNode)); + const invokeIndex = node.data.invoke.indexOf(blockId); + const invokeProperty = findProperty( + undefined, + host.ts, + stateNode, + 'invoke', + ); + assert(!!invokeProperty); + + let invokeNode = invokeProperty.initializer; + + if ( + host.ts.isArrayLiteralExpression( + invokeProperty.initializer, + ) + ) { + invokeNode = + invokeProperty.initializer.elements[invokeIndex]; + assert(!!invokeNode); + } + + assert(host.ts.isObjectLiteralExpression(invokeNode)); + + if (patch.path[2] === 'sourceId') { + const srcProperty = findProperty( + undefined, + host.ts, + invokeNode, + 'src', + ); + + assert(!!srcProperty); + + codeChanges.replaceWith( + srcProperty.initializer, + c.string(block.sourceId), + ); + break; + } + + if ( + patch.path[2] === 'properties' && + patch.path[3] === 'id' + ) { + const actorId = block.properties.id; + if (actorId && !actorId.startsWith('inline:')) { + const idProperty = findProperty( + undefined, + host.ts, + invokeNode, + 'id', + ); + if (idProperty) { + codeChanges.replaceWith( + idProperty.initializer, + c.string(actorId), + ); + break; + } + codeChanges.insertPropertyIntoObject( + invokeNode, + 'id', + c.string(actorId), + ); + break; + } + codeChanges.removeObjectProperty(invokeNode, 'id'); + break; + } break; } case 'guard': { + if (patch.path[2] !== 'sourceId') { + break; + } const edge = currentState.digraph!.edges[block.parentId]; const transitionNode = findNodeByAstPath( host.ts,