Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#11878] Integrate instructor request form with API #12943

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
40 changes: 40 additions & 0 deletions src/main/java/teammates/ui/constants/ApiStringConst.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package teammates.ui.constants;

import com.fasterxml.jackson.annotation.JsonValue;

import teammates.common.util.FieldValidator;

/**
* Special constants used by the back-end.
*/
public enum ApiStringConst {
// CHECKSTYLE.OFF:JavadocVariable
EMAIL_REGEX(escapeRegex(FieldValidator.REGEX_EMAIL));
jayasting98 marked this conversation as resolved.
Show resolved Hide resolved
// CHECKSTYLE.ON:JavadocVariable

private final Object value;

ApiStringConst(Object value) {
this.value = value;
}

@JsonValue
public Object getValue() {
return value;
}

/**
* Escape regex pattern strings to ensure the pattern remains valid when converted to JS.
*/
private static String escapeRegex(String regexStr) {
String escapedRegexStr = regexStr;
// Double escape backslashes
escapedRegexStr = escapedRegexStr.replace("\\", "\\\\");
// Replace possessive zero or more times quantifier *+ that the email pattern uses
// with greedy zero or more times quantifier *
// as possessive quantifiers are not supported in JavaScript
escapedRegexStr = escapedRegexStr.replace("*+", "*");
return escapedRegexStr;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -63,16 +63,6 @@ exports[`RequestPageComponent should render correctly after form is submitted 1`
js@exampleu.edu
</td>
</tr>
<tr>
<th
scope="row"
>
Home Page URL
</th>
<td>
u.exampleu.edu/jsmith
</td>
</tr>
<tr>
<th
scope="row"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,21 @@

exports[`InstructorRequestFormComponent should render correctly 1`] = `
<tm-instructor-request-form
COUNTRY_NAME_MAX_LENGTH={[Function Number]}
EMAIL_MAX_LENGTH={[Function Number]}
INSTITUTION_NAME_MAX_LENGTH={[Function Number]}
STUDENT_NAME_MAX_LENGTH={[Function Number]}
accountService={[Function Object]}
arf={[Function FormGroup]}
comments={[Function FormControl2]}
country={[Function FormControl2]}
email={[Function FormControl2]}
hasSubmitAttempt="false"
homePage={[Function FormControl2]}
institution={[Function FormControl2]}
isLoading="false"
name={[Function FormControl2]}
requestSubmissionEvent={[Function EventEmitter_]}
serverErrorMessage=""
>
<p
aria-hidden="true"
Expand Down Expand Up @@ -88,7 +94,7 @@ exports[`InstructorRequestFormComponent should render correctly 1`] = `
role="alert"
tabindex="0"
>
Please enter your institution.
Please enter your institution name.
</div>
</div>
<br />
Expand Down Expand Up @@ -158,29 +164,10 @@ exports[`InstructorRequestFormComponent should render correctly 1`] = `
role="alert"
tabindex="0"
>
Please enter a valid email address.
Please enter your email address.
</div>
</div>
<br />
<div
class="form-group"
>
<label
class="qn"
for="homePage"
id="homePage-label"
>
URL of your home page (if any)
</label>
<input
aria-invalid="false"
autocomplete="url"
class="form-control ng-untouched ng-pristine ng-valid"
id="homePage"
type="url"
/>
</div>
<br />
<div
class="form-group"
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,5 @@ export type InstructorRequestFormModel = {
institution: string,
country: string,
email: string,
homePage: string,
comments: string,
};
Original file line number Diff line number Diff line change
Expand Up @@ -11,10 +11,17 @@
This is the name that will be shown to your students. You may include salutation (Dr. Prof. etc.)
</p>
<input class="form-control {{getFieldValidationClasses(name)}}" type="text" id="name" autocomplete="name"
[formControl]="name" [required]="checkIsFieldRequired(name)" [attr.aria-invalid]="checkIsFieldInvalid(name)">
<div *ngIf="checkIsFieldInvalid(name)" role="alert" aria-describedby="name-label" tabindex="0" class="invalid-feedback">
[formControl]="name" [required]="checkIsFieldRequired(name)" [attr.aria-invalid]="name.invalid">
<div *ngIf="name.errors?.['required']" role="alert" aria-describedby="name-label" tabindex="0" class="invalid-feedback">
Please enter your name.
</div>
<div *ngIf="name.errors?.['maxlength']" role="alert" aria-describedby="name-label" tabindex="0" class="invalid-feedback">
Name must be shorter than {{STUDENT_NAME_MAX_LENGTH}} characters. (Current: {{name.value?.length}})
</div>
<div *ngIf="name.errors?.['pattern']" role="alert" aria-describedby="name-label" tabindex="0"
class="invalid-feedback">
Name must start with an alphanumeric character (a-z, 0-9) and cannot contain any vertical bar (|) or percent sign (%).
</div>
</div>
<br>
<div class="form-group {{checkIsFieldRequired(institution) ? 'required' : ''}}">
Expand All @@ -26,10 +33,18 @@
</p>
<input class="form-control {{getFieldValidationClasses(institution)}}" type="text" id="institution"
autocomplete="organization" [formControl]="institution" [required]="checkIsFieldRequired(institution)"
[attr.aria-invalid]="checkIsFieldInvalid(institution)">
<div *ngIf="checkIsFieldInvalid(institution)" role="alert" aria-describedby="institution-label" tabindex="0"
[attr.aria-invalid]="institution.invalid">
<div *ngIf="institution.errors?.['required']" role="alert" aria-describedby="institution-label" tabindex="0"
class="invalid-feedback">
Please enter your institution name.
</div>
<div *ngIf="institution.errors?.['maxlength']" role="alert" aria-describedby="institution-label" tabindex="0"
class="invalid-feedback">
Please enter your institution.
Institution name must be shorter than {{INSTITUTION_NAME_MAX_LENGTH}} characters. (Current: {{institution.value?.length}})
</div>
<div *ngIf="institution.errors?.['pattern']" role="alert" aria-describedby="institution-label" tabindex="0"
class="invalid-feedback">
Institution name must start with an alphanumeric character (a-z, 0-9) and cannot contain any vertical bar (|) or percent sign (%).
</div>
</div>
<br>
Expand All @@ -42,11 +57,19 @@
</p>
<input class="form-control {{getFieldValidationClasses(country)}}" type="text" id="country"
autocomplete="country-name" [formControl]="country" [required]="checkIsFieldRequired(country)"
[attr.aria-invalid]="checkIsFieldInvalid(country)">
<div *ngIf="checkIsFieldInvalid(country)" role="alert" aria-describedby="country-label" tabindex="0"
[attr.aria-invalid]="country.invalid">
<div *ngIf="country.errors?.['required']" role="alert" aria-describedby="country-label" tabindex="0"
class="invalid-feedback">
Please enter your institution's country.
</div>
<div *ngIf="country.errors?.['maxlength']" role="alert" aria-describedby="country-label" tabindex="0"
class="invalid-feedback">
Country name must be shorter than {{COUNTRY_NAME_MAX_LENGTH}} characters. (Current: {{country.value?.length}})
</div>
<div *ngIf="country.errors?.['pattern']" role="alert" aria-describedby="country-label" tabindex="0"
class="invalid-feedback">
Country name must start with an alphanumeric character (a-z, 0-9) and cannot contain any vertical bar (|) or percent sign (%).
</div>
</div>
<br>
<div class="form-group {{checkIsFieldRequired(email) ? 'required' : ''}}">
Expand All @@ -59,22 +82,18 @@
Note that this email address will be visible to the students you enroll in TEAMMATES.
</p>
<input class="form-control {{getFieldValidationClasses(email)}}" type="email" id="email" autocomplete="email"
[formControl]="email" [required]="checkIsFieldRequired(email)" [attr.aria-invalid]="checkIsFieldInvalid(email)">
<div *ngIf="checkIsFieldInvalid(email)" role="alert" aria-describedby="email-label" tabindex="0"
[formControl]="email" [required]="checkIsFieldRequired(email)" [attr.aria-invalid]="email.invalid">
<div *ngIf="email.errors?.['required']" role="alert" aria-describedby="email-label" tabindex="0"
class="invalid-feedback">
Please enter a valid email address.
Please enter your email address.
</div>
</div>
<br>
<div class="form-group {{checkIsFieldRequired(homePage) ? 'required' : ''}}">
<label for="homePage" id="homePage-label" class="qn">
URL of your home page (if any)
</label>
<input class="form-control {{getFieldValidationClasses(homePage)}}" type="url" id="homePage" autocomplete="url"
[formControl]="homePage" [required]="checkIsFieldRequired(homePage)" [attr.aria-invalid]="checkIsFieldInvalid(homePage)">
<div *ngIf="checkIsFieldInvalid(homePage)" role="alert" aria-describedby="homePage-label" tabindex="0"
<div *ngIf="email.errors?.['maxlength']" role="alert" aria-describedby="email-label" tabindex="0"
class="invalid-feedback">
Email address must be shorter than {{EMAIL_MAX_LENGTH}} characters. (Current: {{email.value?.length}})
</div>
<div *ngIf="email.errors?.['pattern']" role="alert" aria-describedby="email-label" tabindex="0"
class="invalid-feedback">
Please enter a valid URL.
Please enter a valid email address.
</div>
</div>
<br>
Expand All @@ -83,10 +102,16 @@
Any other comments/queries
</label>
<textarea class="form-control {{getFieldValidationClasses(comments)}}" [formControl]="comments"
[attr.aria-invalid]="checkIsFieldInvalid(comments)"></textarea>
[attr.aria-invalid]="comments.invalid"></textarea>
</div>
<br>
<button type="submit" class="btn btn-primary" id="submit-button" [disabled]="!checkCanSubmit()">
Submit
<ngb-alert type="danger" [dismissible]="false" *ngIf="hasSubmitAttempt && arf.invalid" class="error-box">
<strong>There was a problem with your submission.</strong> Please check and fix the errors above and submit again.
</ngb-alert>
<ngb-alert type="danger" [dismissible]="false" *ngIf="serverErrorMessage" class="error-box">
<strong>Error submitting request:</strong> {{serverErrorMessage}}
</ngb-alert>
<button type="submit" class="btn btn-primary" id="submit-button" [disabled]="!canSubmit">
{{isLoading ? "Submitting..." : "Submit"}}
</button>
</form>
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,7 @@ label.qn {
.red-font {
color: red;
}

.error-box {
margin: 1rem 0;
}
Original file line number Diff line number Diff line change
@@ -1,21 +1,34 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing';
import { ReactiveFormsModule } from '@angular/forms';
import { By } from '@angular/platform-browser';
import { first } from 'rxjs';
import { Observable, first } from 'rxjs';
import { InstructorRequestFormModel } from './instructor-request-form-model';
import { InstructorRequestFormComponent } from './instructor-request-form.component';
import { AccountService } from '../../../../services/account.service';
import { AccountCreateRequest } from '../../../../types/api-request';

describe('InstructorRequestFormComponent', () => {
let component: InstructorRequestFormComponent;
let fixture: ComponentFixture<InstructorRequestFormComponent>;
let accountService: AccountService;
const typicalModel: InstructorRequestFormModel = {
name: 'John Doe',
institution: 'Example Institution',
country: 'Example Country',
email: 'jd@example.edu',
homePage: 'xyz.example.edu/john',
comments: '',
};
const typicalCreateRequest: AccountCreateRequest = {
instructorEmail: typicalModel.email,
instructorName: typicalModel.name,
instructorInstitution: `${typicalModel.institution}, ${typicalModel.country}`,
};

const accountServiceStub: Partial<AccountService> = {
createAccountRequest: () => new Observable((subscriber) => {
subscriber.next();
}),
};

/**
* Fills in form fields with the given data.
Expand All @@ -27,19 +40,24 @@ describe('InstructorRequestFormComponent', () => {
component.institution.setValue(data.institution);
component.country.setValue(data.country);
component.email.setValue(data.email);
component.homePage.setValue(data.homePage);
component.comments.setValue(data.comments);
}

beforeEach(() => {
beforeEach(waitForAsync(() => {
TestBed.configureTestingModule({
declarations: [InstructorRequestFormComponent],
imports: [ReactiveFormsModule],
});
providers: [{ provide: AccountService, useValue: accountServiceStub }],
})
.compileComponents();
}));

beforeEach(() => {
fixture = TestBed.createComponent(InstructorRequestFormComponent);
component = fixture.componentInstance;

accountService = TestBed.inject(AccountService);
fixture.detectChanges();
jest.clearAllMocks();
});

it('should create', () => {
Expand All @@ -50,17 +68,20 @@ describe('InstructorRequestFormComponent', () => {
expect(fixture).toMatchSnapshot();
});

it('should emit requestSubmissionEvent once when submit button is clicked', () => {
jest.spyOn(component.requestSubmissionEvent, 'emit');
it('should run onSubmit() when submit button is clicked', () => {
jest.spyOn(component, 'onSubmit');

fillFormWith(typicalModel);
const submitButton = fixture.debugElement.query(By.css('#submit-button'));
submitButton.nativeElement.click();

expect(component.requestSubmissionEvent.emit).toHaveBeenCalledTimes(1);
expect(component.onSubmit).toHaveBeenCalledTimes(1);
});

it('should emit requestSubmissionEvent with the correct data when form is submitted', () => {
jest.spyOn(accountService, 'createAccountRequest').mockReturnValue(
new Observable((subscriber) => { subscriber.next(); }));

// Listen for emitted value
let actualModel: InstructorRequestFormModel | null = null;
component.requestSubmissionEvent.pipe(first())
Expand All @@ -74,7 +95,17 @@ describe('InstructorRequestFormComponent', () => {
expect(actualModel!.institution).toBe(typicalModel.institution);
expect(actualModel!.country).toBe(typicalModel.country);
expect(actualModel!.email).toBe(typicalModel.email);
expect(actualModel!.homePage).toBe(typicalModel.homePage);
expect(actualModel!.comments).toBe(typicalModel.comments);
});

it('should send the correct request data when form is submitted', () => {
jest.spyOn(accountService, 'createAccountRequest').mockReturnValue(
new Observable((subscriber) => { subscriber.next(); }));

fillFormWith(typicalModel);
component.onSubmit();

expect(accountService.createAccountRequest).toHaveBeenCalledTimes(1);
expect(accountService.createAccountRequest).toHaveBeenCalledWith(expect.objectContaining(typicalCreateRequest));
});
});
Loading
Loading