Skip to content

Commit

Permalink
Merge pull request #3168 from jspsych/abort-timeline-by-id
Browse files Browse the repository at this point in the history
Add `abortTimelineByName()` to allow ending specific timelines
  • Loading branch information
jodeleeuw authored Nov 9, 2023
2 parents 3855b5d + a4c573e commit 72ddfa0
Show file tree
Hide file tree
Showing 9 changed files with 228 additions and 1 deletion.
5 changes: 5 additions & 0 deletions .changeset/afraid-badgers-lie.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"jspsych": minor
---

Added `jsPsych.abortTimelineByName()`. This allows for aborting a specific active timeline by its `name` property. The `name` can be set in the description of the timline.
68 changes: 68 additions & 0 deletions docs/reference/jspsych.md
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,74 @@ var trial = {
}
```

---
## jsPsych.abortTimelineByName

```javascript
jsPsych.abortTimelineByName()
```

### Parameters

| Parameter | Type | Description |
| --------------- | -------- | ---------------------------------------- |
| name | string | The name of the timeline to abort. |

### Return value

None.

### Description

Ends the currently active timeline that matches the `name` parameter. This can be used to control which level is aborted in a nested timeline structure.

### Example

#### Abort a procedure if an incorrect response is given.

```javascript
const fixation = {
type: jsPsychHtmlKeyboardResponse,
stimulus: '<p>+</p>',
choices: "NO_KEYS",
trial_duration: 1000
}

const test = {
type: jsPsychImageKeyboardResponse,
stimulus: jsPsych.timelineVariable('stimulus'),
choices: ['y', 'n'],
on_finish: function(data){
if(jsPsych.pluginAPI.compareKeys(data.response, "n")){
jsPsych.abortTimelineByName('memory_test');
}
}
}

const memoryResponseProcedure = {
timeline: [fixation, test]
}

// the variable `encode` is not shown, but imagine a trial that displays
// some stimulus to remember.
const memoryEncodeProcedure = {
timeline: [fixation, encode]
}

const memoryTestProcedure = {
timeline: [memoryEncodeProcedure, memoryResponseProcedure]
name: 'memory_test',
timeline_variables: [
{stimulus: 'image1.png'},
{stimulus: 'image2.png'},
{stimulus: 'image3.png'},
{stimulus: 'image4.png'}
]
}


```

---
## jsPsych.addNodeToEndOfTimeline

Expand Down
12 changes: 12 additions & 0 deletions packages/jspsych/src/JsPsych.ts
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,18 @@ export class JsPsych {
}
}

/**
* Aborts a named timeline. The timeline must be currently running in order to abort it.
*
* @param name The name of the timeline to abort. Timelines can be given names by setting the `name` parameter in the description of the timeline.
*/
abortTimelineByName(name: string): void {
const timeline = this.timeline?.getActiveTimelineByName(name);
if (timeline) {
timeline.abort();
}
}

getCurrentTrial() {
const activeNode = this.timeline?.getLatestNode();
if (activeNode instanceof Trial) {
Expand Down
41 changes: 41 additions & 0 deletions packages/jspsych/src/timeline/Timeline.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -864,4 +864,45 @@ describe("Timeline", () => {
expect(timeline.getLatestNode()).toBe(nestedTrial);
});
});

describe("getActiveTimelineByName()", () => {
it("returns the timeline with the given name", async () => {
TestPlugin.setManualFinishTrialMode();

const timeline = createTimeline({
timeline: [{ timeline: [{ type: TestPlugin }], name: "innerTimeline" }],
name: "outerTimeline",
});

timeline.run();

expect(timeline.getActiveTimelineByName("outerTimeline")).toBe(timeline);
expect(timeline.getActiveTimelineByName("innerTimeline")).toBe(
timeline.children[0] as Timeline
);
});

it("returns only active timelines", async () => {
TestPlugin.setManualFinishTrialMode();

const timeline = createTimeline({
timeline: [
{ type: TestPlugin },
{ timeline: [{ type: TestPlugin }], name: "innerTimeline" },
],
name: "outerTimeline",
});

timeline.run();

expect(timeline.getActiveTimelineByName("outerTimeline")).toBe(timeline);
expect(timeline.getActiveTimelineByName("innerTimeline")).toBeUndefined();

await TestPlugin.finishTrial();

expect(timeline.getActiveTimelineByName("innerTimeline")).toBe(
timeline.children[1] as Timeline
);
});
});
});
8 changes: 8 additions & 0 deletions packages/jspsych/src/timeline/Timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -331,4 +331,12 @@ export class Timeline extends TimelineNode {
public getLatestNode() {
return this.currentChild?.getLatestNode() ?? this;
}

public getActiveTimelineByName(name: string) {
if (this.description.name === name) {
return this;
}

return this.currentChild?.getActiveTimelineByName(name);
}
}
5 changes: 5 additions & 0 deletions packages/jspsych/src/timeline/TimelineNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,11 @@ export abstract class TimelineNode {
*/
abstract getLatestNode(): TimelineNode;

/**
* Returns an active child timeline (or itself) that matches the given name, or `undefined` if no such child exists.
*/
abstract getActiveTimelineByName(name: string): Timeline | undefined;

protected status = TimelineNodeStatus.PENDING;

constructor(protected readonly dependencies: TimelineNodeDependencies) {}
Expand Down
7 changes: 7 additions & 0 deletions packages/jspsych/src/timeline/Trial.ts
Original file line number Diff line number Diff line change
Expand Up @@ -378,4 +378,11 @@ export class Trial extends TimelineNode {
public getLatestNode() {
return this;
}

public getActiveTimelineByName(name: string): Timeline | undefined {
// This returns undefined because the function is looking
// for a timeline. If we get to this point, then none
// of the parent nodes match the name.
return undefined;
}
}
3 changes: 3 additions & 0 deletions packages/jspsych/src/timeline/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ export interface TimelineDescription extends Record<string, any> {
timeline: TimelineArray;
timeline_variables?: Record<string, any>[];

name?: string;

// Control flow

/** https://www.jspsych.org/latest/overview/timeline/#repeating-a-set-of-trials */
Expand Down Expand Up @@ -112,6 +114,7 @@ export interface TimelineDescription extends Record<string, any> {
export const timelineDescriptionKeys = [
"timeline",
"timeline_variables",
"name",
"repetitions",
"loop_function",
"conditional_function",
Expand Down
80 changes: 79 additions & 1 deletion packages/jspsych/tests/core/timelines.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -337,7 +337,7 @@ describe("conditional function", () => {
});
});

describe("endCurrentTimeline", () => {
describe("abortCurrentTimeline", () => {
test("stops the current timeline, skipping to the end after the trial completes", async () => {
const jsPsych = initJsPsych();
const { getHTML } = await startTimeline(
Expand Down Expand Up @@ -420,6 +420,84 @@ describe("endCurrentTimeline", () => {
});
});

describe("abortTimelineByName", () => {
test("stops the timeline with the given name, skipping to the end after the trial completes", async () => {
const jsPsych = initJsPsych();
const { getHTML } = await startTimeline(
[
{
timeline: [
{
type: htmlKeyboardResponse,
stimulus: "foo",
on_finish: () => {
jsPsych.abortTimelineByName("timeline");
},
},
{
type: htmlKeyboardResponse,
stimulus: "bar",
},
],
name: "timeline",
},
{
type: htmlKeyboardResponse,
stimulus: "woo",
},
],
jsPsych
);

expect(getHTML()).toMatch("foo");
await pressKey("a");
expect(getHTML()).toMatch("woo");
await pressKey("a");
});

test("works inside nested timelines", async () => {
const jsPsych = initJsPsych();
const { getHTML } = await startTimeline(
[
{
timeline: [
{
timeline: [
{
type: htmlKeyboardResponse,
stimulus: "foo",
on_finish: () => {
jsPsych.abortTimelineByName("timeline");
},
},
{
type: htmlKeyboardResponse,
stimulus: "skip me!",
},
],
},
{
type: htmlKeyboardResponse,
stimulus: "skip me too!",
},
],
name: "timeline",
},
{
type: htmlKeyboardResponse,
stimulus: "woo",
},
],
jsPsych
);

expect(getHTML()).toMatch("foo");
await pressKey("a");
expect(getHTML()).toMatch("woo");
await pressKey("a");
});
});

describe("nested timelines", () => {
test("works without other parameters", async () => {
const { getHTML } = await startTimeline([
Expand Down

0 comments on commit 72ddfa0

Please sign in to comment.