Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions proto/src/ground/v1beta1/job.proto
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,13 @@
<mat-option *ngFor="let option of previousMultipleTasks | getTaskOptions:taskIdControl.value" [value]="option.id">
{{option.label}}
</mat-option>
<mat-option
*ngIf="selectedTaskHasOtherOption"
[value]="OTHER_OPTION_ID"
i18n="@@app.taskEditor.condition.otherOption"
>
Other
</mat-option>
</mat-select>

<mat-error *ngIf="optionIdsControl.touched && optionIdsControl.invalid">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -52,6 +53,8 @@ export class TaskConditionFormComponent {
@Input() formGroupIndex!: number;
@Input() tasks!: List<Task>;

readonly OTHER_OPTION_ID = OTHER_OPTION_ID;

previousMultipleTasks: List<Task> = List([]);
options: List<Task> = List([]);

Expand Down Expand Up @@ -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;
}
}
18 changes: 13 additions & 5 deletions web/src/app/components/shared/task-editor/task-editor.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
],
})
Expand Down Expand Up @@ -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)
Expand Down
1 change: 1 addition & 0 deletions web/src/app/converters/proto-model-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,6 +265,7 @@ function toTaskConditionMessage(
multipleChoice: new Pb.Task.MultipleChoiceSelection({
optionIds: expression.optionIds.toArray(),
taskId: expression.taskId,
otherSelected: expression.otherSelected,
}),
})
)
Expand Down
3 changes: 2 additions & 1 deletion web/src/app/converters/survey-data-converter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
),
])
);
Expand Down
5 changes: 4 additions & 1 deletion web/src/app/models/task/task-condition.model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@

import { List } from 'immutable';

export const OTHER_OPTION_ID = '__other__';

export enum TaskConditionMatchType {
MATCH_ALL = 'MATCH_ALL',
}
Expand All @@ -28,7 +30,8 @@ export class TaskConditionExpression {
constructor(
readonly expressionType: TaskConditionExpressionType,
readonly taskId: string,
readonly optionIds: List<string>
readonly optionIds: List<string>,
readonly otherSelected: boolean = false
) {}
}

Expand Down
69 changes: 69 additions & 0 deletions web/src/app/services/job/job.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 2 additions & 1 deletion web/src/app/services/job/job.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,8 @@ export class JobService {
return new TaskConditionExpression(
expr.expressionType,
newTaskId,
newOptionIds
newOptionIds,
expr.otherSelected
);
})
.filter((expr): expr is TaskConditionExpression => !!expr);
Expand Down
1 change: 1 addition & 0 deletions web/src/locale/messages.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading