Skip to content

Commit

Permalink
[#11878] Integrate instructor request form with API (#12943)
Browse files Browse the repository at this point in the history
* Integrate instructor request form FE with API

* Remove redundant statement

* Move URL regex const to backend const file

* Fix import path

* Move URL regex to FieldValidator

* Add validators to match backend fields

* Add error message box

* Change submit button display when loading

* Combine final action into subscribe

* Add max length validators for institution and country

* Fix lint errors

* Add test cases to test submission

* Add specific error messages for form validation

* Remove home page URL field

* Fix lint errors

* Remove url regex from test

* Update snap

* Clean up test code

* Remove comment about home page URL

* Change canSubmit check to getter

* Fix form submit button not re-enabling on error

* Add name pattern validator to front-end

* Fix snapshot
  • Loading branch information
xenosf authored Apr 4, 2024
1 parent 0b07036 commit de3181a
Show file tree
Hide file tree
Showing 13 changed files with 254 additions and 115 deletions.
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));
// 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

0 comments on commit de3181a

Please sign in to comment.