Skip to content

Commit 259e72a

Browse files
authored
[Survey editor] Allow tasks to be conditioned on "Other" response (#2557)
1 parent c972c26 commit 259e72a

10 files changed

Lines changed: 114 additions & 8 deletions

File tree

proto/src/ground/v1beta1/job.proto

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -282,5 +282,11 @@ message Task {
282282
// Required. The system-defined unique identifier of the task to which this
283283
// MultipleChoiceSelection refers.
284284
string task_id = 2;
285+
286+
// When true, the condition passes if the data collector selected the
287+
// "Other (specify)" option on the referenced task. May be combined with
288+
// `option_ids`; the condition passes if any listed option is selected or,
289+
// when this is true, if "Other" is selected.
290+
bool other_selected = 3;
285291
}
286292
}

web/src/app/components/shared/task-editor/task-condition-form/task-condition-form.component.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,13 @@
4848
<mat-option *ngFor="let option of previousMultipleTasks | getTaskOptions:taskIdControl.value" [value]="option.id">
4949
{{option.label}}
5050
</mat-option>
51+
<mat-option
52+
*ngIf="selectedTaskHasOtherOption"
53+
[value]="OTHER_OPTION_ID"
54+
i18n="@@app.taskEditor.condition.otherOption"
55+
>
56+
Other
57+
</mat-option>
5158
</mat-select>
5259

5360
<mat-error *ngIf="optionIdsControl.touched && optionIdsControl.invalid">

web/src/app/components/shared/task-editor/task-condition-form/task-condition-form.component.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import { AbstractControl, FormArray, FormGroup } from '@angular/forms';
2525
import { List } from 'immutable';
2626

2727
import { Option } from 'app/models/task/option.model';
28+
import { OTHER_OPTION_ID } from 'app/models/task/task-condition.model';
2829
import { Task } from 'app/models/task/task.model';
2930

3031
@Pipe({
@@ -52,6 +53,8 @@ export class TaskConditionFormComponent {
5253
@Input() formGroupIndex!: number;
5354
@Input() tasks!: List<Task>;
5455

56+
readonly OTHER_OPTION_ID = OTHER_OPTION_ID;
57+
5558
previousMultipleTasks: List<Task> = List([]);
5659
options: List<Task> = List([]);
5760

@@ -86,4 +89,10 @@ export class TaskConditionFormComponent {
8689
get optionIdsControl(): AbstractControl {
8790
return this.expressionControl.get('optionIds')!;
8891
}
92+
93+
get selectedTaskHasOtherOption(): boolean {
94+
return !!this.previousMultipleTasks.find(
95+
task => task.id === this.taskIdControl.value
96+
)?.multipleChoice?.hasOtherOption;
97+
}
8998
}

web/src/app/components/shared/task-editor/task-editor.component.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
} from 'app/models/task/multiple-choice.model';
4040
import { Option } from 'app/models/task/option.model';
4141
import {
42+
OTHER_OPTION_ID,
4243
TaskCondition,
4344
TaskConditionExpression,
4445
TaskConditionExpressionType,
@@ -255,7 +256,10 @@ export class TaskEditorComponent {
255256
expressionType: expression.expressionType,
256257
taskId: [expression.taskId, Validators.required],
257258
optionIds: [
258-
expression.optionIds.toArray(),
259+
[
260+
...expression.optionIds.toArray(),
261+
...(expression.otherSelected ? [OTHER_OPTION_ID] : []),
262+
],
259263
Validators.required,
260264
],
261265
})
@@ -306,15 +310,19 @@ export class TaskEditorComponent {
306310
matchType: condition.get('matchType')?.value,
307311
expressions: List(
308312
(condition.get('expressions') as FormArray).controls.map(
309-
(expression: AbstractControl) =>
310-
({
313+
(expression: AbstractControl) => {
314+
const selectedIds = (expression.get('optionIds')?.value ||
315+
[]) as string[];
316+
return {
311317
expressionType: expression.get('expressionType')
312318
?.value as TaskConditionExpressionType,
313319
taskId: expression.get('taskId')?.value as string,
314320
optionIds: List(
315-
expression.get('optionIds')?.value as string[]
321+
selectedIds.filter(id => id !== OTHER_OPTION_ID)
316322
),
317-
}) as TaskConditionExpression
323+
otherSelected: selectedIds.includes(OTHER_OPTION_ID),
324+
} as TaskConditionExpression;
325+
}
318326
)
319327
),
320328
} as TaskCondition)

web/src/app/converters/proto-model-converter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -265,6 +265,7 @@ function toTaskConditionMessage(
265265
multipleChoice: new Pb.Task.MultipleChoiceSelection({
266266
optionIds: expression.optionIds.toArray(),
267267
taskId: expression.taskId,
268+
otherSelected: expression.otherSelected,
268269
}),
269270
})
270271
)

web/src/app/converters/survey-data-converter.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,8 @@ function taskConditionPbToModel(pb: Pb.ITask): TaskCondition | undefined {
177177
new TaskConditionExpression(
178178
TaskConditionExpressionType.ONE_OF_SELECTED,
179179
multipleChoice!.taskId!,
180-
List(multipleChoice!.optionIds!)
180+
List(multipleChoice!.optionIds!),
181+
multipleChoice!.otherSelected ?? false
181182
),
182183
])
183184
);

web/src/app/models/task/task-condition.model.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
import { List } from 'immutable';
1818

19+
export const OTHER_OPTION_ID = '__other__';
20+
1921
export enum TaskConditionMatchType {
2022
MATCH_ALL = 'MATCH_ALL',
2123
}
@@ -28,7 +30,8 @@ export class TaskConditionExpression {
2830
constructor(
2931
readonly expressionType: TaskConditionExpressionType,
3032
readonly taskId: string,
31-
readonly optionIds: List<string>
33+
readonly optionIds: List<string>,
34+
readonly otherSelected: boolean = false
3235
) {}
3336
}
3437

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

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,75 @@ describe('JobService', () => {
191191
expect(expression?.optionIds.toArray()).toEqual(['newOpt1']);
192192
});
193193

194+
it('should preserve the otherSelected flag when remapping conditions', () => {
195+
const sourceTask = new Task(
196+
'src',
197+
TaskType.MULTIPLE_CHOICE,
198+
'Source',
199+
false,
200+
0,
201+
new MultipleChoice(Cardinality.SELECT_ONE, List([]), true)
202+
);
203+
const dependentTask = new Task(
204+
'dep',
205+
TaskType.TEXT,
206+
'Dependent',
207+
false,
208+
1,
209+
undefined,
210+
new TaskCondition(
211+
TaskConditionMatchType.MATCH_ALL,
212+
List([
213+
new TaskConditionExpression(
214+
TaskConditionExpressionType.ONE_OF_SELECTED,
215+
'src',
216+
List([]),
217+
/* otherSelected= */ true
218+
),
219+
])
220+
)
221+
);
222+
const job = new Job(
223+
'job1',
224+
0,
225+
'#000',
226+
'Job 1',
227+
Map({ src: sourceTask, dep: dependentTask }),
228+
DataCollectionStrategy.MIXED
229+
);
230+
231+
const newSourceTask = new Task(
232+
'newSrc',
233+
TaskType.MULTIPLE_CHOICE,
234+
'Source',
235+
false,
236+
0,
237+
new MultipleChoice(Cardinality.SELECT_ONE, List([]), true)
238+
);
239+
const newDependentTask = new Task(
240+
'newDep',
241+
TaskType.TEXT,
242+
'Dependent',
243+
false,
244+
1,
245+
undefined,
246+
dependentTask.condition
247+
);
248+
dataStoreServiceSpy.generateId.and.returnValue('job2');
249+
taskServiceSpy.duplicateTask.and.returnValues(
250+
newSourceTask,
251+
newDependentTask
252+
);
253+
254+
const newJob = service.duplicateJob(job, '#FFF');
255+
256+
const expression = newJob.tasks
257+
?.get('newDep')
258+
?.condition?.expressions.first();
259+
expect(expression?.taskId).toBe('newSrc');
260+
expect(expression?.otherSelected).toBe(true);
261+
});
262+
194263
it('should drop condition expressions whose source task is missing', () => {
195264
const dependentTask = new Task(
196265
'dep',

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

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,7 +137,8 @@ export class JobService {
137137
return new TaskConditionExpression(
138138
expr.expressionType,
139139
newTaskId,
140-
newOptionIds
140+
newOptionIds,
141+
expr.otherSelected
141142
);
142143
})
143144
.filter((expr): expr is TaskConditionExpression => !!expr);

web/src/locale/messages.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@
149149
"app.controls.errors.requiredQuestion": "A question must be selected as a condition",
150150
"app.labels.is": "is",
151151
"app.taskEditor.condition.selectAnswer": "Select answer",
152+
"app.taskEditor.condition.otherOption": "Other",
152153
"app.controls.errors.requiredAnswer": "At least one answer must be selected",
153154
"app.texts.whenAdding": "When adding a new collection site...",
154155
"app.labels.addATask": "Add a task",

0 commit comments

Comments
 (0)