aboutsummaryrefslogtreecommitdiff
path: root/src/client/util/reportManager/ReportManager.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/util/reportManager/ReportManager.tsx')
-rw-r--r--src/client/util/reportManager/ReportManager.tsx613
1 files changed, 613 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..7599b8949
--- /dev/null
+++ b/src/client/util/reportManager/ReportManager.tsx
@@ -0,0 +1,613 @@
+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 ReactLoading from 'react-loading';
+import v4 = require('uuid/v4');
+import { BugType, FileData, Priority, ViewState, inactiveBorderColor, inactiveColor } from './reportManagerUtils';
+import { IssueCard, IssueView, Tag } from './ReportManagerComponents';
+import { StrCast } from '../../../fields/Types';
+const higflyout = require('@hig/flyout');
+export const { anchorPoints } = higflyout;
+export const Flyout = higflyout.default;
+
+// StrCast(Doc.UserDoc().userColor);
+// StrCast(Doc.UserDoc().userBackgroundColor);
+// StrCast(Doc.UserDoc().userVariantColor);
+
+/**
+ * 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" style={{ backgroundColor: StrCast(Doc.UserDoc().userBackgroundColor) }}>
+ <div className="left" style={{ display: this.rightExpanded ? 'none' : 'flex' }}>
+ <div className="report-header">
+ <h2 style={{ color: StrCast(Doc.UserDoc().userColor) }}>Open Issues</h2>
+ <Button
+ type={Type.TERT}
+ color={StrCast(Doc.UserDoc().userVariantColor)}
+ 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 ? StrCast(Doc.UserDoc().userVariantColor) : 'transparent'}
+ color={this.priorityFilter === null ? StrCast(Doc.UserDoc().userColor) : inactiveColor}
+ border
+ borderColor={this.priorityFilter === null ? StrCast(Doc.UserDoc().userVariantColor) : inactiveBorderColor}
+ />
+ {Object.values(Priority).map(p => {
+ return (
+ <Tag
+ key={p}
+ text={p}
+ onClick={() => {
+ this.setPriorityFilter(p);
+ }}
+ fontSize="12px"
+ backgroundColor={this.priorityFilter === p ? StrCast(Doc.UserDoc().userVariantColor) : 'transparent'}
+ color={this.priorityFilter === p ? StrCast(Doc.UserDoc().userColor) : inactiveColor}
+ border
+ borderColor={this.priorityFilter === p ? StrCast(Doc.UserDoc().userVariantColor) : inactiveBorderColor}
+ />
+ );
+ })}
+ </div>
+ <div className="issues-filter">
+ <Tag
+ text={'All'}
+ onClick={() => {
+ this.setBugFilter(null);
+ }}
+ fontSize="12px"
+ backgroundColor={this.bugFilter === null ? StrCast(Doc.UserDoc().userVariantColor) : 'transparent'}
+ color={this.bugFilter === null ? StrCast(Doc.UserDoc().userColor) : inactiveColor}
+ border
+ borderColor={this.bugFilter === null ? StrCast(Doc.UserDoc().userVariantColor) : inactiveBorderColor}
+ />
+ {Object.values(BugType).map(b => {
+ return (
+ <Tag
+ key={b}
+ text={b}
+ onClick={() => {
+ this.setBugFilter(b);
+ }}
+ fontSize="12px"
+ backgroundColor={this.bugFilter === b ? StrCast(Doc.UserDoc().userVariantColor) : 'transparent'}
+ color={this.bugFilter === b ? StrCast(Doc.UserDoc().userColor) : inactiveColor}
+ border
+ borderColor={this.bugFilter === b ? StrCast(Doc.UserDoc().userVariantColor) : inactiveBorderColor}
+ />
+ );
+ })}
+ </div>
+ </div>
+ <div className="issues">
+ {this.fetchingIssues ? (
+ <div style={{ flexGrow: 1, display: 'flex', justifyContent: 'center', alignItems: 'center' }}>
+ <ReactLoading type="spin" color={StrCast(Doc.UserDoc().userColor)} 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
+ type={Type.PRIM}
+ text="back to view"
+ icon={<HiOutlineArrowLeft />}
+ iconPlacement="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>}
+ {this.submitting ? (
+ <Button
+ text="Submit"
+ type={Type.TERT}
+ icon={<ReactLoading type="spin" color={'#ffffff'} width={20} height={20} />}
+ iconPlacement="right"
+ onClick={() => {
+ this.reportIssue();
+ }}
+ />
+ ) : (
+ <Button
+ text="Submit"
+ type={Type.TERT}
+ 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: StrCast(Doc.UserDoc().userBackgroundColor), borderRadius: '8px' }}
+ />
+ );
+ }
+}