Skip to content

Commit f0e9f72

Browse files
authored
159 rerouting behavior (#160)
* feat: added JSON editor in study edit and initial reroute logic * feat: finished redirect on sign up
1 parent a051599 commit f0e9f72

File tree

11 files changed

+179
-40
lines changed

11 files changed

+179
-40
lines changed

src/app/models/Study.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,16 @@
1-
import { SharplabTaskConfig } from '../pages/tasks/task-playables/task-player/task-player.component';
1+
import { InfoDisplayViewerMetadata } from '../pages/shared/info-display-viewer/info-display-viewer.component';
22
import { NullTime } from './InternalDTOs';
33
import { StudyTask } from './StudyTask';
44
import { Task } from './Task';
55
import { User } from './User';
6-
import { Platform } from './enums';
6+
7+
export class ReroutingConfig {
8+
mustCompleteOneOf: {
9+
studyId: number;
10+
currentTaskIndex: number;
11+
}[]; // this will be logical OR – at least one of the studies in this list must have been completed
12+
rerouteTo: number;
13+
}
714

815
export class Study {
916
id: number;
@@ -16,9 +23,15 @@ export class Study {
1623
consent: Task;
1724
owner: Partial<User>;
1825
description: string;
19-
config: any; // json metadata
26+
/*
27+
* json metadata that describes the study, used for study background landing page
28+
* config would be of type InfoDisplayViewerMetadata & RoutingConfig
29+
*/
30+
config?: {
31+
rerouteConfig?: ReroutingConfig;
32+
} & Partial<InfoDisplayViewerMetadata>;
2033
studyTasks: StudyTask[];
2134
snapshots: {
22-
[key: string]: (Task & { taskOrder: number })[];
35+
[key: string]: (Task & { taskOrder: number })[]; // a copy of the json task metadata or questionnaire metadata at a given point in time
2336
};
2437
}

src/app/pages/admin/admin-dashboard/studies/create-modify-study/create-modify-study.component.html

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,13 @@ <h2 class="mb-3">{{ mode === 'CREATE' ? 'Create a new study' : 'Edit study' }}</
1010
<hr />
1111
<div>
1212
<div [formGroup]="studyForm">
13-
<mat-form-field appearance="fill" class="w-50 d-block">
13+
<mat-form-field appearance="fill" class="w-100 d-block">
1414
<mat-label>External Study Name</mat-label>
1515
<input required formControlName="externalName" matInput type="text" placeholder="My study" />
1616
<mat-error *ngIf="studyForm.get('externalName')?.hasError('required')">This is required</mat-error>
1717
<mat-error *ngIf="studyForm.get('externalName')?.hasError('maxlength')">Max 255 characters</mat-error>
1818
</mat-form-field>
19-
<mat-form-field appearance="fill" class="w-50 d-block">
19+
<mat-form-field appearance="fill" class="w-100 d-block">
2020
<mat-label>Internal Study Name</mat-label>
2121
<input
2222
formControlName="internalName"
@@ -28,12 +28,12 @@ <h2 class="mb-3">{{ mode === 'CREATE' ? 'Create a new study' : 'Edit study' }}</
2828
<mat-error *ngIf="studyForm.get('internalName')?.hasError('required')">This is required</mat-error>
2929
<mat-error *ngIf="studyForm.get('internalName')?.hasError('maxlength')">Max 255 characters</mat-error>
3030
</mat-form-field>
31-
<mat-form-field appearance="fill" class="w-50 d-block">
31+
<mat-form-field appearance="fill" class="w-100 d-block">
3232
<mat-label>Description (optional)</mat-label>
3333
<textarea rows="4" formControlName="description" matInput type="text"></textarea>
3434
<mat-error *ngIf="studyForm.get('description')?.hasError('maxlength')">Max 500 characters</mat-error>
3535
</mat-form-field>
36-
<mat-form-field appearance="fill" class="w-50">
36+
<mat-form-field appearance="fill" class="w-100">
3737
<mat-label>Consent For Study</mat-label>
3838
<mat-select required formControlName="consent" [compareWith]="compareConsentFn">
3939
<mat-option *ngFor="let consentForm of consentForms" [value]="consentForm">{{
@@ -43,6 +43,18 @@ <h2 class="mb-3">{{ mode === 'CREATE' ? 'Create a new study' : 'Edit study' }}</
4343
<mat-error *ngIf="studyForm.get('consent')?.hasError('required')">This is required</mat-error>
4444
</mat-form-field>
4545

46+
<div class="w-100">
47+
<mat-accordion *ngIf="studyConfig">
48+
<mat-expansion-panel>
49+
<mat-expansion-panel-header>
50+
<mat-panel-title>Advanced</mat-panel-title>
51+
<mat-panel-description>Set up rerouting and study background</mat-panel-description>
52+
</mat-expansion-panel-header>
53+
<app-json-editor [json]="studyConfig" (onChange)="handleUpdateJSON($event)"></app-json-editor>
54+
</mat-expansion-panel>
55+
</mat-accordion>
56+
</div>
57+
4658
<hr />
4759

4860
<mat-label class="d-block">Build Your Study</mat-label>
@@ -161,7 +173,7 @@ <h2 class="mb-3">{{ mode === 'CREATE' ? 'Create a new study' : 'Edit study' }}</
161173
</div>
162174
<div class="my-2">
163175
<button
164-
[disabled]="!studyForm.valid || selectedTasks.length < 1"
176+
[disabled]="!studyForm.valid || selectedTasks.length < 1 || jsonIsError"
165177
class="w-25"
166178
mat-raised-button
167179
color="primary"

src/app/pages/admin/admin-dashboard/studies/create-modify-study/create-modify-study.component.ts

Lines changed: 18 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,10 @@ import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop';
22
import { HttpErrorResponse } from '@angular/common/http';
33
import { Component, Inject, OnDestroy, OnInit, Optional } from '@angular/core';
44
import { UntypedFormBuilder, UntypedFormGroup, Validators } from '@angular/forms';
5-
import { MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog';
5+
import { MAT_DIALOG_DATA, MatDialogRef } from '@angular/material/dialog';
66
import { Router } from '@angular/router';
7-
import { Observable, Subscription } from 'rxjs';
8-
import { map, mergeMap, take } from 'rxjs/operators';
9-
import { AdminRouteNames, Platform, TaskType } from 'src/app/models/enums';
7+
import { Subscription } from 'rxjs';
8+
import { Platform, TaskType } from 'src/app/models/enums';
109
import { Study } from 'src/app/models/Study';
1110
import { Task } from 'src/app/models/Task';
1211
import { LoaderService } from 'src/app/services/loader/loader.service';
@@ -22,17 +21,16 @@ import { UserStateService } from 'src/app/services/user-state-service';
2221
})
2322
export class CreateModifyStudyComponent implements OnInit, OnDestroy {
2423
mode: 'EDIT' | 'CREATE' = 'CREATE';
25-
2624
studyForm: UntypedFormGroup;
27-
2825
selectedTasks: Task[];
26+
studyConfig: {};
27+
jsonIsError: boolean;
28+
subscriptions: Subscription[] = [];
2929

3030
get tasks(): Task[] {
3131
return this.taskService.tasksValue;
3232
}
3333

34-
subscriptions: Subscription[] = [];
35-
3634
private originalSelectedTasks: Task[] = [];
3735

3836
constructor(
@@ -106,6 +104,7 @@ export class CreateModifyStudyComponent implements OnInit, OnDestroy {
106104
this.taskService.getOrUpdateTasks().subscribe(() => {});
107105

108106
this.selectedTasks = [];
107+
this.studyConfig = {};
109108
this.studyForm = this.fb.group({
110109
externalName: ['', Validators.compose([Validators.required, Validators.maxLength(255)])],
111110
internalName: ['', Validators.compose([Validators.required, Validators.maxLength(255)])],
@@ -122,6 +121,8 @@ export class CreateModifyStudyComponent implements OnInit, OnDestroy {
122121
this.studyForm.controls['description'].setValue(this.study.description);
123122
this.studyForm.controls['consent'].setValue(this.study.consent);
124123

124+
this.studyConfig = { ...this.study.config };
125+
125126
this.study.studyTasks.forEach((studyTask) => {
126127
this.selectedTasks.push(studyTask.task);
127128
this.originalSelectedTasks.push(studyTask.task);
@@ -145,6 +146,13 @@ export class CreateModifyStudyComponent implements OnInit, OnDestroy {
145146
this.selectedTasks.splice(index, 1);
146147
}
147148

149+
handleUpdateJSON(event: { json: any; isValid: boolean }) {
150+
this.jsonIsError = !event.isValid;
151+
if (event.json && event.isValid) {
152+
this.studyConfig = event.json;
153+
}
154+
}
155+
148156
onSubmit() {
149157
this.mode === 'CREATE' ? this.handleCreateStudy() : this.handleEditStudy();
150158
}
@@ -159,7 +167,7 @@ export class CreateModifyStudyComponent implements OnInit, OnDestroy {
159167
started: this.study.started,
160168
description: this.studyForm.controls['description'].value,
161169
canEdit: this.study.canEdit,
162-
config: this.study.config,
170+
config: this.studyConfig,
163171
owner: this.study.owner,
164172
consent: this.studyForm.controls['consent'].value,
165173
studyTasks: this.selectedTasks.map((task, index) => {
@@ -220,7 +228,7 @@ export class CreateModifyStudyComponent implements OnInit, OnDestroy {
220228
owner: {
221229
id: parseInt(this.userStateService.currentlyLoggedInUserId),
222230
},
223-
config: {},
231+
config: this.studyConfig,
224232
consent: this.studyForm.controls['consent'].value,
225233
studyTasks: this.selectedTasks.map((task, index) => {
226234
return {

src/app/pages/landing-page/study-background/study-background.component.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,14 +42,15 @@ export class StudyBackgroundComponent implements OnInit, OnDestroy {
4242
this.studyService
4343
.getStudyById(parsedNum)
4444
.subscribe(
45-
(task) => {
46-
if (task.status === 204) {
45+
(study) => {
46+
if (study.status === 204) {
4747
this.router.navigate([`${RouteNames.LANDINGPAGE_NOTFOUND}`]);
4848
return;
4949
} else {
5050
// save the id in session storage so that the user can register for it later
5151
this.sessionStorageService.setStudyIdToRegisterInSessionStorage(studyIdFromURL);
52-
this.studyBackground = Object.keys(task.body.config).length === 0 ? null : task.body.config;
52+
this.studyBackground =
53+
Object.keys(study.body.config).length === 0 ? null : study.body.config;
5354
}
5455
},
5556
(_err) => {

src/app/pages/participant/participant-dashboard/participant-dashboard.component.ts

Lines changed: 42 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ import { Component, OnDestroy, OnInit } from '@angular/core';
22
import { MatDialog } from '@angular/material/dialog';
33
import { TranslateService } from '@ngx-translate/core';
44
import { Observable, of, Subscription, throwError } from 'rxjs';
5-
import { catchError, mergeMap, take, tap } from 'rxjs/operators';
5+
import { catchError, map, mergeMap, take, tap } from 'rxjs/operators';
66
import { SupportedLangs } from 'src/app/models/enums';
7+
import { Study } from 'src/app/models/Study';
8+
import { StudyUser } from 'src/app/models/StudyUser';
79
import { AuthService } from 'src/app/services/auth.service';
810
import { LoaderService } from 'src/app/services/loader/loader.service';
911
import { SessionStorageService } from 'src/app/services/sessionStorage.service';
1012
import { SnackbarService } from 'src/app/services/snackbar/snackbar.service';
1113
import { StudyUserService } from 'src/app/services/study-user.service';
14+
import { StudyService } from 'src/app/services/study.service';
1215
import { UserStateService } from 'src/app/services/user-state-service';
1316
import { UserService } from 'src/app/services/user.service';
1417
import { LanguageDialogComponent } from './language-dialog/language-dialog.component';
@@ -31,16 +34,33 @@ export class ParticipantDashboardComponent implements OnInit, OnDestroy {
3134
private loaderService: LoaderService,
3235
private userStateService: UserStateService,
3336
private authService: AuthService,
34-
private snackbarService: SnackbarService
37+
private snackbarService: SnackbarService,
38+
private studyService: StudyService
3539
) {}
3640

3741
ngOnInit(): void {
38-
const studyId = parseInt(this.sessionStorageService.getStudyIdToRegisterInSessionStorage());
42+
let studyId = parseInt(this.sessionStorageService.getStudyIdToRegisterInSessionStorage());
3943
this.isLoading = true;
44+
this.loaderService.showLoader();
4045

41-
const obs = this.userStateService
42-
.getOrUpdateUserState()
46+
let sub: Subscription;
47+
48+
const redirectSub = this.studyService.getStudyById(studyId).pipe(
49+
mergeMap((study) => {
50+
return this.studyUserService
51+
.getOrUpdateStudyUsers(true)
52+
.pipe(map((studyUsers) => ({ study, studyUsers })));
53+
}),
54+
tap(({ study, studyUsers }) => {
55+
if (this.shouldReroute(study.body.config, studyUsers)) {
56+
studyId = study.body.config.rerouteConfig.rerouteTo;
57+
}
58+
})
59+
);
60+
61+
sub = (studyId ? redirectSub : of(null))
4362
.pipe(
63+
mergeMap(() => this.userStateService.getOrUpdateUserState(true)),
4464
mergeMap((res) =>
4565
res?.lang === SupportedLangs.NONE
4666
? this.openLanguageDialog().pipe(
@@ -50,17 +70,14 @@ export class ParticipantDashboardComponent implements OnInit, OnDestroy {
5070
),
5171
tap((user) => this.translateService.use(user?.lang)),
5272
mergeMap((user) => {
53-
this.loaderService.showLoader();
5473
// register the participant for the given study saved in session storage if it exists
5574
return studyId ? this.studyUserService.registerParticipantForStudy(user, studyId) : of(null);
5675
}),
76+
// force update as sometimes the retrieved studyUsers value is cached elsewhere
77+
// and does not reflect our recent call to registerParticipantForStudy
78+
mergeMap(() => this.studyUserService.getOrUpdateStudyUsers(true)),
5779
// if 409 (conflict) then we dont want an error
58-
catchError((err) => (err.status === 409 ? of(null) : throwError(err))),
59-
mergeMap((_res) => {
60-
// force update as sometimes the retrieved studyUsers value is cached elsewhere
61-
// and does not reflect our recent call to registerParticipantForStudy
62-
return this.studyUserService.getOrUpdateStudyUsers(true);
63-
})
80+
catchError((err) => (err.status === 409 ? of(null) : throwError(err)))
6481
)
6582
.subscribe(
6683
(_res) => {
@@ -76,12 +93,23 @@ export class ParticipantDashboardComponent implements OnInit, OnDestroy {
7693
}
7794
)
7895
.add(() => {
79-
// this.sessionStorageService.removeStudyIdToRegisterInSessionStorage();
96+
this.sessionStorageService.removeStudyIdToRegisterInSessionStorage();
8097
this.isLoading = false;
8198
this.loaderService.hideLoader();
8299
});
83100

84-
this.subscriptions.push(obs);
101+
this.subscriptions.push(sub);
102+
}
103+
104+
shouldReroute(studyConfig: Study['config'], studyUsers: StudyUser[]): boolean {
105+
if (!studyConfig?.rerouteConfig?.mustCompleteOneOf) return false;
106+
107+
return studyConfig?.rerouteConfig.mustCompleteOneOf.some(({ studyId, currentTaskIndex }) => {
108+
const hasStudyUserForStudy = studyUsers.find((studyUser) => studyUser.studyId === studyId);
109+
if (!hasStudyUserForStudy) return false;
110+
111+
return hasStudyUserForStudy.currentTaskIndex >= currentTaskIndex;
112+
});
85113
}
86114

87115
openLanguageDialog(): Observable<SupportedLangs> {

src/app/pages/shared/info-display-viewer/info-display-viewer.component.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ export interface InfoDisplayViewerMetadata {
1111

1212
export interface InfoDisplayViewerSection {
1313
header?: string;
14-
indent?: boolean;
14+
indent?: false | number;
1515
hr?: boolean;
1616
textContent?: string;
1717
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<div style="height: 50vh">
2+
<textarea
3+
[value]="jsonTextValue"
4+
(input)="onTextChange($event.target.value)"
5+
class="w-100"
6+
style="height: 45vh"
7+
[ngStyle]="{ 'border-color': isError ? 'red' : 'initial' }"
8+
>{{ json }}</textarea
9+
>
10+
<div style="color: red">
11+
<p *ngIf="isError">JSON is invalid</p>
12+
</div>
13+
</div>

src/app/pages/shared/json-editor/json-editor.component.scss

Whitespace-only changes.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core';
2+
3+
@Component({
4+
selector: 'app-json-editor',
5+
templateUrl: './json-editor.component.html',
6+
styleUrls: ['./json-editor.component.scss'],
7+
})
8+
export class JsonEditorComponent {
9+
jsonTextValue = '';
10+
private _originalJSON: any;
11+
isError = false;
12+
13+
@Input()
14+
set json(json: any) {
15+
this.jsonTextValue = JSON.stringify(json, null, 4);
16+
this._originalJSON = json;
17+
}
18+
19+
get json() {
20+
return this._originalJSON;
21+
}
22+
23+
@Output() onChange: EventEmitter<any> = new EventEmitter<{ json: any | null; isValid: boolean }>(); // emit json
24+
25+
onTextChange(value: string) {
26+
this.isError = false;
27+
this.jsonTextValue = value;
28+
try {
29+
const parsed = JSON.parse(value);
30+
this.onChange.emit({ json: parsed, isValid: true });
31+
} catch (e) {
32+
this.onChange.emit({ json: null, isValid: false });
33+
this.isError = true;
34+
return;
35+
}
36+
}
37+
}

src/app/pages/shared/shared.module.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,23 @@ import { InfoDisplayViewerComponent } from './info-display-viewer/info-display-v
88
import { TranslateModule } from '@ngx-translate/core';
99
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
1010
import { ProfileComponent } from './profile/profile.component';
11+
import { JsonEditorComponent } from './json-editor/json-editor.component';
1112

1213
@NgModule({
13-
declarations: [NavbarComponent, ConsentReaderComponent, InfoDisplayViewerComponent, ProfileComponent],
14+
declarations: [
15+
NavbarComponent,
16+
ConsentReaderComponent,
17+
InfoDisplayViewerComponent,
18+
ProfileComponent,
19+
JsonEditorComponent,
20+
],
1421
imports: [CommonModule, MaterialModule, RouterModule, TranslateModule, FormsModule, ReactiveFormsModule],
15-
exports: [NavbarComponent, ConsentReaderComponent, InfoDisplayViewerComponent, ProfileComponent],
22+
exports: [
23+
NavbarComponent,
24+
ConsentReaderComponent,
25+
InfoDisplayViewerComponent,
26+
ProfileComponent,
27+
JsonEditorComponent,
28+
],
1629
})
1730
export class SharedModule {}

0 commit comments

Comments
 (0)