diff --git a/package.json b/package.json index 9c9e00ace..74f080def 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "CATcher", - "version": "1.0.0", + "version": "3.1.0", "main": "main.js", "scripts": { "postinstall": "npm run postinstall:electron && electron-builder install-app-deps", diff --git a/src/app/core/models/issue-dispute.model.ts b/src/app/core/models/issue-dispute.model.ts new file mode 100644 index 000000000..44a01029a --- /dev/null +++ b/src/app/core/models/issue-dispute.model.ts @@ -0,0 +1,38 @@ +export class IssueDispute { + readonly TODO_UNCHECKED = '- [ ] Done'; + readonly INITIAL_RESPONSE = '[replace this with your explanation]'; + readonly TITLE_PREFIX = '## :question: '; + readonly LINE_BREAK = '-------------------\n'; + title: string; // e.g Issue severity + description: string; // e.g Team says: xxx\n Tester says: xxx. + tutorResponse: string; // e.g Not justified. I've changed it back. + todo: string; // e.g - [x] Done + + constructor(title: string, description: string) { + this.title = title; + this.description = description; + this.tutorResponse = this.INITIAL_RESPONSE; + this.todo = this.TODO_UNCHECKED; + } + + /* + This method is used to format the tutor's response so that the app can upload it on Github. + Refer to format in https://github.com/CATcher-org/templates#app-collect-tutor-response + */ + toTutorResponseString(): string { + let toString = ''; + toString += this.TITLE_PREFIX + this.title + '\n\n'; + toString += this.todo + '\n\n'; + toString += this.tutorResponse + '\n\n'; + toString += this.LINE_BREAK; + return toString; + } + + toString(): string { + let toString = ''; + toString += this.TITLE_PREFIX + this.title + '\n\n'; + toString += this.description + '\n\n'; + toString += this.LINE_BREAK; + return toString; + } +} diff --git a/src/app/core/models/issue.model.ts b/src/app/core/models/issue.model.ts index 195a9cfa8..d9d18a952 100644 --- a/src/app/core/models/issue.model.ts +++ b/src/app/core/models/issue.model.ts @@ -1,5 +1,7 @@ import {Team} from './team.model'; import { TesterResponse } from './tester-response.model'; +import { IssueComment, IssueComments } from './comment.model'; +import { IssueDispute } from './issue-dispute.model'; export interface Issue { readonly id: number; @@ -18,6 +20,10 @@ export interface Issue { teamResponse?: string; tutorResponse?: string; testerResponses?: TesterResponse[]; + issueComment?: IssueComment; // Issue comment is used for Tutor Response and Tester Response + issueDisputes?: IssueDispute[]; + pending?: string; + unsure?: boolean; } export interface Issues { @@ -29,13 +35,15 @@ export interface Issues { * Where `Type` represent the type of the label. (e.g. severity, type, response) * And `Value` represent the value that is associated to the `Type` (e.g. for severity Type, it could be Low, Medium, High) */ -export const LABELS = ['severity', 'type', 'response', 'duplicate', 'status']; +export const LABELS = ['severity', 'type', 'response', 'duplicate', 'status', 'pending', 'unsure']; export const labelsToAttributeMapping = { 'severity': 'severity', 'type': 'type', 'response': 'responseTag', 'status': 'status', + 'pending': 'pending', + 'unsure': 'unsure' }; export const SEVERITY_ORDER = { Low: 0, Medium: 1, High: 2 }; @@ -107,6 +115,6 @@ export const phaseTesterResponseDescriptionTemplate = new RegExp('(?
# De '|## State the duplicated issue here, if any|# Items for the Tester to Verify|$)', 'gi'); export const phaseModerationDescriptionTemplate = new RegExp('(?
# Description|# Team\'s Response|## State the duplicated issue ' + - 'here, if any|## Proposed Assignees|# Items for the Tester to Verify|# Tutor\'s Response|## Tutor to check)\\s+' + - '(?[\\s\\S]*?)(?=# Team\'s Response|## State the duplicated issue here, if any|## Proposed Assignees|' + - '# Items for the Tester to Verify|# Tutor\'s Response|## Tutor to check|$)', 'gi'); + 'here, if any|# Disputes)\\s+' + + '(?[\\s\\S]*?)(?=# Team\'s Response|## State the duplicated issue here, if any|' + + '# Disputes|$)', 'gi'); diff --git a/src/app/core/models/tester-response.model.ts b/src/app/core/models/tester-response.model.ts index e3e2f04ef..313e8ce7d 100644 --- a/src/app/core/models/tester-response.model.ts +++ b/src/app/core/models/tester-response.model.ts @@ -1,7 +1,10 @@ export class TesterResponse { - title: string; // e.g Change of Severity - description: string; // e.g Changed from High to Low - disagreeCheckbox: string; // e.g [x] I disagree + readonly TITLE_PREFIX = '## :question: '; + readonly DISAGREEMENT_PREFIX = '**Reason for disagreement:** '; + readonly LINE_BREAK = '-------------------\n'; + title: string; // e.g Issue Severity + description: string; // e.g Team chose `Low`. Originally `High`. + disagreeCheckbox: string; // e.g - [x] I disagree reasonForDiagreement: string; constructor(title: string, description: string, disagreeCheckbox: string, reasonForDiagreement: string) { @@ -13,11 +16,11 @@ export class TesterResponse { toString(): string { let toString = ''; - toString += this.title + '\n\n'; + toString += this.TITLE_PREFIX + this.title + '\n\n'; toString += this.description + '\n\n'; toString += this.disagreeCheckbox + '\n\n'; - toString += '**Reason for disagreement:** ' + this.reasonForDiagreement + '\n\n'; - toString += '-------------------\n'; + toString += this.DISAGREEMENT_PREFIX + this.reasonForDiagreement + '\n\n'; + toString += this.LINE_BREAK; return toString; } } diff --git a/src/app/core/services/github.service.ts b/src/app/core/services/github.service.ts index 2c845758d..ceda15021 100644 --- a/src/app/core/services/github.service.ts +++ b/src/app/core/services/github.service.ts @@ -80,7 +80,7 @@ export class GithubService { mergeMap((numOfPages) => { const apiCalls = []; for (let i = 1; i <= numOfPages; i++) { - apiCalls.push(from(octokit.issues.listComments({owner: ORG_NAME, repo: REPO, number: issueId, per_page: 1, page: i}))); + apiCalls.push(from(octokit.issues.listComments({owner: ORG_NAME, repo: REPO, number: issueId, page: i}))); } return forkJoin(apiCalls); }), @@ -222,7 +222,7 @@ export class GithubService { } private getNumberOfCommentPages(issueId: number): Observable { - return from(octokit.issues.listComments({owner: ORG_NAME, repo: REPO, number: issueId, per_page: 1, page: 1})).pipe( + return from(octokit.issues.listComments({owner: ORG_NAME, repo: REPO, number: issueId, page: 1})).pipe( map((response) => { if (!response['headers'].link) { return 1; diff --git a/src/app/core/services/issue-comment.service.ts b/src/app/core/services/issue-comment.service.ts index 96a6a0984..1ba08fc9f 100644 --- a/src/app/core/services/issue-comment.service.ts +++ b/src/app/core/services/issue-comment.service.ts @@ -1,9 +1,11 @@ import {Injectable} from '@angular/core'; -import {Observable, of} from 'rxjs'; +import {Observable, of, BehaviorSubject} from 'rxjs'; import {GithubService} from './github.service'; import {IssueComment, IssueComments} from '../models/comment.model'; import {map} from 'rxjs/operators'; import * as moment from 'moment'; +import { TesterResponse } from '../models/tester-response.model'; +import { IssueDispute } from '../models/issue-dispute.model'; @Injectable({ providedIn: 'root', @@ -16,11 +18,7 @@ export class IssueCommentService { } getIssueComments(issueId: number): Observable { - if (!this.comments.get(issueId)) { - return this.initializeIssueComments(issueId); - } else { - return of(this.comments.get(issueId)); - } + return this.initializeIssueComments(issueId); } createIssueComment(issueId: number, description: string): Observable { @@ -34,7 +32,7 @@ export class IssueCommentService { ); } - private updateIssueComment(issueComment: IssueComment): Observable { + updateIssueComment(issueComment: IssueComment): Observable { return this.githubService.updateIssueComment({ ...issueComment, description: issueComment.description, @@ -45,6 +43,29 @@ export class IssueCommentService { ); } + // Template url: https://github.com/CATcher-org/templates#teams-response-1 + createGithubTesterResponse(teamResponse: string, testerResponses: TesterResponse[]): string { + return `# Team\'s Response\n${teamResponse}\n ` + + `# Items for the Tester to Verify\n${this.getTesterResponsesString(testerResponses)}`; + } + + // Template url: https://github.com/CATcher-org/templates#tutor-moderation + createGithubTutorResponse(issueDisputes: IssueDispute[]): string { + let tutorResponseString = '# Tutor Moderation\n\n'; + for (const issueDispute of issueDisputes) { + tutorResponseString += issueDispute.toTutorResponseString(); + } + return tutorResponseString; + } + + private getTesterResponsesString(testerResponses: TesterResponse[]): string { + let testerResponsesString = ''; + for (const testerResponse of testerResponses) { + testerResponsesString += testerResponse.toString(); + } + return testerResponsesString; + } + private initializeIssueComments(issueId: number): Observable { return this.githubService.fetchIssueComments(issueId).pipe( map((comments: []) => { diff --git a/src/app/core/services/issue.service.ts b/src/app/core/services/issue.service.ts index 12aaaf06a..3659a6028 100644 --- a/src/app/core/services/issue.service.ts +++ b/src/app/core/services/issue.service.ts @@ -1,7 +1,7 @@ import { Injectable } from '@angular/core'; import { GithubService } from './github.service'; -import { map } from 'rxjs/operators'; -import { BehaviorSubject, forkJoin, Observable, of } from 'rxjs'; +import { map, flatMap } from 'rxjs/operators'; +import { BehaviorSubject, combineLatest, forkJoin, merge, Observable, of, zip } from 'rxjs'; import { Issue, Issues, @@ -22,6 +22,9 @@ import { Team } from '../models/team.model'; import { DataService } from './data.service'; import { ErrorHandlingService } from './error-handling.service'; import { TesterResponse } from '../models/tester-response.model'; +import { IssueComments, IssueComment } from '../models/comment.model'; +import { IssueDispute } from '../models/issue-dispute.model'; +import { UserRole } from '../../core/models/user.model'; @Injectable({ providedIn: 'root', @@ -31,6 +34,7 @@ export class IssueService { issues$: BehaviorSubject; private issueTeamFilter = 'All Teams'; readonly MINIMUM_MATCHES = 1; + readonly userRole = UserRole; constructor(private githubService: GithubService, private userService: UserService, @@ -63,7 +67,7 @@ export class IssueService { getIssue(id: number): Observable { if (this.issues === undefined) { return this.githubService.fetchIssue(id).pipe( - map((response) => { + flatMap((response) => { return this.createIssueModel(response); }) ); @@ -75,7 +79,7 @@ export class IssueService { createIssue(title: string, description: string, severity: string, type: string): Observable { const labelsArray = [this.createLabel('severity', severity), this.createLabel('type', type)]; return this.githubService.createIssue(title, description, labelsArray).pipe( - map((response) => { + flatMap((response) => { return this.createIssueModel(response); }) ); @@ -85,7 +89,7 @@ export class IssueService { const assignees = this.phaseService.currentPhase === Phase.phaseModeration ? [] : issue.assignees; return this.githubService.updateIssue(issue.id, issue.title, this.createGithubIssueDescription(issue), this.createLabelsForIssue(issue), assignees).pipe( - map((response) => { + flatMap((response) => { return this.createIssueModel(response); }) ); @@ -101,23 +105,10 @@ export class IssueService { case Phase.phaseTeamResponse: return `# Description\n${issue.description}\n# Team\'s Response\n${issue.teamResponse}\n ` + `## State the duplicated issue here, if any\n${issue.duplicateOf ? `Duplicate of #${issue.duplicateOf}` : `--`}`; - case Phase.phaseTesterResponse: - return `# Description\n${issue.description}\n# Team\'s Response\n${issue.teamResponse}\n ` + - `## State the duplicated issue here, if any\n${issue.duplicateOf ? `Duplicate of #${issue.duplicateOf}` : `--`}\n` + - `## Items for the Tester to Verify\n${this.getTesterResponsesString(issue.testerResponses)}`; case Phase.phaseModeration: - if (!issue.todoList) { - issue.todoList = []; - } - let todoString = ''; - for (const todo of issue.todoList) { - todoString += todo + '\n'; - } return `# Description\n${issue.description}\n# Team\'s Response\n${issue.teamResponse}\n ` + - `## State the duplicated issue here, if any\n${issue.duplicateOf ? `Duplicate of #${issue.duplicateOf}` : `--`}\n` + - `## Proposed Assignees\n${issue.assignees.length === 0 ? '--' : issue.assignees.join(', ')}\n` + - `## Items for the Tester to Verify\n${this.getTesterResponsesString(issue.testerResponses)}\n` + - `# Tutor\'s Response\n${issue.tutorResponse}\n## Tutor to check\n${todoString}`; + // `## State the duplicated issue here, if any\n${issue.duplicateOf ? `Duplicate of #${issue.duplicateOf}` : `--`}\n` + + `# Disputes\n\n${this.getIssueDisputeString(issue.issueDisputes)}\n`; default: return issue.description; } @@ -131,12 +122,23 @@ export class IssueService { return testerResponsesString; } + private getIssueDisputeString(issueDisputes: IssueDispute[]): string { + let issueDisputeString = ''; + for (const issueDispute of issueDisputes) { + issueDisputeString += issueDispute.toString(); + } + return issueDisputeString; + } + deleteIssue(id: number): Observable { return this.githubService.closeIssue(id).pipe( - map((response) => { - const deletedIssue = this.createIssueModel(response); - this.deleteFromLocalStore(deletedIssue); - return deletedIssue; + flatMap((response) => { + return this.createIssueModel(response).pipe( + map(deletedIssue => { + this.deleteFromLocalStore(deletedIssue); + return deletedIssue; + }) + ); }) ); } @@ -248,18 +250,22 @@ export class IssueService { } return forkJoin(issuesPerFilter).pipe( - map((issuesByFilter: [][]) => { - let mappedResult = {}; + flatMap((issuesByFilter: [][]) => { + const mappingFunctions: Observable[] = []; for (const issues of issuesByFilter) { for (const issue of issues) { - const issueModel = this.createIssueModel(issue); - mappedResult = { - ...mappedResult, - [issueModel.id]: issueModel, - }; + mappingFunctions.push(this.createIssueModel(issue)); } } - return mappedResult; + return combineLatest(mappingFunctions); + }), + map((issueArray) => { + let mappedResults: Issues = {}; + issueArray.forEach(issue => mappedResults = { + ...mappedResults, + [issue.id]: issue + }); + return mappedResults; }), map((issues: Issues) => { this.issues = { ...this.issues, ...issues }; @@ -274,11 +280,12 @@ export class IssueService { * Will be used to parse the github representation of the issue's description */ private getParsedBody(issue: any) { - if (this.phaseService.currentPhase === Phase.phaseBugReporting) { + if (this.phaseService.currentPhase === Phase.phaseBugReporting || + this.phaseService.currentPhase === Phase.phaseTesterResponse) { return; } [issue.body, issue.teamResponse, issue.duplicateOf, issue.tutorResponse, issue.todoList, - issue.proposedAssignees, issue.testerResponses] = this.parseBody(issue); + issue.proposedAssignees, issue.testerResponses, issue.issueDisputes] = this.parseBody(issue); } /** @@ -313,7 +320,8 @@ export class IssueService { tutorResponse, todoList, assignees, - testerResponses; + testerResponses, + issueDisputes; for (const match of matches) { const groups = regexExp.exec(match)['groups']; @@ -353,11 +361,15 @@ export class IssueService { case '# Items for the Tester to Verify': testerResponses = this.parseTesterResponse(groups['description']); break; + case '# Disputes': + issueDisputes = this.parseIssueDisputes(groups['description']); + break; default: break; } } - return Array(description || '', teamResponse, duplicateOf, tutorResponse, todoList || [], assignees || [], testerResponses || []); + return Array(description || '', teamResponse, duplicateOf, tutorResponse, todoList || [], + assignees || [], testerResponses || [], issueDisputes || []); } /** @@ -392,6 +404,16 @@ export class IssueService { result.push(this.createLabel('status', issue.status)); } + if (issue.pending) { + if (+issue.pending > 0) { + result.push(this.createLabel('pending', issue.pending)); + } + } + + if (issue.unsure) { + result.push('unsure'); + } + return result; } @@ -399,23 +421,68 @@ export class IssueService { return `${prepend}.${value}`; } - private createIssueModel(issueInJson: {}): Issue { + private createIssueModel(issueInJson: {}): Observable { this.getParsedBody(issueInJson); - return { - id: +issueInJson['number'], - created_at: moment(issueInJson['created_at']).format('lll'), - title: issueInJson['title'], - assignees: this.phaseService.currentPhase === Phase.phaseModeration ? issueInJson['proposedAssignees'] : - issueInJson['assignees'].map((assignee) => assignee['login']), - description: issueInJson['body'], - teamAssigned: this.getTeamAssignedToIssue(issueInJson), - todoList: issueInJson['todoList'], - teamResponse: issueInJson['teamResponse'], - tutorResponse: issueInJson['tutorResponse'], - duplicateOf: issueInJson['duplicateOf'], - testerResponses: issueInJson['testerResponses'], - ...this.getFormattedLabels(issueInJson['labels'], LABELS), - }; + const issueId = +issueInJson['number']; + return this.issueCommentService.getIssueComments(issueId).pipe( + map((issueComments: IssueComments) => { + const issueComment = this.getIssueComment(issueComments); + return { + id: issueId, + created_at: moment(issueInJson['created_at']).format('lll'), + title: issueInJson['title'], + assignees: this.phaseService.currentPhase === Phase.phaseModeration ? issueInJson['proposedAssignees'] : + issueInJson['assignees'].map((assignee) => assignee['login']), + description: issueInJson['body'], + teamAssigned: this.getTeamAssignedToIssue(issueInJson), + todoList: this.getToDoList(issueComment, issueInJson['issueDisputes']), + teamResponse: issueInJson['teamResponse'], + tutorResponse: issueInJson['tutorResponse'], + duplicateOf: issueInJson['duplicateOf'], + testerResponses: issueInJson['testerResponses'], + issueComment: issueComment, + issueDisputes: issueInJson['issueDisputes'], + ...this.getFormattedLabels(issueInJson['labels'], LABELS), + }; + }), + map((issue: Issue) => { + if (issue.issueComment === undefined) { + return issue; + } + + const LABEL_CATEGORY = 1; + const LABEL_VALUE = 2; + + const issueLabelsExtractionRegex = /#{2} ?:question: ?Issue (\w+)[\n\r]*Team Chose `?(\w+)`?\.?/gi; + let extractedLabelsAndValues: RegExpExecArray; + + while (extractedLabelsAndValues = issueLabelsExtractionRegex.exec(issue.issueComment.description)) { + issue = { + ...issue, + [(extractedLabelsAndValues[LABEL_CATEGORY] === 'response' + ? extractedLabelsAndValues[LABEL_CATEGORY].concat('Tag') + : extractedLabelsAndValues[LABEL_CATEGORY])]: extractedLabelsAndValues[LABEL_VALUE] + }; + } + + this.updateIssue(issue); + return issue; + }) + ); + } + + getIssueComment(issueComments: IssueComments): IssueComment { + let regex = /# *Team\'?s Response[\n\r]*[\s\S]*# Items for the Tester to Verify/gi; + if (this.phaseService.currentPhase === Phase.phaseModeration) { + regex = /# Tutor Moderation[\n\r]*#{2} *:question: *.*[\n\r]*.*[\n\r]*[\s\S]*?(?=-{19})/gi; + } + + for (const comment of issueComments.comments) { + const matched = regex.exec(comment.description); + if (matched) { + return comment; + } + } } private parseDuplicateOfValue(toParse: string): number { @@ -428,19 +495,87 @@ export class IssueService { } } - private parseTesterResponse(toParse: string): TesterResponse[] { + // Template url: https://github.com/CATcher-org/templates#items-for-the-tester-to-verify + parseTesterResponse(toParse: string): TesterResponse[] { let matches; const testerResponses: TesterResponse[] = []; - const regex = /(## \d.*)[\r\n]*(.*)[\r\n]*(.*)[\r\n]*\*\*Reason for disagreement:\*\* ([\s\S]*?(?=-------------------))/gi; + const regex: RegExp = new RegExp('#{2} *:question: *([\\w ]+)[\\r\\n]*(Team Chose.*[\\r\\n]* *Originally.*' + + '|Team Chose.*[\\r\\n]*)[\\r\\n]*(- \\[x? ?\\] I disagree)[\\r\\n]*\\*\\*Reason *for *disagreement:\\*\\* *([\\s\\S]*?)-{19}', + 'gi'); while (matches = regex.exec(toParse)) { if (matches && matches.length > this.MINIMUM_MATCHES) { const [regexString, title, description, disagreeCheckbox, reasonForDiagreement] = matches; - testerResponses.push(new TesterResponse(title, description, disagreeCheckbox, reasonForDiagreement)); + testerResponses.push(new TesterResponse(title, description, disagreeCheckbox, reasonForDiagreement.trim())); } } return testerResponses; } + // Template url: https://github.com/CATcher-org/templates#teams-response-1 + parseTeamResponse(toParse: string): string { + let teamResponse = ''; + const regex = /# *Team\'?s Response[\n\r]*([\s\S]*)# Items for the Tester to Verify/gi; + const matches = regex.exec(toParse); + + if (matches && matches.length > this.MINIMUM_MATCHES) { + teamResponse = matches[1].trim(); + } + return teamResponse; + } + + // Template url: https://github.com/CATcher-org/templates#disputes + parseIssueDisputes(toParse: string): IssueDispute[] { + let matches; + const issueDisputes: IssueDispute[] = []; + const regex = /#{2} *:question: *(.*)[\r\n]*([\s\S]*?(?=-{19}))/gi; + while (matches = regex.exec(toParse)) { + if (matches && matches.length > this.MINIMUM_MATCHES) { + const [regexString, title, description] = matches; + issueDisputes.push(new IssueDispute(title, description.trim())); + } + } + return issueDisputes; + } + + // Template url: https://github.com/CATcher-org/templates#tutor-moderations + parseTutorResponseInComment(toParse: string, issueDispute: IssueDispute[]): IssueDispute[] { + let matches, i = 0; + const regex = /#{2} *:question: *.*[\n\r]*(.*)[\n\r]*([\s\S]*?(?=-{19}))/gi; + while (matches = regex.exec(toParse)) { + if (matches && matches.length > this.MINIMUM_MATCHES) { + const [regexString, todo, tutorResponse] = matches; + issueDispute[i].todo = todo; + issueDispute[i].tutorResponse = tutorResponse.trim(); + i++; + } + } + return issueDispute; + } + + // Template url: https://github.com/CATcher-org/templates#tutor-moderations + getToDoList(issueComment: IssueComment, issueDisputes: IssueDispute[]): string[] { + let matches; + const toDoList: string[] = []; + const regex = /- .* Done/gi; + + if (this.userService.currentUser.role !== this.userRole.Tutor) { + return toDoList; + } + + if (!issueComment && issueDisputes) { + for (const dispute of issueDisputes) { + toDoList.push(dispute.todo); + } + return toDoList; + } + + while (matches = regex.exec(issueComment.description)) { + if (matches) { + toDoList.push(matches[0]); + } + } + return toDoList; + } /** * Based on the kind labels specified in `desiredLabels` field, this function will produce a neatly formatted JSON object. @@ -470,6 +605,11 @@ export class IssueService { ...result, duplicated: true, }; + } else if (label['name'] === 'unsure') { + result = { + ...result, + unsure: true, + }; } else if (desiredLabels.includes(labelType)) { result = { ...result, diff --git a/src/app/phase-moderation/issue/issue.component.ts b/src/app/phase-moderation/issue/issue.component.ts index 4763465fc..dd369f46f 100644 --- a/src/app/phase-moderation/issue/issue.component.ts +++ b/src/app/phase-moderation/issue/issue.component.ts @@ -16,15 +16,12 @@ export class IssueComponent implements OnInit { readonly issueComponents: ISSUE_COMPONENTS[] = [ ISSUE_COMPONENTS.TESTER_POST, ISSUE_COMPONENTS.TEAM_RESPONSE, - ISSUE_COMPONENTS.TUTOR_RESPONSE, - ISSUE_COMPONENTS.NEW_TUTOR_RESPONSE, - ISSUE_COMPONENTS.TESTER_RESPONSE, + ISSUE_COMPONENTS.ISSUE_DISPUTE, ISSUE_COMPONENTS.SEVERITY_LABEL, ISSUE_COMPONENTS.TYPE_LABEL, ISSUE_COMPONENTS.RESPONSE_LABEL, - ISSUE_COMPONENTS.ASSIGNEE, ISSUE_COMPONENTS.DUPLICATE, - ISSUE_COMPONENTS.TODO_LIST + ISSUE_COMPONENTS.UNSURE_CHECKBOX ]; @ViewChild(ViewIssueComponent) viewIssue: ViewIssueComponent; diff --git a/src/app/phase-tester-response/issue-pending/issue-pending.component.ts b/src/app/phase-tester-response/issue-pending/issue-pending.component.ts index be01a21c7..f744b4555 100644 --- a/src/app/phase-tester-response/issue-pending/issue-pending.component.ts +++ b/src/app/phase-tester-response/issue-pending/issue-pending.component.ts @@ -23,11 +23,14 @@ export class IssuePendingComponent implements OnInit { ACTION_BUTTONS.RESPOND_TO_ISSUE, ACTION_BUTTONS.MARK_AS_RESPONDED ]; - readonly filter: (issue: Issue) => boolean = (issue: Issue) => (!issue.status || issue.status === STATUS.Incomplete); + filter: (issue: Issue) => boolean; constructor() { } ngOnInit() { + this.filter = (issue: Issue) => { + return (!issue.status || issue.status === STATUS.Incomplete) && !!issue.issueComment; + }; } applyFilter(filterValue: string) { diff --git a/src/app/phase-tester-response/issue-responded/issue-responded.component.ts b/src/app/phase-tester-response/issue-responded/issue-responded.component.ts index a57992a75..7f45f4f1c 100644 --- a/src/app/phase-tester-response/issue-responded/issue-responded.component.ts +++ b/src/app/phase-tester-response/issue-responded/issue-responded.component.ts @@ -21,11 +21,14 @@ export class IssueRespondedComponent implements OnInit { ACTION_BUTTONS.VIEW_IN_WEB, ACTION_BUTTONS.MARK_AS_PENDING ]; - readonly filter: (issue: Issue) => boolean = (issue: Issue) => (issue.status === STATUS.Done); + filter: (issue: Issue) => boolean; constructor() { } ngOnInit() { + this.filter = (issue: Issue) => { + return (issue.status === STATUS.Done) && !!issue.issueComment; + }; } applyFilter(filterValue: string) { diff --git a/src/app/shared/issue-dispute/issue-dispute.component.css b/src/app/shared/issue-dispute/issue-dispute.component.css new file mode 100644 index 000000000..bb790d47c --- /dev/null +++ b/src/app/shared/issue-dispute/issue-dispute.component.css @@ -0,0 +1,4 @@ +.container { + padding: 10px 20px 0 20px; + display: grid; +} diff --git a/src/app/shared/issue-dispute/issue-dispute.component.html b/src/app/shared/issue-dispute/issue-dispute.component.html new file mode 100644 index 000000000..34d2dac62 --- /dev/null +++ b/src/app/shared/issue-dispute/issue-dispute.component.html @@ -0,0 +1,47 @@ +

Disputes

+
+
+
+ Post your response here. + +
+
+
+
+
?
+ +
+
+ +
+
+ + Done + +
+
+
+ + +
+
+ +
+

+
+ +
+ + +
+
+
+
diff --git a/src/app/shared/issue-dispute/issue-dispute.component.ts b/src/app/shared/issue-dispute/issue-dispute.component.ts new file mode 100644 index 000000000..23648dde1 --- /dev/null +++ b/src/app/shared/issue-dispute/issue-dispute.component.ts @@ -0,0 +1,153 @@ +import { Component, OnInit, EventEmitter, Input, Output, ViewChild } from '@angular/core'; +import { FormGroup, FormBuilder, FormControl, Validators } from '@angular/forms'; +import { Issue, STATUS } from '../../core/models/issue.model'; +import { IssueComment } from '../../core/models/comment.model'; +import { CommentEditorComponent } from '../comment-editor/comment-editor.component'; +import { IssueService } from '../../core/services/issue.service'; +import { IssueCommentService } from '../../core/services/issue-comment.service'; +import { UserService } from '../../core/services/user.service'; +import { ErrorHandlingService } from '../../core/services/error-handling.service'; +import { finalize } from 'rxjs/operators'; +import { UserRole } from '../../core/models/user.model'; + +@Component({ + selector: 'app-issue-dispute', + templateUrl: './issue-dispute.component.html', + styleUrls: ['./issue-dispute.component.css'] +}) +export class IssueDisputeComponent implements OnInit { + tutorResponseForm: FormGroup; + isFormPending = false; + isEditing = false; + + @Input() issue: Issue; + @Output() issueUpdated = new EventEmitter(); + @Output() commentUpdated = new EventEmitter(); + @ViewChild(CommentEditorComponent) commentEditor: CommentEditorComponent; + + constructor(private formBuilder: FormBuilder, + private issueService: IssueService, + private issueCommentService: IssueCommentService, + public userService: UserService, + private errorHandlingService: ErrorHandlingService) { } + + ngOnInit() { + const group: any = {}; + for (let i = 0; i < this.issue.issueDisputes.length; i++) { + group[i.toString()] = new FormControl(Validators.required); + } + this.tutorResponseForm = this.formBuilder.group(group); + + if (this.isNewResponse()) { + this.isEditing = true; + } + } + + submitTutorResponseForm() { + if (this.tutorResponseForm.invalid) { + return; + } + this.isFormPending = true; + + this.issue.pending = '' + this.getNumOfPending(); + this.issue.todoList = this.getToDoList(); + + // Update tutor's response in the issue comment + if (this.issue.issueComment) { + this.issue.issueComment.description = this.issueCommentService. + createGithubTutorResponse(this.issue.issueDisputes); + + this.issueCommentService.updateIssueComment(this.issue.issueComment).subscribe( + (updatedComment) => { + this.isFormPending = false; + this.isEditing = false; + this.commentUpdated.emit(updatedComment); + }, (error) => { + this.errorHandlingService.handleHttpError(error); + }); + } else { + const issueCommentDescription = this.issueCommentService + .createGithubTutorResponse(this.issue.issueDisputes); + + this.issueCommentService.createIssueComment(this.issue.id, issueCommentDescription).subscribe( + (newComment) => { + this.isFormPending = false; + this.isEditing = false; + this.issue.issueComment = newComment; + this.commentUpdated.emit(newComment); + }, + (error) => { + this.errorHandlingService.handleHttpError(error); + }); + } + + this.issueService.updateIssue(this.issue).subscribe( + (updatedIssue) => { + this.issueUpdated.emit(updatedIssue); + }, + (error) => { + this.errorHandlingService.handleHttpError(error); + }); + } + + changeToEditMode() { + this.isEditing = true; + } + + cancelEditMode() { + this.isEditing = false; + } + + handleChangeOfText(event, disagree, index) { + if (event.target.value !== disagree) { + this.issue.issueDisputes[index].tutorResponse = event.target.value; + } + } + + handleChangeOfTodoCheckbox(event, todo, index) { + if (event.checked) { + this.issue.issueDisputes[index].todo = '- [x]' + todo.substring(5); + } else { + this.issue.issueDisputes[index].todo = '- [ ]' + todo.substring(5); + } + } + + isTodoChecked(todo): boolean { + return todo.charAt(3) === 'x'; + } + + trackDisputeList(index: number, item: string[]): string { + return item[index]; + } + + isNewResponse(): boolean { + return !this.issue.issueComment; + } + + getSubmitButtonText(): string { + return this.isNewResponse() ? 'Submit' : 'Save'; + } + + getItemTitleText(title: string): string { + return '## ' + title; + } + + getToDoList(): string[] { + const toDoList: string[] = []; + for (const issueDispute of this.issue.issueDisputes) { + toDoList.push(issueDispute.todo); + } + return toDoList; + } + + getNumOfPending(): number { + let pending = this.issue.issueDisputes.length; // Initial pending is number of disputes + for (const issueDispute of this.issue.issueDisputes) { + // For each number of Done that is checked, reduce pending by one + if (this.isTodoChecked(issueDispute.todo)) { + pending--; + } + } + return pending; + } +} diff --git a/src/app/shared/issue-tables/IssuesDataTable.ts b/src/app/shared/issue-tables/IssuesDataTable.ts index a32e1e2bd..b818d4328 100644 --- a/src/app/shared/issue-tables/IssuesDataTable.ts +++ b/src/app/shared/issue-tables/IssuesDataTable.ts @@ -56,14 +56,14 @@ export class IssuesDataTable extends DataSource { if (this.defaultFilter) { data = data.filter(this.defaultFilter); } - data = this.getSortedData(data); data = this.getFilteredTeamData(data); data = this.getFilteredData(data); data = this.getPaginatedData(data); return data; - })); + }) + ); }) ).subscribe((issues) => { this.issuesSubject.next(issues); diff --git a/src/app/shared/issue-tables/issue-tables.component.ts b/src/app/shared/issue-tables/issue-tables.component.ts index e480f0ef5..7496d392d 100644 --- a/src/app/shared/issue-tables/issue-tables.component.ts +++ b/src/app/shared/issue-tables/issue-tables.component.ts @@ -93,7 +93,7 @@ export class IssueTablesComponent implements OnInit { } isTodoListExists(issue): boolean { - return issue.todoList.length !== 0; + return issue.todoList; } todoFinished(issue): number { diff --git a/src/app/shared/issue/issue-components.module.ts b/src/app/shared/issue/issue-components.module.ts index 35a082e3c..5b48b18c1 100644 --- a/src/app/shared/issue/issue-components.module.ts +++ b/src/app/shared/issue/issue-components.module.ts @@ -11,6 +11,7 @@ import {AssigneeComponent} from './assignee/assignee.component'; import {DuplicateOfComponent} from './duplicateOf/duplicate-of.component'; import {DuplicatedIssuesComponent} from './duplicatedIssues/duplicated-issues.component'; import { TodoListComponent } from './todo-list/todo-list.component'; +import { UnsureCheckboxComponent } from './unsure-checkbox/unsure-checkbox.component'; @NgModule({ imports: [ @@ -28,6 +29,7 @@ import { TodoListComponent } from './todo-list/todo-list.component'; DuplicateOfComponent, DuplicatedIssuesComponent, TodoListComponent, + UnsureCheckboxComponent, ], exports: [ TitleComponent, @@ -38,6 +40,7 @@ import { TodoListComponent } from './todo-list/todo-list.component'; DuplicateOfComponent, DuplicatedIssuesComponent, TodoListComponent, + UnsureCheckboxComponent, ] }) export class IssueComponentsModule { } diff --git a/src/app/shared/issue/todo-list/todo-list.component.ts b/src/app/shared/issue/todo-list/todo-list.component.ts index b736a5733..a96fffbfe 100644 --- a/src/app/shared/issue/todo-list/todo-list.component.ts +++ b/src/app/shared/issue/todo-list/todo-list.component.ts @@ -76,7 +76,7 @@ export class TodoListComponent implements OnInit { } get isTodoListExists(): boolean { - return this.issue.todoList.length !== 0; + return !!this.issue.todoList; } get isTodoListChecked(): boolean { diff --git a/src/app/shared/issue/unsure-checkbox/unsure-checkbox.component.css b/src/app/shared/issue/unsure-checkbox/unsure-checkbox.component.css new file mode 100644 index 000000000..7af79d8f1 --- /dev/null +++ b/src/app/shared/issue/unsure-checkbox/unsure-checkbox.component.css @@ -0,0 +1,4 @@ +.mat-checkbox-disabled .mat-checkbox-label { + color: black; + } + \ No newline at end of file diff --git a/src/app/shared/issue/unsure-checkbox/unsure-checkbox.component.html b/src/app/shared/issue/unsure-checkbox/unsure-checkbox.component.html new file mode 100644 index 000000000..6999241f2 --- /dev/null +++ b/src/app/shared/issue/unsure-checkbox/unsure-checkbox.component.html @@ -0,0 +1,3 @@ + + Unsure + diff --git a/src/app/shared/issue/unsure-checkbox/unsure-checkbox.component.ts b/src/app/shared/issue/unsure-checkbox/unsure-checkbox.component.ts new file mode 100644 index 000000000..af58c6297 --- /dev/null +++ b/src/app/shared/issue/unsure-checkbox/unsure-checkbox.component.ts @@ -0,0 +1,40 @@ +import { Component, OnInit, Input, Output, EventEmitter } from '@angular/core'; +import { Issue } from '../../../core/models/issue.model'; +import { IssueService } from '../../../core/services/issue.service'; +import { ErrorHandlingService } from '../../../core/services/error-handling.service'; + +@Component({ + selector: 'app-unsure-checkbox', + templateUrl: './unsure-checkbox.component.html', + styleUrls: ['./unsure-checkbox.component.css'] +}) +export class UnsureCheckboxComponent implements OnInit { + + @Input() issue: Issue; + + @Output() issueUpdated = new EventEmitter(); + + constructor(private issueService: IssueService, + private errorHandlingService: ErrorHandlingService) { } + + ngOnInit() { + } + + handleChangeOfUnsureCheckbox(event) { + let UNSURE = false; + + if (event.checked) { + UNSURE = true; + } + + this.issueService.updateIssue({ + ...this.issue, + unsure: UNSURE, + }).subscribe((updatedIssue: Issue) => { + this.issueUpdated.emit(updatedIssue); + }, (error) => { + this.errorHandlingService.handleHttpError(error); + }); + } + +} diff --git a/src/app/shared/layout/header.component.ts b/src/app/shared/layout/header.component.ts index 5db669a28..2519fb86e 100644 --- a/src/app/shared/layout/header.component.ts +++ b/src/app/shared/layout/header.component.ts @@ -11,6 +11,7 @@ import { IssueService } from '../../core/services/issue.service'; import { shell } from 'electron'; import { GithubService } from '../../core/services/github.service'; import { UserRole } from '../../core/models/user.model'; +import { IssueCommentService } from '../../core/services/issue-comment.service'; @Component({ selector: 'app-layout-header', @@ -26,7 +27,8 @@ export class HeaderComponent implements OnInit { constructor(private router: Router, public auth: AuthService, public phaseService: PhaseService, public userService: UserService, private location: Location, private githubEventService: GithubEventService, private issueService: IssueService, - private errorHandlingService: ErrorHandlingService, private githubService: GithubService) { + private errorHandlingService: ErrorHandlingService, private githubService: GithubService, + private issueCommentService: IssueCommentService) { router.events.pipe( filter((e: any) => e instanceof RoutesRecognized), pairwise() @@ -54,6 +56,7 @@ export class HeaderComponent implements OnInit { // Remove current phase issues and load selected phase issues. this.issueService.reset(); + this.issueCommentService.reset(); this.reload(); // Route app to new phase. diff --git a/src/app/shared/tester-response/tester-response.component.html b/src/app/shared/tester-response/tester-response.component.html index 257771af8..204f67700 100644 --- a/src/app/shared/tester-response/tester-response.component.html +++ b/src/app/shared/tester-response/tester-response.component.html @@ -10,7 +10,11 @@

Tester's Response

- +
+
?
+ +


diff --git a/src/app/shared/tester-response/tester-response.component.ts b/src/app/shared/tester-response/tester-response.component.ts index b50317a94..a7eb9f329 100644 --- a/src/app/shared/tester-response/tester-response.component.ts +++ b/src/app/shared/tester-response/tester-response.component.ts @@ -7,6 +7,8 @@ import { finalize } from 'rxjs/operators'; import { ErrorHandlingService } from '../../core/services/error-handling.service'; import { UserService } from '../../core/services/user.service'; import { UserRole } from '../../core/models/user.model'; +import { IssueCommentService } from '../../core/services/issue-comment.service'; +import { IssueComment } from '../../core/models/comment.model'; @Component({ selector: 'app-tester-response', @@ -21,10 +23,12 @@ export class TesterResponseComponent implements OnInit { @Input() issue: Issue; @Output() issueUpdated = new EventEmitter(); + @Output() commentUpdated = new EventEmitter(); @ViewChild(CommentEditorComponent) commentEditor: CommentEditorComponent; constructor(private formBuilder: FormBuilder, private issueService: IssueService, + private issueCommentService: IssueCommentService, public userService: UserService, private errorHandlingService: ErrorHandlingService) { } @@ -57,10 +61,26 @@ export class TesterResponseComponent implements OnInit { this.isFormPending = false; this.isEditing = false; })).subscribe((updatedIssue) => { + updatedIssue.teamResponse = this.issue.teamResponse; + updatedIssue.testerResponses = this.issue.testerResponses; this.issueUpdated.emit(updatedIssue); }, (error) => { this.errorHandlingService.handleHttpError(error); }); + + // For Tester Response phase, where the items are in the issue's comment + if (this.issue.issueComment) { + this.issue.issueComment.description = this.issueCommentService. + createGithubTesterResponse(this.issue.teamResponse, this.issue.testerResponses); + + this.issueCommentService.updateIssueComment(this.issue.issueComment).subscribe( + (updatedComment) => { + this.commentUpdated.emit(updatedComment); + }, (error) => { + this.errorHandlingService.handleHttpError(error); + }); + } + } changeToEditMode() { @@ -73,7 +93,6 @@ export class TesterResponseComponent implements OnInit { handleChangeOfDisagreeCheckbox(event, disagree, index) { this.issue.testerResponses[index].disagreeCheckbox = ('- [').concat((event.checked ? 'x' : ' '), '] ', disagree.substring(6)); - console.log(this.issue.testerResponses[index].disagreeCheckbox); this.toggleCommentEditor(index, event.checked); } @@ -109,4 +128,8 @@ export class TesterResponseComponent implements OnInit { return this.isNewResponse() ? 'Submit' : 'Save'; } + getItemTitleText(title: string): string { + return '## ' + title; + } + } diff --git a/src/app/shared/view-issue/view-issue.component.html b/src/app/shared/view-issue/view-issue.component.html index 97870203d..6137c1e16 100644 --- a/src/app/shared/view-issue/view-issue.component.html +++ b/src/app/shared/view-issue/view-issue.component.html @@ -23,12 +23,23 @@ (issueUpdated)="updateIssue($event)"> +
+ +
+ + (issueUpdated)="updateIssue($event)" (commentUpdated)="updateComment($event)"> + + + +
@@ -90,6 +101,12 @@ * Need your resolution. An issue cannot have both duplicated issues and duplicated status.
+ +
+ + +
+

diff --git a/src/app/shared/view-issue/view-issue.component.ts b/src/app/shared/view-issue/view-issue.component.ts index ae606c3f5..51b9d6787 100644 --- a/src/app/shared/view-issue/view-issue.component.ts +++ b/src/app/shared/view-issue/view-issue.component.ts @@ -16,15 +16,17 @@ export enum ISSUE_COMPONENTS { TESTER_POST, TEAM_RESPONSE, NEW_TEAM_RESPONSE, - TUTOR_RESPONSE, - NEW_TUTOR_RESPONSE, + TUTOR_RESPONSE, // Old component, unused + NEW_TUTOR_RESPONSE, // Old component, unused TESTER_RESPONSE, + ISSUE_DISPUTE, SEVERITY_LABEL, TYPE_LABEL, RESPONSE_LABEL, ASSIGNEE, DUPLICATE, - TODO_LIST + TODO_LIST, + UNSURE_CHECKBOX } @Component({ @@ -34,13 +36,13 @@ export enum ISSUE_COMPONENTS { }) export class ViewIssueComponent implements OnInit, OnDestroy { issue: Issue; - comments: IssueComment[]; isIssueLoading = true; isCommentsLoading = true; isTutorResponseEditing = false; isIssueDescriptionEditing = false; isTeamResponseEditing = false; issueSubscription: Subscription; + issueCommentSubscription: Subscription; @Input() issueId: number; @Input() issueComponents: ISSUE_COMPONENTS[]; @@ -60,7 +62,7 @@ export class ViewIssueComponent implements OnInit, OnDestroy { this.route.params.subscribe( params => { this.initializeIssue(this.issueId); - this.initializeComments(this.issueId); + this.initializeComments(); } ); } @@ -91,6 +93,11 @@ export class ViewIssueComponent implements OnInit, OnDestroy { this.issueService.updateLocalStore(this.issue); } + updateComment(newComment: IssueComment) { + this.issue.issueComment = newComment; + this.issueService.updateLocalStore(this.issue); + } + updateDescriptionEditState(updatedState: boolean) { this.isIssueDescriptionEditing = updatedState; } @@ -103,13 +110,30 @@ export class ViewIssueComponent implements OnInit, OnDestroy { this.isTutorResponseEditing = updatedState; } - private initializeComments(id: number) { - this.issueCommentService.getIssueComments(id).pipe(finalize(() => this.isCommentsLoading = false)) - .subscribe((issueComments: IssueComments) => { - this.comments = issueComments.comments; - }, (error) => { - this.errorHandlingService.handleHttpError(error, () => this.initializeComments(id)); - }); + setTeamAndTesterResponse() { + this.issue.teamResponse = this.issueService.parseTeamResponse(this.issue.issueComment.description); + this.issue.testerResponses = this.issueService.parseTesterResponse(this.issue.issueComment.description); + } + + private initializeComments() { + this.isCommentsLoading = false; + // If there is no comment in the issue, don't need to continue + if (!this.issue.issueComment) { + return; + } + // For Tester Response Phase, where team and tester response items are in the issue's comment + if (!this.issue.teamResponse && this.userService.currentUser.role === this.userRole.Student) { + this.setTeamAndTesterResponse(); + } + // For Moderation Phase, where tutor responses are in the issue's comment + if (this.issue.issueDisputes && this.userService.currentUser.role === this.userRole.Tutor) { + this.setTutorResponse(); + } + } + + setTutorResponse() { + this.issue.issueDisputes = + this.issueService.parseTutorResponseInComment(this.issue.issueComment.description, this.issue.issueDisputes); } ngOnDestroy() { diff --git a/src/app/shared/view-issue/view-issue.module.ts b/src/app/shared/view-issue/view-issue.module.ts index cfcbea698..a24e517e3 100644 --- a/src/app/shared/view-issue/view-issue.module.ts +++ b/src/app/shared/view-issue/view-issue.module.ts @@ -9,6 +9,7 @@ import { SharedModule } from '../shared.module'; import { IssueComponentsModule } from '../issue/issue-components.module'; import { LabelDropdownModule } from '../label-dropdown/label-dropdown.module'; import { TesterResponseComponent } from '../tester-response/tester-response.component'; +import { IssueDisputeComponent } from '../issue-dispute/issue-dispute.component'; @NgModule({ exports: [ @@ -18,6 +19,7 @@ import { TesterResponseComponent } from '../tester-response/tester-response.comp NewTutorResponseComponent, NewTeamResponseComponent, TesterResponseComponent, + IssueDisputeComponent, ViewIssueComponent ], imports: [