diff options
Diffstat (limited to 'src/client/util/reportManager/ReportManager.tsx')
-rw-r--r-- | src/client/util/reportManager/ReportManager.tsx | 595 |
1 files changed, 595 insertions, 0 deletions
diff --git a/src/client/util/reportManager/ReportManager.tsx b/src/client/util/reportManager/ReportManager.tsx new file mode 100644 index 000000000..b65ada7ec --- /dev/null +++ b/src/client/util/reportManager/ReportManager.tsx @@ -0,0 +1,595 @@ +import * as React from 'react'; +import '.././SettingsManager.scss'; +import './ReportManager.scss'; +import { action, observable } from 'mobx'; +import { BsX, BsArrowsAngleExpand, BsArrowsAngleContract } from 'react-icons/bs'; +import { CgClose } from 'react-icons/cg'; +import { AiOutlineUpload } from 'react-icons/ai'; +import { HiOutlineArrowLeft } from 'react-icons/hi'; +import { Issue } from './reportManagerSchema'; +import { observer } from 'mobx-react'; +import { Doc } from '../../../fields/Doc'; +import { Networking } from '../../Network'; +import { MainViewModal } from '../../views/MainViewModal'; +import { Octokit } from '@octokit/core'; +import { Button, IconButton, OrientationType, Type } from 'browndash-components'; +import Dropzone from 'react-dropzone'; +import { theme } from '../../theme'; +import ReactLoading from 'react-loading'; +import v4 = require('uuid/v4'); +import { BugType, FileData, Priority, ViewState, inactiveBorderColor, inactiveColor } from './reportManagerUtils'; +import { IssueCard, IssueView, Tag } from './ReportManagerComponents'; +const higflyout = require('@hig/flyout'); +export const { anchorPoints } = higflyout; +export const Flyout = higflyout.default; + +/** + * Class for reporting and viewing Github issues within the app. + */ +@observer +export class ReportManager extends React.Component<{}> { + public static Instance: ReportManager; + @observable private isOpen = false; + + @observable private query = ''; + @action private setQuery = (q: string) => { + this.query = q; + }; + + private octokit: Octokit; + + @observable viewState: ViewState = ViewState.VIEW; + @action private setViewState = (state: ViewState) => { + this.viewState = state; + }; + @observable submitting: boolean = false; + @action private setSubmitting = (submitting: boolean) => { + this.submitting = submitting; + }; + + @observable fetchingIssues: boolean = false; + @action private setFetchingIssues = (fetching: boolean) => { + this.fetchingIssues = fetching; + }; + + @observable + public shownIssues: Issue[] = []; + @action setShownIssues = action((issues: Issue[]) => { + this.shownIssues = issues; + }); + + @observable + public priorityFilter: Priority | null = null; + @action setPriorityFilter = action((priority: Priority | null) => { + this.priorityFilter = priority; + }); + + @observable + public bugFilter: BugType | null = null; + @action setBugFilter = action((bug: BugType | null) => { + this.bugFilter = bug; + }); + + @observable selectedIssue: Issue | undefined = undefined; + @action setSelectedIssue = action((issue: Issue | undefined) => { + this.selectedIssue = issue; + }); + + @observable rightExpanded: boolean = false; + @action setRightExpanded = action((expanded: boolean) => { + this.rightExpanded = expanded; + }); + + // Form state + + @observable private bugTitle = ''; + @action setBugTitle = action((title: string) => { + this.bugTitle = title; + }); + @observable private bugDescription = ''; + @action setBugDescription = action((description: string) => { + this.bugDescription = description; + }); + @observable private bugType = ''; + @action setBugType = action((type: string) => { + this.bugType = type; + }); + @observable private bugPriority = ''; + @action setBugPriority = action((priortiy: string) => { + this.bugPriority = priortiy; + }); + + @observable private mediaFiles: FileData[] = []; + @action private setMediaFiles = (files: FileData[]) => { + this.mediaFiles = files; + }; + + public close = action(() => (this.isOpen = false)); + public open = action(async () => { + this.isOpen = true; + if (this.shownIssues.length === 0) { + this.setFetchingIssues(true); + try { + // load in the issues if not already loaded + const issues = (await this.getAllIssues()) as Issue[]; + // filtering to include only open issues and exclude pull requests, maybe add a separate tab for pr's? + this.setShownIssues(issues.filter(issue => issue.state === 'open' && !issue.pull_request)); + } catch (err) { + console.log(err); + } + this.setFetchingIssues(false); + } + }); + + constructor(props: {}) { + super(props); + ReportManager.Instance = this; + + // initializing Github connection + this.octokit = new Octokit({ + auth: process.env.GITHUB_ACCESS_TOKEN, + }); + } + + /** + * Fethches issues from Github. + * @returns array of all issues + */ + public async getAllIssues(): Promise<any[]> { + const res = await this.octokit.request('GET /repos/{owner}/{repo}/issues', { + owner: 'brown-dash', + repo: 'Dash-Web', + per_page: 80, + }); + + // 200 status means success + if (res.status === 200) { + return res.data; + } else { + throw new Error('Error getting issues'); + } + } + + /** + * Sends a request to Github to report a new issue with the form data. + * @returns nothing + */ + public async reportIssue(): Promise<void> { + if (this.bugTitle === '' || this.bugDescription === '' || this.bugType === '' || this.bugPriority === '') { + alert('Please fill out all required fields to report an issue.'); + return; + } + this.setSubmitting(true); + + const body = { + email: 'aaa@gmail.com', + password: '1234', + }; + + //s%3A7pO_IijgvVbSaI3m2Tn6un4oQPHQYTBe.CsIRpBpvzy1AXbyQNXDz%2FCpo4M9A47aw%2F%2BQbCp9lXmc + + const res = await fetch('http://10.38.71.246:1050/headlesslogin', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify(body), + }); + + console.log(res); + const links = await this.uploadFilesToServer(); + console.log(links); + if (!links) { + // error uploading files to the server + return; + } + const formattedLinks = (links ?? []).map(this.fileLinktoServerLink); + + const req = await this.octokit.request('POST /repos/{owner}/{repo}/issues', { + owner: 'brown-dash', + repo: 'Dash-Web', + title: this.formatTitle(this.bugTitle, Doc.CurrentUserEmail), + body: `${this.bugDescription} ${formattedLinks.length > 0 && `\n\nFiles:\n${formattedLinks.join('\n')}`}`, + labels: ['from-dash-app', this.bugType, this.bugPriority], + }); + + // 201 status means success + if (req.status !== 201) { + alert('Error creating issue on github.'); + return; + } + + // Reset fields + this.setBugTitle(''); + this.setBugDescription(''); + this.setMediaFiles([]); + this.setBugType(''); + this.setBugPriority(''); + this.setSubmitting(false); + this.setFetchingIssues(true); + try { + // load in the issues if not already loaded + const issues = (await this.getAllIssues()) as Issue[]; + // filtering to include only open issues and exclude pull requests, maybe add a separate tab for pr's? + this.setShownIssues(issues.filter(issue => issue.state === 'open' && !issue.pull_request)); + } catch (err) { + console.log(err); + } + this.setFetchingIssues(false); + alert('Successfully submitted issue.'); + } + + /** + * Formats issue title. + * + * @param title title of issue + * @param userEmail email of issue submitter + * @returns formatted title + */ + private formatTitle = (title: string, userEmail: string): string => `${title} - ${userEmail.replace('@brown.edu', '')}`; + + // turns an upload link -> server link + // ex: + // C: /Users/dash/Documents/GitHub/Dash-Web/src/server/public/files/images/upload_8008dbc4b6424fbff14da7345bb32eb2.png + // -> https://browndash.com/files/images/upload_8008dbc4b6424fbff14da7345bb32eb2_l.png + private fileLinktoServerLink = (fileLink: string) => { + const serverUrl = 'https://browndash.com/'; + + const regex = 'public'; + const publicIndex = fileLink.indexOf(regex) + regex.length; + + const finalUrl = `${serverUrl}${fileLink.substring(publicIndex + 1).replace('.', '_l.')}`; + return finalUrl; + }; + + /** + * Gets the server file path. + * + * @param link response from file upload + * @returns server file path + */ + private getServerPath = (link: any): string => { + return link.result.accessPaths.agnostic.server as string; + }; + + /** + * Uploads media files to the server. + * @returns the server paths or undefined on error + */ + private uploadFilesToServer = async (): Promise<string[] | undefined> => { + try { + // need to always upload to browndash + const links = await Networking.UploadFilesToServer( + this.mediaFiles.map(file => ({ file: file.file })), + true + ); + return (links ?? []).map(this.getServerPath); + } catch (err) { + if (err instanceof Error) { + alert(err.message); + } else { + alert(err); + } + } + }; + + /** + * Handles file upload. + * + * @param files uploaded files + */ + private onDrop = (files: File[]) => { + this.setMediaFiles([...this.mediaFiles, ...files.map(file => ({ _id: v4(), file }))]); + }; + + /** + * Returns when the issue passes the current filters. + * + * @param issue issue to check + * @returns boolean indicating whether the issue passes the current filters + */ + private passesTagFilter = (issue: Issue) => { + let passesPriority = true; + let passesBug = true; + if (this.priorityFilter) { + passesPriority = issue.labels.some(label => { + if (typeof label === 'string') { + return label === this.priorityFilter; + } else { + return label.name === this.priorityFilter; + } + }); + } + if (this.bugFilter) { + passesBug = issue.labels.some(label => { + if (typeof label === 'string') { + return label === this.bugFilter; + } else { + return label.name === this.bugFilter; + } + }); + } + return passesPriority && passesBug; + }; + + /** + * Gets a JSX element to render a media preview + * @param fileData file data + * @returns JSX element of a piece of media (image, video, audio) + */ + private getMediaPreview = (fileData: FileData): JSX.Element => { + const file = fileData.file; + const mimeType = file.type; + const preview = URL.createObjectURL(file); + + if (mimeType.startsWith('image/')) { + return ( + <div key={fileData._id} className="report-media-wrapper"> + <div className="report-media-content"> + <img height={100} alt={`Preview of ${file.name}`} src={preview} style={{ display: 'block' }} /> + </div> + <div className="close-btn"> + <IconButton icon={<BsX color="#ffffff" />} onClick={() => this.setMediaFiles(this.mediaFiles.filter(f => f._id !== fileData._id))} /> + </div> + </div> + ); + } else if (mimeType.startsWith('video/')) { + return ( + <div key={fileData._id} className="report-media-wrapper"> + <div className="report-media-content"> + <video className="report-default-video" controls style={{ height: '100px', width: 'auto', display: 'block' }}> + <source src={preview} type="video/mp4" /> + Your browser does not support the video tag. + </video> + </div> + <div className="close-btn"> + <IconButton icon={<BsX color="#ffffff" />} onClick={() => this.setMediaFiles(this.mediaFiles.filter(f => f._id !== fileData._id))} /> + </div> + </div> + ); + } else if (mimeType.startsWith('audio/')) { + return ( + <div key={fileData._id} className="report-audio-wrapper"> + <audio src={preview} controls /> + <div className="close-btn"> + <IconButton icon={<BsX color="#ffffff" />} onClick={() => this.setMediaFiles(this.mediaFiles.filter(f => f._id !== fileData._id))} /> + </div> + </div> + ); + } + return <></>; + }; + + /** + * @returns the component that dispays all issues + */ + private viewIssuesComponent = () => { + return ( + <div className="view-issues"> + <div className="left" style={{ display: this.rightExpanded ? 'none' : 'flex' }}> + <div className="report-header"> + <h2>Open Issues</h2> + <Button + type={Type.PRIM} + text="report issue" + onClick={() => { + this.setViewState(ViewState.CREATE); + }} + /> + </div> + <input + className="report-input" + type="text" + placeholder="Filter by query..." + onChange={e => { + this.setQuery(e.target.value); + }} + required + /> + <div className="issues-filters"> + <div className="issues-filter"> + <Tag + text={'All'} + onClick={() => { + this.setPriorityFilter(null); + }} + fontSize="12px" + backgroundColor={this.priorityFilter === null ? theme.palette.primary.main : '#ffffff'} + color={this.priorityFilter === null ? '#ffffff' : inactiveColor} + border + borderColor={this.priorityFilter === null ? theme.palette.primary.main : inactiveBorderColor} + /> + {Object.values(Priority).map(p => { + return ( + <Tag + key={p} + text={p} + onClick={() => { + this.setPriorityFilter(p); + }} + fontSize="12px" + backgroundColor={this.priorityFilter === p ? theme.palette.primary.main : '#ffffff'} + color={this.priorityFilter === p ? '#ffffff' : inactiveColor} + border + borderColor={this.priorityFilter === p ? theme.palette.primary.main : inactiveBorderColor} + /> + ); + })} + </div> + <div className="issues-filter"> + <Tag + text={'All'} + onClick={() => { + this.setBugFilter(null); + }} + fontSize="12px" + backgroundColor={this.bugFilter === null ? theme.palette.primary.main : '#ffffff'} + color={this.bugFilter === null ? '#ffffff' : inactiveColor} + border + borderColor={this.bugFilter === null ? theme.palette.primary.main : inactiveBorderColor} + /> + {Object.values(BugType).map(b => { + return ( + <Tag + key={b} + text={b} + onClick={() => { + this.setBugFilter(b); + }} + fontSize="12px" + backgroundColor={this.bugFilter === b ? theme.palette.primary.main : '#ffffff'} + color={this.bugFilter === b ? '#ffffff' : inactiveColor} + border + borderColor={this.bugFilter === b ? theme.palette.primary.main : inactiveBorderColor} + /> + ); + })} + </div> + </div> + <div className="issues"> + {this.fetchingIssues ? ( + <div style={{ flexGrow: 1, display: 'flex', justifyContent: 'center', alignItems: 'center' }}> + <ReactLoading type="spin" color={theme.palette.primary.main} width={50} height={50} /> + </div> + ) : ( + this.shownIssues + .filter(issue => issue.title.toLowerCase().includes(this.query)) + .filter(issue => this.passesTagFilter(issue)) + .map(issue => ( + <IssueCard + key={issue.number} + issue={issue} + onSelect={() => { + this.setSelectedIssue(issue); + }} + /> + )) + )} + </div> + </div> + <div className="right">{this.selectedIssue ? <IssueView key={this.selectedIssue.number} issue={this.selectedIssue} /> : <div>No issue selected</div>} </div> + <div style={{ position: 'absolute', top: '8px', right: '8px', display: 'flex', gap: '16px' }}> + <IconButton + tooltip={this.rightExpanded ? 'Minimize right side' : 'Expand right side'} + icon={this.rightExpanded ? <BsArrowsAngleContract size="16px" /> : <BsArrowsAngleExpand size="16px" />} + onClick={e => { + e.stopPropagation(); + this.setRightExpanded(!this.rightExpanded); + }} + /> + <IconButton tooltip="close" icon={<CgClose size="16px" />} onClick={this.close} /> + </div> + </div> + ); + }; + + /** + * @returns the form component for submitting issues + */ + private reportIssueComponent = () => { + return ( + <div className="report-issue"> + <div className="report-header-vertical"> + <Button + text="back to view" + icon={<HiOutlineArrowLeft color={theme.palette.primary.main} />} + iconPosition={OrientationType.LEFT} + onClick={() => { + this.setViewState(ViewState.VIEW); + }} + /> + <h2>Report an Issue</h2> + </div> + <div className="report-section"> + <label className="report-label">Please provide a title for the bug</label> + <input className="report-input" value={this.bugTitle} type="text" placeholder="Title..." onChange={e => this.setBugTitle(e.target.value)} required /> + </div> + <div className="report-section"> + <label className="report-label">Please leave a description for the bug and how it can be recreated</label> + <textarea className="report-textarea" value={this.bugDescription} placeholder="Description..." onChange={e => this.setBugDescription(e.target.value)} required /> + </div> + <div className="report-selects"> + <select className="report-select" name="bugType" onChange={e => (this.bugType = e.target.value)}> + <option value="" disabled selected> + Type + </option> + <option className="report-opt" value={BugType.BUG}> + Bug + </option> + <option className="report-opt" value={BugType.COSMETIC}> + Poor Design or Cosmetic + </option> + <option className="report-opt" value={BugType.DOCUMENTATION}> + Poor Documentation + </option> + <option className="report-opt" value={BugType.ENHANCEMENT}> + New feature or request + </option> + </select> + <select className="report-select" name="priority" onChange={e => (this.bugPriority = e.target.value)}> + <option className="report-opt" value="" disabled selected> + Priority + </option> + <option value={Priority.LOW}>Low</option> + <option value={Priority.MEDIUM}>Medium</option> + <option value={Priority.HIGH}>High</option> + </select> + </div> + <Dropzone + onDrop={this.onDrop} + accept={{ + 'image/*': ['.png', '.jpg', '.jpeg', '.gif'], + 'video/*': ['.mp4', '.mpeg', '.webm', '.mov'], + 'audio/mpeg': ['.mp3'], + 'audio/wav': ['.wav'], + 'audio/ogg': ['.ogg'], + }}> + {({ getRootProps, getInputProps }) => ( + <div {...getRootProps({ className: 'dropzone' })}> + <input {...getInputProps()} /> + <div className="dropzone-instructions"> + <AiOutlineUpload size={25} /> + <p>Drop or select media that shows the bug (optional)</p> + </div> + </div> + )} + </Dropzone> + {this.mediaFiles.length > 0 && <ul className="file-list">{this.mediaFiles.map(file => this.getMediaPreview(file))}</ul>} + <Button + text="submit" + icon={this.submitting ? <ReactLoading type="spin" color={'#ffffff'} width={20} height={20} /> : <></>} + iconPosition={OrientationType.RIGHT} + onClick={() => { + this.reportIssue(); + }} + /> + <div style={{ position: 'absolute', top: '4px', right: '4px' }}> + <IconButton tooltip="close" icon={<CgClose size={'16px'} />} onClick={this.close} /> + </div> + </div> + ); + }; + + /** + * @returns the component rendered to the modal + */ + private reportComponent = () => { + if (this.viewState === ViewState.VIEW) { + return this.viewIssuesComponent(); + } else { + return this.reportIssueComponent(); + } + }; + + render() { + return ( + <MainViewModal + contents={this.reportComponent()} + isDisplayed={this.isOpen} + interactive={true} + closeOnExternalClick={this.close} + dialogueBoxStyle={{ width: 'auto', minWidth: '300px', height: '85vh', maxHeight: '90vh', background: '#ffffff', borderRadius: '8px' }} + /> + ); + } +} |