Skip to content

Commit

Permalink
Implement upgrading transitions to objects when adding actions to them
Browse files Browse the repository at this point in the history
  • Loading branch information
Andarist committed Mar 6, 2024
1 parent fc2712c commit f269f19
Show file tree
Hide file tree
Showing 4 changed files with 260 additions and 114 deletions.
163 changes: 96 additions & 67 deletions new-packages/ts-project/__tests__/source-edits/add-action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,82 +355,111 @@ test('should be possible to add an entry action in the middle of existing entry
`);
});

test.todo(
'should be possible to add a transition action to the root',
async () => {
const tmpPath = await testdir({
'tsconfig.json': JSON.stringify({}),
'index.ts': ts`
import { createMachine } from "xstate";
createMachine({
on: {
FOO: ".a",
},
states: {
a: {},
},
});
`,
});
test('should be possible to add a transition action to the root', async () => {
const tmpPath = await testdir({
'tsconfig.json': JSON.stringify({}),
'index.ts': ts`
import { createMachine } from "xstate";
const project = await createTestProject(tmpPath);
createMachine({
on: {
FOO: ".a",
},
states: {
a: {},
},
});
`,
});

const project = await createTestProject(tmpPath);

const textEdits = project.editDigraph(
{
fileName: 'index.ts',
machineIndex: 0,
},
{
type: 'add_action',
path: [],
actionPath: ['on', 'FOO', 0, 0],
name: 'doStuff',
},
);
expect(await project.applyTextEdits(textEdits)).toMatchInlineSnapshot(`
{
"index.ts": "import { createMachine } from "xstate";
const textEdits = project.editDigraph(
{
fileName: 'index.ts',
machineIndex: 0,
createMachine({
on: {
FOO: {
target: ".a",
actions: "doStuff"
},
},
{
type: 'add_action',
path: [],
actionPath: ['on', 'FOO', 0, 0],
name: 'doStuff',
states: {
a: {},
},
);
expect(await project.applyTextEdits(textEdits)).toMatchInlineSnapshot();
},
);

test.todo(
`should be possible to add a transition action to invoke's onDone`,
async () => {
const tmpPath = await testdir({
'tsconfig.json': JSON.stringify({}),
'index.ts': ts`
import { createMachine } from "xstate";
createMachine({
states: {
a: {
invoke: {
src: "callDavid",
onDone: "b",
},
});",
}
`);
});

test(`should be possible to add a transition action to invoke's onDone`, async () => {
const tmpPath = await testdir({
'tsconfig.json': JSON.stringify({}),
'index.ts': ts`
import { createMachine } from "xstate";
createMachine({
states: {
a: {
invoke: {
src: "callDavid",
onDone: "b",
},
b: {},
},
});
`,
});
b: {},
},
});
`,
});

const project = await createTestProject(tmpPath);
const project = await createTestProject(tmpPath);

const textEdits = project.editDigraph(
{
fileName: 'index.ts',
machineIndex: 0,
},
{
type: 'add_action',
path: ['a'],
actionPath: ['invoke', 0, 'onDone', 0, 0],
name: 'getRaise',
const textEdits = project.editDigraph(
{
fileName: 'index.ts',
machineIndex: 0,
},
{
type: 'add_action',
path: ['a'],
actionPath: ['invoke', 0, 'onDone', 0, 0],
name: 'getRaise',
},
);
expect(await project.applyTextEdits(textEdits)).toMatchInlineSnapshot(`
{
"index.ts": "import { createMachine } from "xstate";
createMachine({
states: {
a: {
invoke: {
src: "callDavid",
onDone: {
target: "b",
actions: "getRaise"
},
},
},
b: {},
},
);
expect(await project.applyTextEdits(textEdits)).toMatchInlineSnapshot();
},
);
});",
}
`);
});

test(`should be possible to add a transition action to an object transition`, async () => {
const tmpPath = await testdir({
Expand Down
118 changes: 117 additions & 1 deletion new-packages/ts-project/src/codeChanges.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,13 @@ interface WrapIntoArrayWithCodeChange extends BaseCodeChange {
newElement: InsertionElement;
}

interface WrapIntoObjectCodeChange extends BaseCodeChange {
type: 'wrap_into_object';
current: Node;
reuseAs: string;
newProperties: PropertyInsertionElement[];
}

type CodeChange =
| InsertAtOptionalObjectPathCodeChange
| InsertElementIntoArrayCodeChange
Expand All @@ -93,7 +100,8 @@ type CodeChange =
| ReplacePropertyNameChange
| ReplaceRangeCodeChange
| ReplaceWithCodeChange
| WrapIntoArrayWithCodeChange;
| WrapIntoArrayWithCodeChange
| WrapIntoObjectCodeChange;

function toZeroLengthRange(position: number) {
return { start: position, end: position };
Expand Down Expand Up @@ -461,6 +469,28 @@ export function createCodeChanges(ts: typeof import('typescript')) {
newElement,
});
},
wrapIntoObject: (
current: Node,
{
reuseAs,
newProperties,
}: {
reuseAs: string;
newProperties: PropertyInsertionElement[];
},
) => {
changes.push({
type: 'wrap_into_object',
sourceFile: current.getSourceFile(),
range: {
start: current.getStart(),
end: current.getEnd(),
},
current,
reuseAs,
newProperties,
});
},

getTextEdits: (): TextEdit[] => {
const edits: TextEdit[] = [];
Expand Down Expand Up @@ -949,6 +979,92 @@ export function createCodeChanges(ts: typeof import('typescript')) {
});
break;
}
case 'wrap_into_object': {
// TODO: this handler shares a lot of the core logic with `wrap_into_array_with`
// ideally this would get deduplicated heavily as the problems of wrapping a single item in an array and in object are very similar
const currentIdentation = getIndentationBeforePosition(
change.sourceFile.text,
change.current.getStart(),
);
const hasNewLine = change.current.getFullText().includes('\n');

const newElementIndentation = hasNewLine
? currentIdentation
: currentIdentation + formattingOptions.singleIndentation;

let beforePosition;
let beforeText = '';

if (hasNewLine) {
beforePosition = change.current.pos;
beforeText += ` {`;
} else {
const leadingTrivia = change.sourceFile.text.slice(
change.current.pos,
change.current.getStart(),
);
beforePosition =
change.current.pos + getLeadingWhitespaceLength(leadingTrivia);
beforeText += `{\n` + newElementIndentation;
}

beforeText += change.reuseAs + ': ';

edits.push({
type: 'insert',
fileName: change.sourceFile.fileName,
position: beforePosition,
newText: beforeText,
});

const trailingComment = last(
ts.getTrailingCommentRanges(
change.sourceFile.text,
change.current.getEnd(),
),
);

if (trailingComment) {
edits.push({
type: 'insert',
fileName: change.sourceFile.fileName,
position: change.current.getEnd(),
newText: ',',
});
}

let afterPosition = trailingComment?.end ?? change.current.getEnd();
let afterText =
(trailingComment ? '' : ',') +
'\n' +
change.newProperties
.map(
(property) =>
newElementIndentation +
insertionToText(
ts,
change.sourceFile,
property,
formattingOptions,
),
)
.join(',\n');

edits.push({
type: 'insert',
fileName: change.sourceFile.fileName,
position: afterPosition,
newText:
afterText +
'\n' +
getIndentationBeforePosition(
change.sourceFile.text,
change.current.pos,
) +
`}`,
});
break;
}
}
}

Expand Down
26 changes: 20 additions & 6 deletions new-packages/ts-project/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,10 @@ import type {
SourceFile,
} from 'typescript';
import {
c,
createCodeChanges,
InsertionElement,
InsertionPriority,
c,
createCodeChanges,
} from './codeChanges';
import { shallowEqual } from './shallowEqual';
import { extractState } from './state';
Expand Down Expand Up @@ -822,8 +822,6 @@ function createProjectMachine({
createMachineCall,
currentState.astPaths.edges[edgeId],
);
// TODO: this isn't always true, it's a temporary assertion
assert(host.ts.isObjectLiteralExpression(edge));
if (patch.path[2] === 'data' && patch.path[3] === 'actions') {
const index = patch.path[4];
assert(typeof index === 'number');
Expand All @@ -838,6 +836,22 @@ function createProjectMachine({
const actionId = patch.value;
assert(typeof actionId === 'string');

if (!host.ts.isObjectLiteralExpression(edge)) {
assert(index === 0);

codeChanges.wrapIntoObject(edge, {
reuseAs: 'target',
newProperties: [
c.property(
'actions',
c.string(
currentState.digraph!.blocks[actionId].sourceId,
),
),
],
});
break;
}
codeChanges.insertAtOptionalObjectPath(
edge,
[patch.path[3], index],
Expand Down Expand Up @@ -893,9 +907,9 @@ function createProjectMachine({
createMachineCall,
currentState.astPaths.edges[edgeId],
);
// TODO: this isn't always true, it's a temporary assertion
assert(host.ts.isObjectLiteralExpression(edge));
if (patch.path[2] === 'data' && patch.path[3] === 'actions') {
// this should always be true - if we are replacing an action within an edge then the edge already has to be an object literal
assert(host.ts.isObjectLiteralExpression(edge));
const insertion = consumeArrayInsertionAtIndex(
sortedArrayPatches,
i,
Expand Down
Loading

0 comments on commit f269f19

Please sign in to comment.