diff --git a/proto/src/ground/v1beta1/job.proto b/proto/src/ground/v1beta1/job.proto
index 9a1f04b75..0e17d7c94 100644
--- a/proto/src/ground/v1beta1/job.proto
+++ b/proto/src/ground/v1beta1/job.proto
@@ -282,5 +282,11 @@ message Task {
// Required. The system-defined unique identifier of the task to which this
// MultipleChoiceSelection refers.
string task_id = 2;
+
+ // When true, the condition passes if the data collector selected the
+ // "Other (specify)" option on the referenced task. May be combined with
+ // `option_ids`; the condition passes if any listed option is selected or,
+ // when this is true, if "Other" is selected.
+ bool other_selected = 3;
}
}
diff --git a/web/src/app/components/shared/task-editor/task-condition-form/task-condition-form.component.html b/web/src/app/components/shared/task-editor/task-condition-form/task-condition-form.component.html
index 01995c53e..1e3e3ff10 100644
--- a/web/src/app/components/shared/task-editor/task-condition-form/task-condition-form.component.html
+++ b/web/src/app/components/shared/task-editor/task-condition-form/task-condition-form.component.html
@@ -48,6 +48,13 @@
{{option.label}}
+
+ Other
+
diff --git a/web/src/app/components/shared/task-editor/task-condition-form/task-condition-form.component.ts b/web/src/app/components/shared/task-editor/task-condition-form/task-condition-form.component.ts
index a198d02ad..ce24dcae3 100644
--- a/web/src/app/components/shared/task-editor/task-condition-form/task-condition-form.component.ts
+++ b/web/src/app/components/shared/task-editor/task-condition-form/task-condition-form.component.ts
@@ -25,6 +25,7 @@ import { AbstractControl, FormArray, FormGroup } from '@angular/forms';
import { List } from 'immutable';
import { Option } from 'app/models/task/option.model';
+import { OTHER_OPTION_ID } from 'app/models/task/task-condition.model';
import { Task } from 'app/models/task/task.model';
@Pipe({
@@ -52,6 +53,8 @@ export class TaskConditionFormComponent {
@Input() formGroupIndex!: number;
@Input() tasks!: List;
+ readonly OTHER_OPTION_ID = OTHER_OPTION_ID;
+
previousMultipleTasks: List = List([]);
options: List = List([]);
@@ -86,4 +89,10 @@ export class TaskConditionFormComponent {
get optionIdsControl(): AbstractControl {
return this.expressionControl.get('optionIds')!;
}
+
+ get selectedTaskHasOtherOption(): boolean {
+ return !!this.previousMultipleTasks.find(
+ task => task.id === this.taskIdControl.value
+ )?.multipleChoice?.hasOtherOption;
+ }
}
diff --git a/web/src/app/components/shared/task-editor/task-editor.component.ts b/web/src/app/components/shared/task-editor/task-editor.component.ts
index 9c1001a77..bbef83da1 100644
--- a/web/src/app/components/shared/task-editor/task-editor.component.ts
+++ b/web/src/app/components/shared/task-editor/task-editor.component.ts
@@ -39,6 +39,7 @@ import {
} from 'app/models/task/multiple-choice.model';
import { Option } from 'app/models/task/option.model';
import {
+ OTHER_OPTION_ID,
TaskCondition,
TaskConditionExpression,
TaskConditionExpressionType,
@@ -255,7 +256,10 @@ export class TaskEditorComponent {
expressionType: expression.expressionType,
taskId: [expression.taskId, Validators.required],
optionIds: [
- expression.optionIds.toArray(),
+ [
+ ...expression.optionIds.toArray(),
+ ...(expression.otherSelected ? [OTHER_OPTION_ID] : []),
+ ],
Validators.required,
],
})
@@ -306,15 +310,19 @@ export class TaskEditorComponent {
matchType: condition.get('matchType')?.value,
expressions: List(
(condition.get('expressions') as FormArray).controls.map(
- (expression: AbstractControl) =>
- ({
+ (expression: AbstractControl) => {
+ const selectedIds = (expression.get('optionIds')?.value ||
+ []) as string[];
+ return {
expressionType: expression.get('expressionType')
?.value as TaskConditionExpressionType,
taskId: expression.get('taskId')?.value as string,
optionIds: List(
- expression.get('optionIds')?.value as string[]
+ selectedIds.filter(id => id !== OTHER_OPTION_ID)
),
- }) as TaskConditionExpression
+ otherSelected: selectedIds.includes(OTHER_OPTION_ID),
+ } as TaskConditionExpression;
+ }
)
),
} as TaskCondition)
diff --git a/web/src/app/converters/proto-model-converter.ts b/web/src/app/converters/proto-model-converter.ts
index 974776eab..8b8f1a7a8 100644
--- a/web/src/app/converters/proto-model-converter.ts
+++ b/web/src/app/converters/proto-model-converter.ts
@@ -265,6 +265,7 @@ function toTaskConditionMessage(
multipleChoice: new Pb.Task.MultipleChoiceSelection({
optionIds: expression.optionIds.toArray(),
taskId: expression.taskId,
+ otherSelected: expression.otherSelected,
}),
})
)
diff --git a/web/src/app/converters/survey-data-converter.ts b/web/src/app/converters/survey-data-converter.ts
index 16d6d57d3..c45cc1055 100644
--- a/web/src/app/converters/survey-data-converter.ts
+++ b/web/src/app/converters/survey-data-converter.ts
@@ -177,7 +177,8 @@ function taskConditionPbToModel(pb: Pb.ITask): TaskCondition | undefined {
new TaskConditionExpression(
TaskConditionExpressionType.ONE_OF_SELECTED,
multipleChoice!.taskId!,
- List(multipleChoice!.optionIds!)
+ List(multipleChoice!.optionIds!),
+ multipleChoice!.otherSelected ?? false
),
])
);
diff --git a/web/src/app/models/task/task-condition.model.ts b/web/src/app/models/task/task-condition.model.ts
index 2ebf79d51..1c41b58e3 100644
--- a/web/src/app/models/task/task-condition.model.ts
+++ b/web/src/app/models/task/task-condition.model.ts
@@ -16,6 +16,8 @@
import { List } from 'immutable';
+export const OTHER_OPTION_ID = '__other__';
+
export enum TaskConditionMatchType {
MATCH_ALL = 'MATCH_ALL',
}
@@ -28,7 +30,8 @@ export class TaskConditionExpression {
constructor(
readonly expressionType: TaskConditionExpressionType,
readonly taskId: string,
- readonly optionIds: List
+ readonly optionIds: List,
+ readonly otherSelected: boolean = false
) {}
}
diff --git a/web/src/app/services/job/job.service.spec.ts b/web/src/app/services/job/job.service.spec.ts
index fd32679d9..b9496880a 100644
--- a/web/src/app/services/job/job.service.spec.ts
+++ b/web/src/app/services/job/job.service.spec.ts
@@ -191,6 +191,75 @@ describe('JobService', () => {
expect(expression?.optionIds.toArray()).toEqual(['newOpt1']);
});
+ it('should preserve the otherSelected flag when remapping conditions', () => {
+ const sourceTask = new Task(
+ 'src',
+ TaskType.MULTIPLE_CHOICE,
+ 'Source',
+ false,
+ 0,
+ new MultipleChoice(Cardinality.SELECT_ONE, List([]), true)
+ );
+ const dependentTask = new Task(
+ 'dep',
+ TaskType.TEXT,
+ 'Dependent',
+ false,
+ 1,
+ undefined,
+ new TaskCondition(
+ TaskConditionMatchType.MATCH_ALL,
+ List([
+ new TaskConditionExpression(
+ TaskConditionExpressionType.ONE_OF_SELECTED,
+ 'src',
+ List([]),
+ /* otherSelected= */ true
+ ),
+ ])
+ )
+ );
+ const job = new Job(
+ 'job1',
+ 0,
+ '#000',
+ 'Job 1',
+ Map({ src: sourceTask, dep: dependentTask }),
+ DataCollectionStrategy.MIXED
+ );
+
+ const newSourceTask = new Task(
+ 'newSrc',
+ TaskType.MULTIPLE_CHOICE,
+ 'Source',
+ false,
+ 0,
+ new MultipleChoice(Cardinality.SELECT_ONE, List([]), true)
+ );
+ const newDependentTask = new Task(
+ 'newDep',
+ TaskType.TEXT,
+ 'Dependent',
+ false,
+ 1,
+ undefined,
+ dependentTask.condition
+ );
+ dataStoreServiceSpy.generateId.and.returnValue('job2');
+ taskServiceSpy.duplicateTask.and.returnValues(
+ newSourceTask,
+ newDependentTask
+ );
+
+ const newJob = service.duplicateJob(job, '#FFF');
+
+ const expression = newJob.tasks
+ ?.get('newDep')
+ ?.condition?.expressions.first();
+ expect(expression?.taskId).toBe('newSrc');
+ expect(expression?.otherSelected).toBe(true);
+ });
+
it('should drop condition expressions whose source task is missing', () => {
const dependentTask = new Task(
'dep',
diff --git a/web/src/app/services/job/job.service.ts b/web/src/app/services/job/job.service.ts
index c51b12aac..53a80c636 100644
--- a/web/src/app/services/job/job.service.ts
+++ b/web/src/app/services/job/job.service.ts
@@ -137,7 +137,8 @@ export class JobService {
return new TaskConditionExpression(
expr.expressionType,
newTaskId,
- newOptionIds
+ newOptionIds,
+ expr.otherSelected
);
})
.filter((expr): expr is TaskConditionExpression => !!expr);
diff --git a/web/src/locale/messages.json b/web/src/locale/messages.json
index b1f4d1cb8..152b15e50 100644
--- a/web/src/locale/messages.json
+++ b/web/src/locale/messages.json
@@ -149,6 +149,7 @@
"app.controls.errors.requiredQuestion": "A question must be selected as a condition",
"app.labels.is": "is",
"app.taskEditor.condition.selectAnswer": "Select answer",
+ "app.taskEditor.condition.otherOption": "Other",
"app.controls.errors.requiredAnswer": "At least one answer must be selected",
"app.texts.whenAdding": "When adding a new collection site...",
"app.labels.addATask": "Add a task",