From 79b0d95f00fd05fb7e4fb708689512887fbeed71 Mon Sep 17 00:00:00 2001 From: Roberto Fontanarosa Date: Tue, 7 Jul 2026 11:09:18 +0200 Subject: [PATCH 1/5] add other_selected to MultipleChoiceSelection --- proto/src/ground/v1beta1/job.proto | 6 ++++++ 1 file changed, 6 insertions(+) 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; } } From 3a693148f0000a2916b9702bb504b4fb5558e73d Mon Sep 17 00:00:00 2001 From: Roberto Fontanarosa Date: Tue, 7 Jul 2026 11:20:15 +0200 Subject: [PATCH 2/5] update model and converters --- web/src/app/converters/proto-model-converter.ts | 1 + web/src/app/converters/survey-data-converter.ts | 3 ++- web/src/app/models/task/task-condition.model.ts | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) 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..8deb8bc81 100644 --- a/web/src/app/models/task/task-condition.model.ts +++ b/web/src/app/models/task/task-condition.model.ts @@ -28,7 +28,8 @@ export class TaskConditionExpression { constructor( readonly expressionType: TaskConditionExpressionType, readonly taskId: string, - readonly optionIds: List + readonly optionIds: List, + readonly otherSelected: boolean = false ) {} } From 6bd1375bfa343ea4bafb60dc53c70111e61afb9e Mon Sep 17 00:00:00 2001 From: Roberto Fontanarosa Date: Tue, 7 Jul 2026 11:22:29 +0200 Subject: [PATCH 3/5] preserve otherSelected when remapping and duplicating --- web/src/app/services/job/job.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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); From 5052546030f8089c9125a5d69ee6bc0f4657a063 Mon Sep 17 00:00:00 2001 From: Roberto Fontanarosa Date: Tue, 7 Jul 2026 11:24:25 +0200 Subject: [PATCH 4/5] add "Other" as a selectable condition answer --- .../task-condition-form.component.html | 7 +++++++ .../task-condition-form.component.ts | 9 +++++++++ .../task-editor/task-editor.component.ts | 18 +++++++++++++----- .../app/models/task/task-condition.model.ts | 2 ++ 4 files changed, 31 insertions(+), 5 deletions(-) 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/models/task/task-condition.model.ts b/web/src/app/models/task/task-condition.model.ts index 8deb8bc81..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', } From 2addde16283bf7bd0215d1f1ff7efff1bdb38102 Mon Sep 17 00:00:00 2001 From: Roberto Fontanarosa Date: Tue, 7 Jul 2026 11:25:03 +0200 Subject: [PATCH 5/5] update test and language file --- web/src/app/services/job/job.service.spec.ts | 69 ++++++++++++++++++++ web/src/locale/messages.json | 1 + 2 files changed, 70 insertions(+) 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/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",