diff --git a/web/src/app/components/shared/data-visibility-control/data-visibility-control.component.html b/web/src/app/components/shared/data-visibility-control/data-visibility-control.component.html
index 7f239ac27..4125e2479 100644
--- a/web/src/app/components/shared/data-visibility-control/data-visibility-control.component.html
+++ b/web/src/app/components/shared/data-visibility-control/data-visibility-control.component.html
@@ -17,7 +17,7 @@
diff --git a/web/src/app/components/shared/data-visibility-control/data-visibility-control.component.spec.ts b/web/src/app/components/shared/data-visibility-control/data-visibility-control.component.spec.ts
new file mode 100644
index 000000000..14dd9a58e
--- /dev/null
+++ b/web/src/app/components/shared/data-visibility-control/data-visibility-control.component.spec.ts
@@ -0,0 +1,129 @@
+/**
+ * Copyright 2026 The Ground Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the 'License');
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an 'AS IS' BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import { MatSlideToggleChange, MatSlideToggleModule } from '@angular/material/slide-toggle';
+import { Map } from 'immutable';
+
+import { DataSharingType, Survey, SurveyDataVisibility } from 'app/models/survey.model';
+import { AuthService } from 'app/services/auth/auth.service';
+import { DraftSurveyService } from 'app/services/draft-survey/draft-survey.service';
+
+import { DataVisibilityControlComponent } from './data-visibility-control.component';
+
+describe('DataVisibilityControlComponent', () => {
+ let component: DataVisibilityControlComponent;
+ let fixture: ComponentFixture;
+
+ let draftSurveyService: jasmine.SpyObj;
+
+ const mockSurveyBase = new Survey(
+ 'survey1',
+ 'Test Survey',
+ 'A test survey',
+ Map(),
+ Map(),
+ 'owner1',
+ { type: DataSharingType.PRIVATE }
+ );
+
+ beforeEach(async () => {
+ draftSurveyService = jasmine.createSpyObj('DraftSurveyService', [
+ 'updateDataVisibility',
+ ]);
+
+ await TestBed.configureTestingModule({
+ declarations: [DataVisibilityControlComponent],
+ imports: [MatSlideToggleModule],
+ providers: [
+ { provide: AuthService, useValue: {} },
+ { provide: DraftSurveyService, useValue: draftSurveyService },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(DataVisibilityControlComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('defaults to CONTRIBUTOR_AND_ORGANIZERS when survey has no dataVisibility', () => {
+ fixture.componentRef.setInput('survey', mockSurveyBase);
+ fixture.detectChanges();
+
+ expect(component.selectedDataVisibility).toBe(
+ SurveyDataVisibility.CONTRIBUTOR_AND_ORGANIZERS
+ );
+ });
+
+ it('reflects ALL_SURVEY_PARTICIPANTS when survey has that visibility set', () => {
+ const survey = mockSurveyBase.copyWith({
+ dataVisibility: SurveyDataVisibility.ALL_SURVEY_PARTICIPANTS,
+ });
+ fixture.componentRef.setInput('survey', survey);
+ fixture.detectChanges();
+
+ expect(component.selectedDataVisibility).toBe(
+ SurveyDataVisibility.ALL_SURVEY_PARTICIPANTS
+ );
+ });
+
+ it('reflects CONTRIBUTOR_AND_ORGANIZERS when survey has that visibility set', () => {
+ const survey = mockSurveyBase.copyWith({
+ dataVisibility: SurveyDataVisibility.CONTRIBUTOR_AND_ORGANIZERS,
+ });
+ fixture.componentRef.setInput('survey', survey);
+ fixture.detectChanges();
+
+ expect(component.selectedDataVisibility).toBe(
+ SurveyDataVisibility.CONTRIBUTOR_AND_ORGANIZERS
+ );
+ });
+
+ it('updates to ALL_SURVEY_PARTICIPANTS and calls service when toggle is turned on', () => {
+ fixture.componentRef.setInput('survey', mockSurveyBase);
+ fixture.detectChanges();
+
+ component.onDataVisibilityChange({ checked: true } as MatSlideToggleChange);
+
+ expect(component.selectedDataVisibility).toBe(
+ SurveyDataVisibility.ALL_SURVEY_PARTICIPANTS
+ );
+ expect(draftSurveyService.updateDataVisibility).toHaveBeenCalledWith(
+ SurveyDataVisibility.ALL_SURVEY_PARTICIPANTS
+ );
+ });
+
+ it('updates to CONTRIBUTOR_AND_ORGANIZERS and calls service when toggle is turned off', () => {
+ const survey = mockSurveyBase.copyWith({
+ dataVisibility: SurveyDataVisibility.ALL_SURVEY_PARTICIPANTS,
+ });
+ fixture.componentRef.setInput('survey', survey);
+ fixture.detectChanges();
+
+ component.onDataVisibilityChange({ checked: false } as MatSlideToggleChange);
+
+ expect(component.selectedDataVisibility).toBe(
+ SurveyDataVisibility.CONTRIBUTOR_AND_ORGANIZERS
+ );
+ expect(draftSurveyService.updateDataVisibility).toHaveBeenCalledWith(
+ SurveyDataVisibility.CONTRIBUTOR_AND_ORGANIZERS
+ );
+ });
+});
diff --git a/web/src/app/components/shared/data-visibility-control/data-visibility-control.component.ts b/web/src/app/components/shared/data-visibility-control/data-visibility-control.component.ts
index c48425f3d..b66a5c9e6 100644
--- a/web/src/app/components/shared/data-visibility-control/data-visibility-control.component.ts
+++ b/web/src/app/components/shared/data-visibility-control/data-visibility-control.component.ts
@@ -47,7 +47,7 @@ export class DataVisibilityControlComponent {
});
}
- changeDataVisibility(event: MatSlideToggleChange) {
+ onDataVisibilityChange(event: MatSlideToggleChange) {
const dataVisibility = event.checked
? SurveyDataVisibility.ALL_SURVEY_PARTICIPANTS
: SurveyDataVisibility.CONTRIBUTOR_AND_ORGANIZERS;
diff --git a/web/src/app/components/shared/job-integration-control/job-integration-control.component.html b/web/src/app/components/shared/job-integration-control/job-integration-control.component.html
new file mode 100644
index 000000000..00b9a6355
--- /dev/null
+++ b/web/src/app/components/shared/job-integration-control/job-integration-control.component.html
@@ -0,0 +1,22 @@
+
+
+
+
+
diff --git a/web/src/app/components/shared/job-integration-control/job-integration-control.component.spec.ts b/web/src/app/components/shared/job-integration-control/job-integration-control.component.spec.ts
new file mode 100644
index 000000000..ddbebe83b
--- /dev/null
+++ b/web/src/app/components/shared/job-integration-control/job-integration-control.component.spec.ts
@@ -0,0 +1,115 @@
+/**
+ * Copyright 2026 The Ground Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the 'License');
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an 'AS IS' BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { ComponentFixture, TestBed } from '@angular/core/testing';
+import {
+ MatSlideToggleChange,
+ MatSlideToggleModule,
+} from '@angular/material/slide-toggle';
+import { Map } from 'immutable';
+
+import { Job } from 'app/models/job.model';
+import { AuthService } from 'app/services/auth/auth.service';
+import { DraftSurveyService } from 'app/services/draft-survey/draft-survey.service';
+
+import { JobIntegrationControlComponent } from './job-integration-control.component';
+
+describe('JobIntegrationControlComponent', () => {
+ let component: JobIntegrationControlComponent;
+ let fixture: ComponentFixture;
+
+ let draftSurveyService: jasmine.SpyObj;
+
+ const integrationId = 'integration1';
+
+ const mockJobBase = new Job('job1', 0, '#000', 'Test Job');
+
+ beforeEach(async () => {
+ draftSurveyService = jasmine.createSpyObj('DraftSurveyService', [
+ 'addOrUpdateJob',
+ ]);
+
+ await TestBed.configureTestingModule({
+ declarations: [JobIntegrationControlComponent],
+ imports: [MatSlideToggleModule],
+ providers: [
+ { provide: AuthService, useValue: {} },
+ { provide: DraftSurveyService, useValue: draftSurveyService },
+ ],
+ }).compileComponents();
+
+ fixture = TestBed.createComponent(JobIntegrationControlComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('reflects false when job has no enabled integrations', () => {
+ fixture.componentRef.setInput('surveyId', 'survey1');
+ fixture.componentRef.setInput('integrationId', integrationId);
+ fixture.componentRef.setInput('job', mockJobBase);
+ fixture.detectChanges();
+
+ expect(component.integrationEnabled).toBeFalse();
+ });
+
+ it('reflects true when job has the integration enabled', () => {
+ const job = mockJobBase.copyWith({
+ enabledIntegrations: Map([[integrationId, { id: integrationId }]]),
+ });
+ fixture.componentRef.setInput('surveyId', 'survey1');
+ fixture.componentRef.setInput('integrationId', integrationId);
+ fixture.componentRef.setInput('job', job);
+ fixture.detectChanges();
+
+ expect(component.integrationEnabled).toBeTrue();
+ });
+
+ it('adds integration and calls service when toggle is turned on', () => {
+ fixture.componentRef.setInput('surveyId', 'survey1');
+ fixture.componentRef.setInput('integrationId', integrationId);
+ fixture.componentRef.setInput('job', mockJobBase);
+ fixture.detectChanges();
+
+ component.onIntegrationToggle({ checked: true } as MatSlideToggleChange);
+
+ const updatedJob: Job =
+ draftSurveyService.addOrUpdateJob.calls.mostRecent().args[0];
+ expect(updatedJob.enabledIntegrations.has(integrationId)).toBeTrue();
+ expect(updatedJob.enabledIntegrations.get(integrationId)).toEqual({
+ id: integrationId,
+ });
+ });
+
+ it('removes integration and calls service when toggle is turned off', () => {
+ const job = mockJobBase.copyWith({
+ enabledIntegrations: Map([[integrationId, { id: integrationId }]]),
+ });
+ fixture.componentRef.setInput('surveyId', 'survey1');
+ fixture.componentRef.setInput('integrationId', integrationId);
+ fixture.componentRef.setInput('job', job);
+ fixture.detectChanges();
+
+ component.onIntegrationToggle({ checked: false } as MatSlideToggleChange);
+
+ const updatedJob: Job =
+ draftSurveyService.addOrUpdateJob.calls.mostRecent().args[0];
+ expect(updatedJob.enabledIntegrations.has(integrationId)).toBeFalse();
+ });
+});
diff --git a/web/src/app/components/shared/job-integration-control/job-integration-control.component.ts b/web/src/app/components/shared/job-integration-control/job-integration-control.component.ts
new file mode 100644
index 000000000..b362925a1
--- /dev/null
+++ b/web/src/app/components/shared/job-integration-control/job-integration-control.component.ts
@@ -0,0 +1,65 @@
+/**
+ * Copyright 2026 The Ground Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the 'License');
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an 'AS IS' BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Component, effect, input } from '@angular/core';
+import { MatSlideToggleChange } from '@angular/material/slide-toggle';
+import { Integration, Job } from 'app/models/job.model';
+
+import { AuthService } from 'app/services/auth/auth.service';
+import { DraftSurveyService } from 'app/services/draft-survey/draft-survey.service';
+
+@Component({
+ selector: 'ground-job-integration-control',
+ templateUrl: './job-integration-control.component.html',
+ standalone: false,
+})
+export class JobIntegrationControlComponent {
+ surveyId = input();
+ integrationId = input();
+ job = input();
+
+ integrationEnabled!: boolean;
+
+ constructor(
+ readonly authService: AuthService,
+ readonly draftSurveyService: DraftSurveyService
+ ) {
+ effect(() => {
+ const surveyId = this.surveyId();
+ const integrationId = this.integrationId();
+ const job = this.job();
+ if (surveyId && integrationId && job) {
+ this.integrationEnabled = job.enabledIntegrations.has(integrationId) || false;
+ }
+ });
+ }
+
+ onIntegrationToggle(event: MatSlideToggleChange) {
+ const integrationId = this.integrationId();
+ const job = this.job();
+ if (integrationId && job) {
+ const updatedIntegrations = event.checked
+ ? job.enabledIntegrations.set(integrationId, {
+ id: integrationId,
+ } as Integration)
+ : job.enabledIntegrations.delete(integrationId);
+
+ this.draftSurveyService.addOrUpdateJob(
+ job.copyWith({ enabledIntegrations: updatedIntegrations })
+ );
+ }
+ }
+}
diff --git a/web/src/app/components/shared/job-integration-control/job-integration-control.module.ts b/web/src/app/components/shared/job-integration-control/job-integration-control.module.ts
new file mode 100644
index 000000000..3b0a662b0
--- /dev/null
+++ b/web/src/app/components/shared/job-integration-control/job-integration-control.module.ts
@@ -0,0 +1,28 @@
+/**
+ * Copyright 2026 The Ground Authors.
+ *
+ * Licensed under the Apache License, Version 2.0 (the 'License');
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an 'AS IS' BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { CommonModule } from '@angular/common';
+import { NgModule } from '@angular/core';
+import { MatSlideToggleModule } from '@angular/material/slide-toggle';
+
+import { JobIntegrationControlComponent } from './job-integration-control.component';
+
+@NgModule({
+ declarations: [JobIntegrationControlComponent],
+ imports: [CommonModule, MatSlideToggleModule],
+ exports: [JobIntegrationControlComponent],
+})
+export class JobIntegrationControlModule {}