Skip to content

Commit 092ca9d

Browse files
rfontanarosagino-m
andauthored
Fix duplicateJob to regenerate task ids and remap conditions (#2512)
Co-authored-by: Gino Miceli <228050+gino-m@users.noreply.github.com>
1 parent 2da2ab7 commit 092ca9d

3 files changed

Lines changed: 220 additions & 41 deletions

File tree

web/src/app/services/job/job.service.spec.ts

Lines changed: 128 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,10 +16,18 @@
1616

1717
import { TestBed } from '@angular/core/testing';
1818
import { RouterTestingModule } from '@angular/router/testing';
19-
import { Map } from 'immutable';
19+
import { List, Map } from 'immutable';
2020

2121
import { DataCollectionStrategy, Job } from 'app/models/job.model';
2222
import { DataSharingType, Survey } from 'app/models/survey.model';
23+
import { Cardinality, MultipleChoice } from 'app/models/task/multiple-choice.model';
24+
import { Option } from 'app/models/task/option.model';
25+
import {
26+
TaskCondition,
27+
TaskConditionExpression,
28+
TaskConditionExpressionType,
29+
TaskConditionMatchType,
30+
} from 'app/models/task/task-condition.model';
2331
import { Task, TaskType } from 'app/models/task/task.model';
2432
import { DataStoreService } from 'app/services/data-store/data-store.service';
2533
import { JobService } from 'app/services/job/job.service';
@@ -90,7 +98,7 @@ describe('JobService', () => {
9098
describe('duplicateJob', () => {
9199
it('should duplicate job with new IDs', () => {
92100
const oldTask = new Task('task1', TaskType.TEXT, 'Label', false, 0);
93-
const newTask = new Task('task1', TaskType.TEXT, 'Label', false, 0);
101+
const newTask = new Task('newTask1', TaskType.TEXT, 'Label', false, 0);
94102
const job = new Job(
95103
'job1',
96104
0,
@@ -108,8 +116,124 @@ describe('JobService', () => {
108116
expect(newJob.id).toBe('job2');
109117
expect(newJob.name).toBe('Copy of Job 1');
110118
expect(newJob.color).toBe('#FFF');
111-
expect(newJob.tasks?.get('task1')).toEqual(newTask);
112-
expect(taskServiceSpy.duplicateTask).toHaveBeenCalledWith(oldTask, true);
119+
expect(newJob.tasks?.get('newTask1')).toEqual(newTask);
120+
expect(taskServiceSpy.duplicateTask).toHaveBeenCalledWith(oldTask);
121+
});
122+
123+
it('should remap condition references to the duplicated task and option ids', () => {
124+
const sourceOption = new Option('opt1', 'A', 'A', 0);
125+
const sourceTask = new Task(
126+
'src',
127+
TaskType.MULTIPLE_CHOICE,
128+
'Source',
129+
false,
130+
0,
131+
new MultipleChoice(Cardinality.SELECT_ONE, List([sourceOption]))
132+
);
133+
const dependentTask = new Task(
134+
'dep',
135+
TaskType.TEXT,
136+
'Dependent',
137+
false,
138+
1,
139+
undefined,
140+
new TaskCondition(
141+
TaskConditionMatchType.MATCH_ALL,
142+
List([
143+
new TaskConditionExpression(
144+
TaskConditionExpressionType.ONE_OF_SELECTED,
145+
'src',
146+
List(['opt1'])
147+
),
148+
])
149+
)
150+
);
151+
const job = new Job(
152+
'job1',
153+
0,
154+
'#000',
155+
'Job 1',
156+
Map({ src: sourceTask, dep: dependentTask }),
157+
DataCollectionStrategy.MIXED
158+
);
159+
160+
const newSourceTask = new Task(
161+
'newSrc',
162+
TaskType.MULTIPLE_CHOICE,
163+
'Source',
164+
false,
165+
0,
166+
new MultipleChoice(
167+
Cardinality.SELECT_ONE,
168+
List([new Option('newOpt1', 'A', 'A', 0)])
169+
)
170+
);
171+
const newDependentTask = new Task(
172+
'newDep',
173+
TaskType.TEXT,
174+
'Dependent',
175+
false,
176+
1,
177+
undefined,
178+
dependentTask.condition
179+
);
180+
dataStoreServiceSpy.generateId.and.returnValue('job2');
181+
taskServiceSpy.duplicateTask.and.returnValues(
182+
newSourceTask,
183+
newDependentTask
184+
);
185+
186+
const newJob = service.duplicateJob(job, '#FFF');
187+
188+
const remapped = newJob.tasks?.get('newDep');
189+
const expression = remapped?.condition?.expressions.first();
190+
expect(expression?.taskId).toBe('newSrc');
191+
expect(expression?.optionIds.toArray()).toEqual(['newOpt1']);
192+
});
193+
194+
it('should drop condition expressions whose source task is missing', () => {
195+
const dependentTask = new Task(
196+
'dep',
197+
TaskType.TEXT,
198+
'Dependent',
199+
false,
200+
0,
201+
undefined,
202+
new TaskCondition(
203+
TaskConditionMatchType.MATCH_ALL,
204+
List([
205+
new TaskConditionExpression(
206+
TaskConditionExpressionType.ONE_OF_SELECTED,
207+
'missing',
208+
List(['opt-missing'])
209+
),
210+
])
211+
)
212+
);
213+
const job = new Job(
214+
'job1',
215+
0,
216+
'#000',
217+
'Job 1',
218+
Map({ dep: dependentTask }),
219+
DataCollectionStrategy.MIXED
220+
);
221+
222+
const newDependentTask = new Task(
223+
'newDep',
224+
TaskType.TEXT,
225+
'Dependent',
226+
false,
227+
0,
228+
undefined,
229+
dependentTask.condition
230+
);
231+
dataStoreServiceSpy.generateId.and.returnValue('job2');
232+
taskServiceSpy.duplicateTask.and.returnValue(newDependentTask);
233+
234+
const newJob = service.duplicateJob(job, '#FFF');
235+
236+
expect(newJob.tasks?.get('newDep')?.condition?.expressions.size).toBe(0);
113237
});
114238
});
115239

web/src/app/services/job/job.service.ts

Lines changed: 61 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ import { List, Map } from 'immutable';
2020
import { DataCollectionStrategy, Job } from 'app/models/job.model';
2121
import { MultipleChoice } from 'app/models/task/multiple-choice.model';
2222
import { Option } from 'app/models/task/option.model';
23+
import {
24+
TaskCondition,
25+
TaskConditionExpression,
26+
} from 'app/models/task/task-condition.model';
2327
import { Task, TaskType } from 'app/models/task/task.model';
2428
import { DataStoreService } from 'app/services/data-store/data-store.service';
2529
import { Survey } from 'app/models/survey.model';
@@ -73,23 +77,73 @@ export class JobService {
7377
}
7478

7579
/**
76-
* Returns a new job which is an exact copy of the specified job, but with new UUIDs for all items recursively.
80+
* Returns a new job which is an exact copy of the specified job, but with
81+
* new UUIDs for the job, every task, and every multiple-choice option.
82+
* Conditional task references (TaskConditionExpression) are remapped to
83+
* the new task/option ids so dependencies survive the duplication.
7784
*/
7885
duplicateJob(job: Job, color: string | undefined): Job {
86+
// Pass 1: duplicate each task (with new ids) and record old → new id
87+
// mappings for tasks and options.
88+
const taskIdMap = new globalThis.Map<string, string>();
89+
const optionIdMap = new globalThis.Map<string, string>();
90+
const duplicates = (job.tasks?.toArray() ?? []).map(([, oldTask]) => {
91+
const newTask = this.taskService.duplicateTask(oldTask);
92+
taskIdMap.set(oldTask.id, newTask.id);
93+
oldTask.multipleChoice?.options.forEach((oldOption, i) => {
94+
const newOption = newTask.multipleChoice?.options.get(i);
95+
if (newOption) optionIdMap.set(oldOption.id, newOption.id);
96+
});
97+
return newTask;
98+
});
99+
100+
// Pass 2: remap any condition references using the id maps.
101+
const tasks = Map<string, Task>(
102+
duplicates.map(newTask => {
103+
const remapped = newTask.condition
104+
? newTask.copyWith({
105+
condition: this.remapCondition(
106+
newTask.condition,
107+
taskIdMap,
108+
optionIdMap
109+
),
110+
})
111+
: newTask;
112+
return [remapped.id, remapped];
113+
})
114+
);
115+
79116
return job.copyWith({
80117
id: this.dataStoreService.generateId(),
81118
name: `Copy of ${job.name}`,
82119
color,
83120
index: -1,
84-
tasks: Map<string, Task>(
85-
job.tasks?.toArray().map(([_, task]) => {
86-
const duplicateTask = this.taskService.duplicateTask(task, true);
87-
return [duplicateTask.id, duplicateTask];
88-
})
89-
),
121+
tasks,
90122
});
91123
}
92124

125+
private remapCondition(
126+
condition: TaskCondition,
127+
taskIdMap: globalThis.Map<string, string>,
128+
optionIdMap: globalThis.Map<string, string>
129+
): TaskCondition {
130+
const expressions = condition.expressions
131+
.map(expr => {
132+
const newTaskId = taskIdMap.get(expr.taskId);
133+
if (!newTaskId) return null;
134+
const newOptionIds = expr.optionIds
135+
.map(id => optionIdMap.get(id))
136+
.filter((id): id is string => !!id);
137+
return new TaskConditionExpression(
138+
expr.expressionType,
139+
newTaskId,
140+
newOptionIds
141+
);
142+
})
143+
.filter((expr): expr is TaskConditionExpression => !!expr);
144+
return new TaskCondition(condition.matchType, expressions);
145+
}
146+
93147
/**
94148
* Creates and returns a new task with a generated unique identifier and a single English label.
95149
*/

web/src/app/services/task/task.service.ts

Lines changed: 31 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { List, Map } from 'immutable';
1919

2020
import { DataCollectionStrategy, Job } from 'app/models/job.model';
2121
import { MultipleChoice } from 'app/models/task/multiple-choice.model';
22+
import { Option } from 'app/models/task/option.model';
2223
import { Task, TaskType } from 'app/models/task/task.model';
2324
import { DataStoreService } from 'app/services/data-store/data-store.service';
2425

@@ -51,40 +52,40 @@ export class TaskService {
5152
}
5253

5354
/**
54-
* Creates a duplicate of the provided task.
55-
* @param task - The task to be duplicated.
56-
* @param preserveId - If true, keeps the original task ID.
57-
* If false (default), generates a new unique ID for the duplicate.
58-
* @returns A new Task instance with duplicated nested properties.
55+
* Creates a duplicate of the provided task with a freshly generated id
56+
* (and fresh option ids for multiple-choice tasks). Note: `condition`
57+
* references are copied verbatim — when duplicating tasks across a job
58+
* boundary, the caller is responsible for remapping them.
5959
*/
60-
duplicateTask(task: Task, preserveId: boolean = false): Task {
61-
return {
62-
...task,
63-
id: preserveId ? task.id : this.dataStoreService.generateId(),
64-
multipleChoice: task.multipleChoice
65-
? this.duplicateMultipleChoice(task.multipleChoice, preserveId)
60+
duplicateTask(task: Task): Task {
61+
return new Task(
62+
this.dataStoreService.generateId(),
63+
task.type,
64+
task.label,
65+
task.required,
66+
task.index,
67+
task.multipleChoice
68+
? this.duplicateMultipleChoice(task.multipleChoice)
6669
: undefined,
67-
} as Task;
70+
task.condition,
71+
task.addLoiTask
72+
);
6873
}
6974

70-
/**
71-
* Creates a duplicate of the provided multiple choice object.
72-
* @param multipleChoice - The multiple choice attribute to be duplicated.
73-
* @param preserveId - If true, keeps the original option IDs.
74-
* If false (default), generates new unique IDs for the options.
75-
* @returns A new MultipleChoice instance with duplicated options.
76-
*/
77-
duplicateMultipleChoice(
78-
multipleChoice: MultipleChoice,
79-
preserveId: boolean = false
80-
): MultipleChoice {
81-
return {
82-
...multipleChoice,
83-
options: multipleChoice.options?.map(option => ({
84-
...option,
85-
id: preserveId ? option.id : this.dataStoreService.generateId(),
86-
})),
87-
} as MultipleChoice;
75+
duplicateMultipleChoice(multipleChoice: MultipleChoice): MultipleChoice {
76+
return new MultipleChoice(
77+
multipleChoice.cardinality,
78+
multipleChoice.options.map(
79+
option =>
80+
new Option(
81+
this.dataStoreService.generateId(),
82+
option.code,
83+
option.label,
84+
option.index
85+
)
86+
),
87+
multipleChoice.hasOtherOption
88+
);
8889
}
8990

9091
addOrUpdateTasks(

0 commit comments

Comments
 (0)