diff options
Diffstat (limited to 'src/client/util/ReportManager.tsx')
-rw-r--r-- | src/client/util/ReportManager.tsx | 468 |
1 files changed, 236 insertions, 232 deletions
diff --git a/src/client/util/ReportManager.tsx b/src/client/util/ReportManager.tsx index 3eebb8f15..125d20876 100644 --- a/src/client/util/ReportManager.tsx +++ b/src/client/util/ReportManager.tsx @@ -13,7 +13,6 @@ import * as React from 'react'; import './SettingsManager.scss'; import './ReportManager.scss'; import { action, computed, observable, runInAction } from 'mobx'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { BsX } from 'react-icons/bs'; import { BiX } from 'react-icons/bi'; import { AiOutlineUpload } from 'react-icons/ai'; @@ -27,11 +26,11 @@ import { Octokit } from '@octokit/core'; import { Button, IconButton } from '@mui/material'; import { Oval } from 'react-loader-spinner'; import Dropzone from 'react-dropzone'; -import ReactLoading from 'react-loading'; import ReactMarkdown from 'react-markdown'; import rehypeRaw from 'rehype-raw'; import remarkGfm from 'remark-gfm'; import { theme } from '../theme'; +import v4 = require('uuid/v4'); const higflyout = require('@hig/flyout'); export const { anchorPoints } = higflyout; export const Flyout = higflyout.default; @@ -41,6 +40,16 @@ enum ViewState { CREATE, } +interface FileData { + _id: string; + file: File; +} + +// Format reference: "https://browndash.com/files/images/upload_cb31bc0fda59c96ca14193ec494f80cf_o.jpg" /> + +/** + * Class for reporting and viewing Github issues within the app. + */ @observer export class ReportManager extends React.Component<{}> { public static Instance: ReportManager; @@ -62,6 +71,11 @@ export class ReportManager extends React.Component<{}> { this.submitting = submitting; }; + @observable fetchingIssues: boolean = false; + @action private setFetchingIssues = (fetching: boolean) => { + this.fetchingIssues = fetching; + }; + @observable public shownIssues: Issue[] = []; @action setShownIssues = action((issues: Issue[]) => { @@ -73,34 +87,7 @@ export class ReportManager extends React.Component<{}> { this.selectedIssue = issue; }); - @observable private mediaFiles: File[] = []; - @action private setMediaFiles = (files: File[]) => { - this.mediaFiles = files; - }; - - constructor(props: {}) { - super(props); - ReportManager.Instance = this; - - this.octokit = new Octokit({ - auth: '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', - }); - } - - public close = action(() => (this.isOpen = false)); - public open = action(async () => { - this.isOpen = true; - if (this.shownIssues.length === 0) { - try { - // load in the issues if not already loaded - const issues = (await this.getAllIssues()) as Issue[]; - this.setShownIssues(issues.filter(issue => issue.state === 'open')); - // this.updateIssueSearch(); - } catch (err) { - console.log(err); - } - } - }); + // Form state @observable private bugTitle = ''; @action setBugTitle = action((title: string) => { @@ -119,20 +106,41 @@ export class ReportManager extends React.Component<{}> { this.bugPriority = priortiy; }); - private showReportIssueScreen = () => { - this.setSelectedIssue(undefined); + @observable private mediaFiles: FileData[] = []; + @action private setMediaFiles = (files: FileData[]) => { + this.mediaFiles = files; }; - private closeReportIssueScreen = () => { - this.setSelectedIssue(undefined); - }; + 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[]; + this.setShownIssues(issues.filter(issue => issue.state === 'open' && !issue.pull_request)); + } catch (err) { + console.log(err); + } + this.setFetchingIssues(false); + } + }); - // private toGithub = false; - // will always be set to true - no alterntive option yet - private toGithub = true; + constructor(props: {}) { + super(props); + ReportManager.Instance = this; - private formatTitle = (title: string, userEmail: string) => `${title} - ${userEmail.replace('@brown.edu', '')}`; + // initializing Github connection + this.octokit = new Octokit({ + auth: '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', + }); + } + /** + * 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', @@ -147,53 +155,42 @@ export class ReportManager extends React.Component<{}> { } } - // turns an upload link into a servable link - // ex: - // C: /Users/dash/Documents/GitHub/Dash-Web/src/server/public/files/images/upload_8008dbc4b6424fbff14da7345bb32eb2.png - // -> http://localhost:1050/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; - }; - - public async reportIssue() { - console.log(this.bugTitle); - console.log('reporting issue'); + /** + * 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); - console.log('to github'); const links = await this.uploadFilesToServer(); + 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} \n\nfiles:\n${formattedLinks.join('\n')}`, + 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.'); - // on error, don't close the modal return; } - // if we're down here, then we're good to go. reset the fields. + // Reset fields this.setBugTitle(''); this.setBugDescription(''); - // this.toGithub = false; - this.setFileLinks([]); + this.setMediaFiles([]); this.setBugType(''); this.setBugPriority(''); this.setSubmitting(false); @@ -201,47 +198,118 @@ export class ReportManager extends React.Component<{}> { // this.close(); } - @observable public fileLinks: any = []; - @action setFileLinks = action((links: any) => { - this.fileLinks = links; - }); + /** + * 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', '')}`; - private getServerPath = (link: any) => { - return link.result.accessPaths.agnostic.server; + // turns an upload link into a servable 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; }; - private uploadFiles = (input: any) => { - // keep null while uploading - this.setFileLinks(null); - // upload the files to the server - if (input.files && input.files.length !== 0) { - const fileArray: File[] = Array.from(input.files); - Networking.UploadFilesToServer(fileArray.map(file => ({ file }))).then(links => { - console.log('finshed uploading', links.map(this.getServerPath)); - this.setFileLinks((links ?? []).map(this.getServerPath)); - }); - } + /** + * 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; }; - private uploadFilesToServer = async () => { - const links = await Networking.UploadFilesToServer(this.mediaFiles.map(file => ({ file }))); - console.log('finshed uploading', links.map(this.getServerPath)); - return (links ?? []).map(this.getServerPath); - // this.setFileLinks((links ?? []).map(this.getServerPath)); + /** + * Uploads media files to the server. + * @returns the server paths or undefined on error + */ + private uploadFilesToServer = async (): Promise<string[] | undefined> => { + try { + 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(files); + this.setMediaFiles([...this.mediaFiles, ...files.map(file => ({ _id: v4(), file }))]); }; - private reportComponent = () => { - if (this.viewState === ViewState.VIEW) { - return this.viewIssuesComponent(); - } else { - return this.reportIssueComponent(); + /** + * 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={50} alt={`Preview of ${file.name}`} src={preview} style={{ display: 'block' }} /> + </div> + <IconButton onClick={() => this.setMediaFiles(this.mediaFiles.filter(f => f._id !== fileData._id))} className="close-btn"> + <BsX color="#ffffff" /> + </IconButton> + </div> + ); + } else if (mimeType.startsWith('video/')) { + return ( + <div key={fileData._id} className="report-media-wrapper"> + <div className="report-media-content"> + <video controls style={{ height: '50px', width: 'auto', display: 'block' }}> + <source src={preview} type="video/mp4" /> + Your browser does not support the video tag. + </video> + </div> + <IconButton onClick={() => this.setMediaFiles(this.mediaFiles.filter(f => f._id !== fileData._id))} className="close-btn"> + <BsX color="#ffffff" /> + </IconButton> + </div> + ); + } else if (mimeType.startsWith('audio/')) { + return ( + <div key={fileData._id} className="report-audio-wrapper"> + <audio src={preview} controls /> + <IconButton onClick={() => this.setMediaFiles(this.mediaFiles.filter(f => f._id !== fileData._id))} className="close-btn"> + <BsX /> + </IconButton> + </div> + ); } + return <></>; }; + /** + * @returns the component that dispays all issues + */ private viewIssuesComponent = () => { return ( <div className="view-issues"> @@ -266,17 +334,23 @@ export class ReportManager extends React.Component<{}> { required /> <div className="issues"> - {this.shownIssues - .filter(issue => issue.title.toLowerCase().includes(this.query)) - .map(issue => ( - <IssueCard - key={issue.number} - issue={issue} - onSelect={() => { - this.setSelectedIssue(issue); - }} - /> - ))} + {this.fetchingIssues ? ( + <div style={{ flexGrow: 1, display: 'flex', justifyContent: 'center', alignItems: 'center' }}> + <Oval height={50} width={50} color={theme.palette.primary.main} visible={true} ariaLabel="oval-loading" secondaryColor={theme.palette.primary.main + 'b8'} strokeWidth={3} strokeWidthSecondary={3} /> + </div> + ) : ( + this.shownIssues + .filter(issue => issue.title.toLowerCase().includes(this.query)) + .map(issue => ( + <IssueCard + key={issue.number} + issue={issue} + onSelect={() => { + this.setSelectedIssue(issue); + }} + /> + )) + )} </div> </div> <div className="right">{this.selectedIssue ? <IssueView issue={this.selectedIssue} /> : <div>No issue selected</div>}</div> @@ -287,6 +361,9 @@ export class ReportManager extends React.Component<{}> { ); }; + /** + * @returns the form component for submitting issues + */ private reportIssueComponent = () => { return ( <div className="report-issue"> @@ -337,12 +414,8 @@ export class ReportManager extends React.Component<{}> { <Dropzone onDrop={this.onDrop} accept={{ - 'image/png': ['.png'], - 'image/jpg': ['.jpg'], - 'image/jpeg': ['.jpeg'], - 'video/mp4': ['.mp4'], - 'video/mpeg': ['.mpeg'], - 'video/webm': ['.webm'], + 'image/*': ['.png', '.jpg', '.jpeg', '.gif'], + 'video/*': ['.mp4', '.mpeg', '.webm', '.mov'], 'audio/mpeg': ['.mp3'], 'audio/wav': ['.wav'], 'audio/ogg': ['.ogg'], @@ -357,24 +430,7 @@ export class ReportManager extends React.Component<{}> { </div> )} </Dropzone> - {this.mediaFiles.length > 0 && ( - <div className="files"> - <ul className="file-list"> - {this.mediaFiles.map((file, i) => ( - <li key={file.name} className="file-name"> - {file.name} - <IconButton - onClick={() => { - this.setMediaFiles(this.mediaFiles.filter(f => f !== file)); - }}> - <BiX /> - </IconButton> - </li> - ))} - </ul> - </div> - )} - + {this.mediaFiles.length > 0 && <ul className="file-list">{this.mediaFiles.map(file => this.getMediaPreview(file))}</ul>} <Button variant="contained" sx={{ fontSize: '14px', display: 'flex', alignItems: 'center', gap: '1rem' }} @@ -391,125 +447,32 @@ export class ReportManager extends React.Component<{}> { ); }; - private get reportInterface() { - const isReportingIssue = this.selectedIssue === null; - - return ( - <div className="settings-interface"> - <div className="issue-list-wrapper"> - <h3>Current Issues</h3> - <input type="text" placeholder="search issues" onChange={e => this.setQuery(e.target.value)}></input> - <br /> - {this.shownIssues.length === 0 ? ( - <ReactLoading className="loading-center" /> - ) : ( - this.shownIssues.map(issue => ( - <div className="issue-list" key={issue.number} onClick={() => this.setSelectedIssue(issue)}> - {issue.title} - </div> - )) - )} - {/* <div className="settings-user"> - <button onClick={() => this.getAllIssues().then(issues => this.issues = issues)}>Poll Issues</button> - </div> */} - </div> - - <div className="close-button" onClick={this.close}> - <FontAwesomeIcon icon={'times'} color="black" size={'lg'} /> - </div> - - <div className="issue-content" style={{ paddingTop: this.selectedIssue === undefined ? '50px' : 'inherit' }}> - {this.selectedIssue === undefined ? 'no issue selected' : this.renderIssue(this.selectedIssue)} - </div> - - <div className="report-issue-fab"> - <span className="report-disclaimer" hidden={!isReportingIssue}> - Note: issue reporting is not anonymous. - </span> - <button onClick={() => (isReportingIssue ? this.closeReportIssueScreen() : this.showReportIssueScreen())}>{isReportingIssue ? 'Cancel' : 'Report New Issue'}</button> - </div> - </div> - ); - } - - private renderIssue = (issue: Issue) => { - const isReportingIssue = issue === null; - - return isReportingIssue ? ( - // report issue - <div className="settings-content"> - <h3 style={{ textDecoration: 'underline' }}>Report an Issue</h3> - <label>Please leave a title for the bug.</label> - <br /> - <input value={this.bugTitle} type="text" placeholder="title" onChange={e => this.setBugTitle(e.target.value)} required /> - {/* <TextField fullWidth type="text" placeholder="Title..." required onChange={e => this.setBugTitle(e.target.value)} /> */} - <br /> - <label>Please leave a description for the bug and how it can be recreated.</label> - <textarea value={this.bugDescription} placeholder="description" onChange={e => this.setBugDescription(e.target.value)} required /> - <br /> - {/* {<label>Send to github issues? </label> - <input type="checkbox" onChange={(e) => this.toGithub = e.target.checked} /> - <br /> } */} - - <label>Please label the issue</label> - <div className="flex-select"> - <select name="bugType" onChange={e => (this.bugType = e.target.value)}> - <option value="" disabled selected> - Type - </option> - <option value="priority-low">Bug</option> - <option value="priority-medium">Poor Design or Cosmetic</option> - <option value="priority-high">Poor Documentation</option> - </select> - - <select name="priority" onChange={e => (this.bugPriority = e.target.value)}> - <option 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> - - <div> - <label>Upload media that shows the bug (optional)</label> - <input type="file" name="file" multiple accept="audio/*, video/*, image/*" onChange={e => this.uploadFiles(e.target)} /> - </div> - <br /> - - <button onClick={() => this.reportIssue()} disabled={this.fileLinks === null} style={{ backgroundColor: this.fileLinks === null ? 'grey' : '' }}> - {this.fileLinks === null ? 'Uploading...' : 'Submit'} - </button> - </div> - ) : ( - // view issue - <div className="issue-container"> - <h5 style={{ textAlign: 'left' }}> - <a href={issue.html_url} target="_blank"> - Issue #{issue.number} - </a> - </h5> - <div className="issue-title">{issue.title}</div> - <ReactMarkdown children={issue.body as string} className="issue-body" linkTarget={'_blank'} remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} /> - </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.reportInterface} contents={this.reportComponent()} isDisplayed={this.isOpen} interactive={true} closeOnExternalClick={this.close} - dialogueBoxStyle={{ width: 'auto', minWidth: '400px', height: '85vh', maxHeight: '90vh', background: '#ffffff', borderRadius: '8px' }} + dialogueBoxStyle={{ width: 'auto', minWidth: '300px', height: '85vh', maxHeight: '90vh', background: '#ffffff', borderRadius: '8px' }} /> ); } } +// Mini components to render issues + interface IssueCardProps { issue: Issue; onSelect: () => void; @@ -528,6 +491,48 @@ interface IssueViewProps { } const IssueView = ({ issue }: IssueViewProps) => { + const parseBody = (body: string) => { + const imgTagRegex = /<img\b[^>]*\/?>/; + const fileRegex = /https:\/\/browndash\.com\/files/; + const parts = body.split('\n'); + + const modifiedParts = parts.map(part => { + if (imgTagRegex.test(part)) { + return getLinkFromTag(part); + } else if (fileRegex.test(part)) { + return getTagFromUrl(part); + } else { + return part; + } + }); + return modifiedParts.join('\n'); + }; + + const getLinkFromTag = (tag: string) => { + const regex = /src="([^"]+)"/; + let url = ''; + const match = tag.match(regex); + if (match) { + url = match[1]; + } + + return `\n${url}`; + }; + + const getTagFromUrl = (url: string) => { + const imgRegex = /https:\/\/browndash\.com\/files[/\\]images/; + const videoRegex = /https:\/\/browndash\.com\/files[/\\]videos/; + const audioRegex = /https:\/\/browndash\.com\/files[/\\]audio/; + + if (imgRegex.test(url)) { + return `${url}\n<img width="100%" alt="Issue asset" src=${url} />`; + } else if (videoRegex.test(url)) { + return url; + } else if (audioRegex.test(url)) { + return `${url}\n<audio src=${url} controls />`; + } + }; + return ( <div className="issue-view"> <span className="issue-label"> @@ -537,8 +542,7 @@ const IssueView = ({ issue }: IssueViewProps) => { </a> </span> <h2 className="issue-title">{issue.title}</h2> - <ReactMarkdown children={issue.body as string} className="issue-content" linkTarget={'_blank'} remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} /> - {/* <p className="issue-content">{issue.body}</p> */} + <ReactMarkdown children={issue.body ? parseBody(issue.body as string) : ''} className="issue-content" linkTarget={'_blank'} remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} /> </div> ); }; |