aboutsummaryrefslogtreecommitdiff
path: root/src/client/util/reportManager
diff options
context:
space:
mode:
Diffstat (limited to 'src/client/util/reportManager')
-rw-r--r--src/client/util/reportManager/ReportManager.scss364
-rw-r--r--src/client/util/reportManager/ReportManager.tsx406
-rw-r--r--src/client/util/reportManager/ReportManagerComponents.tsx381
-rw-r--r--src/client/util/reportManager/reportManagerSchema.ts877
-rw-r--r--src/client/util/reportManager/reportManagerUtils.ts254
5 files changed, 2282 insertions, 0 deletions
diff --git a/src/client/util/reportManager/ReportManager.scss b/src/client/util/reportManager/ReportManager.scss
new file mode 100644
index 000000000..cd6a1d934
--- /dev/null
+++ b/src/client/util/reportManager/ReportManager.scss
@@ -0,0 +1,364 @@
+@import '../../views/global/globalCssVariables';
+
+// header
+
+.report-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+
+ .header-btns {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ }
+
+ h2 {
+ margin: 0;
+ padding: 0;
+ font-size: 24px;
+ }
+}
+
+.report-header-vertical {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ gap: 4px;
+
+ h2 {
+ margin: 0;
+ padding: 0;
+ padding-bottom: 8px;
+ font-size: 24px;
+ }
+}
+
+// Report
+
+.report-issue {
+ width: 450px;
+ min-width: 300px;
+ padding: 16px;
+ padding-top: 32px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ // background-color: #ffffff;
+ text-align: left;
+ position: relative;
+
+ .report-label {
+ font-size: 14px;
+ font-weight: 400;
+ }
+
+ .report-section {
+ display: flex;
+ flex-direction: column;
+ }
+
+ .report-textarea {
+ border: none;
+ outline: none;
+ width: 100%;
+ height: 80px;
+ padding: 8px;
+ resize: vertical;
+ background: transparent;
+ transition: border 0.3s ease;
+ }
+
+ .report-selects {
+ display: flex;
+ flex-direction: column;
+ align-items: stretch;
+ gap: 16px;
+ background-color: transparent;
+
+ .report-select {
+ padding: 8px;
+ background-color: transparent;
+
+ .report-opt {
+ padding: 8px;
+ }
+ }
+ }
+}
+
+.report-input {
+ border: none;
+ outline: none;
+ border-bottom: 1px solid;
+ padding: 8px;
+ padding-left: 0;
+ transition: all 0.2s ease;
+ background: transparent;
+
+ &:hover {
+ // border-bottom-color: $text-gray;
+ }
+ &:focus {
+ // border-bottom-color: #4476f7;
+ }
+}
+
+// View issues
+
+.view-issues {
+ width: 75vw;
+ min-width: 500px;
+ display: flex;
+ gap: 16px;
+ height: 100%;
+ overflow-x: auto;
+
+ video::-webkit-media-controls {
+ display: flex !important;
+ }
+
+ .left {
+ flex: 1;
+ height: 100%;
+ padding: 16px;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ text-align: left;
+ position: relative;
+
+ .issues {
+ padding-top: 24px;
+ position: relative;
+ flex-grow: 1;
+ overflow-y: auto;
+ overflow-x: hidden;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ }
+ }
+
+ .right {
+ position: relative;
+ flex: 1;
+ padding: 16px;
+ min-width: 300px;
+ height: 100%;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ justify-content: center;
+ align-items: center;
+ }
+}
+
+// Issue
+
+.issue-card {
+ cursor: pointer;
+ padding: 16px;
+ border: 1px solid;
+ transition: all 0.1s ease;
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ border-radius: 8px;
+ transition: all 0.2s ease;
+
+ .issue-top {
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ padding-bottom: 8px;
+ }
+
+ .issue-label {
+ cursor: pointer;
+ font-size: 14px;
+ font-weight: 400;
+ padding: 0;
+ margin: 0;
+ }
+
+ .issue-title {
+ font-size: 16px;
+ font-weight: 500;
+ padding: 0;
+ margin: 0;
+ }
+}
+
+// Dropzone
+
+.dropzone {
+ padding: 2rem;
+ border-radius: 0.5rem;
+ border: 2px dashed;
+
+ .dropzone-instructions {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 16px;
+
+ p {
+ text-align: center;
+ }
+ }
+}
+
+.file-list {
+ box-sizing: border-box;
+ margin: 0;
+ padding: 0;
+ font-size: 14px;
+ width: 100%;
+ overflow-x: auto;
+ list-style-type: none;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+
+ .file-name {
+ padding: 8px 12px;
+ display: flex;
+ align-items: center;
+ gap: 16px;
+ white-space: nowrap;
+ }
+}
+
+// Detailed issue view
+
+.issue-view {
+ height: 100%;
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+ text-align: left;
+ position: relative;
+ overflow: auto;
+
+ .issue-label {
+ .issue-link {
+ cursor: pointer;
+ color: #4476f7;
+ }
+ }
+
+ .issue-title {
+ font-size: 24px;
+ margin: 0;
+ padding: 0;
+ }
+
+ .issue-date {
+ font-size: 14px;
+ }
+
+ .issue-content {
+ font-size: 14px;
+ }
+}
+
+// tags flex lists
+
+.issues-filters {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ gap: 16px;
+
+ .issues-filter {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ white-space: nowrap;
+ overflow-x: auto;
+ }
+}
+
+.issue-tags {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ white-space: nowrap;
+ overflow-x: auto;
+}
+
+// Media previews
+
+.report-media-wrapper {
+ position: relative;
+ cursor: pointer;
+
+ .close-btn {
+ position: absolute;
+ top: 2px;
+ right: 2px;
+ opacity: 0;
+ }
+
+ .report-media-content {
+ position: relative;
+ display: inline block;
+
+ video::-webkit-media-controls {
+ display: flex !important;
+ }
+ }
+
+ .report-media-content::after {
+ content: '';
+ position: absolute;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.5); /* Adjust the opacity as desired */
+ opacity: 0;
+ transition: opacity 0.3s ease; /* Transition for smooth effect */
+ pointer-events: none;
+
+ video::-webkit-media-controls {
+ pointer-events: all;
+ }
+ }
+
+ &:hover {
+ .report-media-content::after {
+ opacity: 1;
+ }
+
+ .close-btn {
+ opacity: 1;
+ }
+ }
+}
+
+.report-audio-wrapper {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+
+@media (max-width: 1100px) {
+ .report-header {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 2rem;
+ }
+}
+
+// Tag styling
+
+.report-tag {
+ box-sizing: border-box;
+ padding: 4px 10px;
+ font-size: 10px;
+ border-radius: 32px;
+ transition: background-color 0.2s ease, color 0.2s ease, border-color 0.2s ease;
+}
diff --git a/src/client/util/reportManager/ReportManager.tsx b/src/client/util/reportManager/ReportManager.tsx
new file mode 100644
index 000000000..7aad0f2b1
--- /dev/null
+++ b/src/client/util/reportManager/ReportManager.tsx
@@ -0,0 +1,406 @@
+import * as React from 'react';
+import v4 = require('uuid/v4');
+import '.././SettingsManager.scss';
+import './ReportManager.scss';
+import Dropzone from 'react-dropzone';
+import ReactLoading from 'react-loading';
+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 { MainViewModal } from '../../views/MainViewModal';
+import { Octokit } from '@octokit/core';
+import { Button, Dropdown, DropdownType, IconButton, Type } from 'browndash-components';
+import { BugType, FileData, Priority, ReportForm, ViewState, bugDropdownItems, darkColors, emptyReportForm, formatTitle, getAllIssues, isDarkMode, lightColors, passesTagFilter, priorityDropdownItems, uploadFilesToServer } from './reportManagerUtils';
+import { Filter, FormInput, FormTextArea, IssueCard, IssueView, Tag } from './ReportManagerComponents';
+import { StrCast } from '../../../fields/Types';
+import { MdRefresh } from 'react-icons/md';
+import { SettingsManager } from '../SettingsManager';
+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 formData: ReportForm = emptyReportForm;
+ @action setFormData = action((newData: ReportForm) => {
+ this.formData = newData;
+ });
+
+ public close = action(() => (this.isOpen = false));
+ public open = action(async () => {
+ this.isOpen = true;
+ if (this.shownIssues.length === 0) {
+ this.updateIssues();
+ }
+ });
+
+ @action updateIssues = action(async () => {
+ this.setFetchingIssues(true);
+ try {
+ const issues = (await getAllIssues(this.octokit)) as Issue[];
+ 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,
+ });
+ }
+
+ /**
+ * Sends a request to Github to report a new issue with the form data.
+ * @returns nothing
+ */
+ public async reportIssue(): Promise<void> {
+ if (this.formData.title === '' || this.formData.description === '') {
+ alert('Please fill out all required fields to report an issue.');
+ return;
+ }
+ this.setSubmitting(true);
+ let formattedLinks: string[] = [];
+ if (this.formData.mediaFiles.length > 0) {
+ const links = await uploadFilesToServer(this.formData.mediaFiles);
+ if (links) {
+ formattedLinks = links;
+ }
+ }
+
+ const req = await this.octokit.request('POST /repos/{owner}/{repo}/issues', {
+ owner: 'brown-dash',
+ repo: 'Dash-Web',
+ title: formatTitle(this.formData.title, Doc.CurrentUserEmail),
+ body: `${this.formData.description} ${formattedLinks.length > 0 ? `\n\nFiles:\n${formattedLinks.join('\n')}` : ''}`,
+ labels: ['from-dash-app', this.formData.type, this.formData.priority],
+ });
+
+ // 201 status means success
+ if (req.status !== 201) {
+ alert('Error creating issue on github.');
+ } else {
+ await this.updateIssues();
+ alert('Successfully submitted issue.');
+ }
+ this.setFormData(emptyReportForm);
+ this.setSubmitting(false);
+ }
+
+ /**
+ * Handles file upload.
+ *
+ * @param files uploaded files
+ */
+ private onDrop = (files: File[]) => {
+ this.setFormData({ ...this.formData, mediaFiles: [...this.formData.mediaFiles, ...files.map(file => ({ _id: v4(), file }))] });
+ };
+
+ /**
+ * 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.setFormData({ ...this.formData, mediaFiles: this.formData.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.setFormData({ ...this.formData, mediaFiles: this.formData.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.setFormData({ ...this.formData, mediaFiles: this.formData.mediaFiles.filter(f => f._id !== fileData._id) })} />
+ </div>
+ </div>
+ );
+ }
+ return <></>;
+ };
+
+ /**
+ * @returns the component that dispays all issues
+ */
+ private viewIssuesComponent = () => {
+ const darkMode = isDarkMode(SettingsManager.Instance.userBackgroundColor);
+ const colors = darkMode ? darkColors : lightColors;
+
+ return (
+ <div className="view-issues" style={{ backgroundColor: SettingsManager.Instance.userBackgroundColor, color: colors.text }}>
+ <div className="left" style={{ display: this.rightExpanded ? 'none' : 'flex' }}>
+ <div className="report-header">
+ <h2 style={{ color: colors.text }}>Open Issues</h2>
+ <div className="header-btns">
+ <IconButton color={StrCast(Doc.UserDoc().userColor)} tooltip="refresh" icon={<MdRefresh size="16px" />} onClick={this.updateIssues} />
+ <Button
+ type={Type.TERT}
+ color={StrCast(Doc.UserDoc().userVariantColor)}
+ text="Report Issue"
+ onClick={() => {
+ this.setViewState(ViewState.CREATE);
+ }}
+ />
+ </div>
+ </div>
+ <FormInput value={this.query} placeholder="Filter by query..." onChange={this.setQuery} />
+ <div className="issues-filters">
+ <Filter items={Object.values(Priority)} activeValue={this.priorityFilter} setActiveValue={p => this.setPriorityFilter(p)} />
+ <Filter items={Object.values(BugType)} activeValue={this.bugFilter} setActiveValue={b => this.setBugFilter(b)} />
+ </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 => passesTagFilter(issue, this.priorityFilter, this.bugFilter))
+ .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
+ color={StrCast(Doc.UserDoc().userColor)}
+ 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 color={StrCast(Doc.UserDoc().userColor)} tooltip="close" icon={<CgClose size="16px" />} onClick={this.close} />
+ </div>
+ </div>
+ );
+ };
+
+ /**
+ * @returns the form component for submitting issues
+ */
+ private reportIssueComponent = () => {
+ const darkMode = isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor));
+ const colors = darkMode ? darkColors : lightColors;
+
+ return (
+ <div className="report-issue" style={{ color: colors.text }}>
+ <div className="report-header-vertical">
+ <Button
+ type={Type.PRIM}
+ color={StrCast(Doc.UserDoc().userColor)}
+ 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>
+ <FormInput value={this.formData.title} placeholder="Title..." onChange={val => this.setFormData({ ...this.formData, title: val })} />
+ </div>
+ <div className="report-section">
+ <label className="report-label">Please leave a description for the bug and how it can be recreated</label>
+ <FormTextArea value={this.formData.description} placeholder="Description..." onChange={val => this.setFormData({ ...this.formData, description: val })} />
+ </div>
+ <div className="report-selects">
+ <Dropdown
+ color={StrCast(Doc.UserDoc().userColor)}
+ formLabel={'Type'}
+ items={bugDropdownItems}
+ selectedVal={this.formData.type}
+ setSelectedVal={val => {
+ if (typeof val === 'string') this.setFormData({ ...this.formData, type: val as BugType });
+ }}
+ dropdownType={DropdownType.SELECT}
+ type={Type.TERT}
+ fillWidth
+ />
+ <Dropdown
+ color={StrCast(Doc.UserDoc().userColor)}
+ formLabel={'Priority'}
+ items={priorityDropdownItems}
+ selectedVal={this.formData.priority}
+ setSelectedVal={val => {
+ if (typeof val === 'string') this.setFormData({ ...this.formData, priority: val as Priority });
+ }}
+ dropdownType={DropdownType.SELECT}
+ type={Type.TERT}
+ fillWidth
+ />
+ </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' })} style={{ borderColor: isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor)) ? darkColors.border : lightColors.border }}>
+ <input {...getInputProps()} />
+ <div className="dropzone-instructions">
+ <AiOutlineUpload size={25} />
+ <p>Drop or select media that shows the bug (optional)</p>
+ </div>
+ </div>
+ )}
+ </Dropzone>
+ {this.formData.mediaFiles.length > 0 && <ul className="file-list">{this.formData.mediaFiles.map(file => this.getMediaPreview(file))}</ul>}
+ {this.submitting ? (
+ <Button
+ text="Submit"
+ type={Type.TERT}
+ color={StrCast(Doc.UserDoc().userVariantColor)}
+ icon={<ReactLoading type="spin" color={'#ffffff'} width={20} height={20} />}
+ iconPlacement="right"
+ onClick={() => {
+ this.reportIssue();
+ }}
+ />
+ ) : (
+ <Button
+ text="Submit"
+ type={Type.TERT}
+ color={StrCast(Doc.UserDoc().userVariantColor)}
+ onClick={() => {
+ this.reportIssue();
+ }}
+ />
+ )}
+ <div style={{ position: 'absolute', top: '4px', right: '4px' }}>
+ <IconButton color={StrCast(Doc.UserDoc().userColor)} 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' }}
+ />
+ );
+ }
+}
diff --git a/src/client/util/reportManager/ReportManagerComponents.tsx b/src/client/util/reportManager/ReportManagerComponents.tsx
new file mode 100644
index 000000000..e870c073d
--- /dev/null
+++ b/src/client/util/reportManager/ReportManagerComponents.tsx
@@ -0,0 +1,381 @@
+import * as React from 'react';
+import { Issue } from './reportManagerSchema';
+import { darkColors, dashBlue, getLabelColors, isDarkMode, lightColors } from './reportManagerUtils';
+import ReactMarkdown from 'react-markdown';
+import rehypeRaw from 'rehype-raw';
+import remarkGfm from 'remark-gfm';
+import { StrCast } from '../../../fields/Types';
+import { Doc } from '../../../fields/Doc';
+
+/**
+ * Mini helper components for the report component.
+ */
+
+interface FilterProps<T> {
+ items: T[];
+ activeValue: T | null;
+ setActiveValue: (val: T | null) => void;
+}
+
+// filter ui for issues (horizontal list of tags)
+export const Filter = <T extends string>({ items, activeValue, setActiveValue }: FilterProps<T>) => {
+ // establishing theme
+ const darkMode = isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor));
+ const colors = darkMode ? darkColors : lightColors;
+ const isTagDarkMode = isDarkMode(StrCast(Doc.UserDoc().userColor));
+ const activeTagTextColor = isTagDarkMode ? darkColors.text : lightColors.text;
+
+ return (
+ <div className="issues-filter">
+ <Tag
+ text={'All'}
+ onClick={() => {
+ setActiveValue(null);
+ }}
+ fontSize="12px"
+ backgroundColor={activeValue === null ? StrCast(Doc.UserDoc().userColor) : 'transparent'}
+ color={activeValue === null ? activeTagTextColor : colors.textGrey}
+ borderColor={activeValue === null ? StrCast(Doc.UserDoc().userColor) : colors.border}
+ border
+ />
+ {items.map(item => {
+ return (
+ <Tag
+ key={item}
+ text={item}
+ onClick={() => {
+ setActiveValue(item);
+ }}
+ fontSize="12px"
+ backgroundColor={activeValue === item ? StrCast(Doc.UserDoc().userColor) : 'transparent'}
+ color={activeValue === item ? activeTagTextColor : colors.textGrey}
+ border
+ borderColor={activeValue === item ? StrCast(Doc.UserDoc().userColor) : colors.border}
+ />
+ );
+ })}
+ </div>
+ );
+};
+
+interface IssueCardProps {
+ issue: Issue;
+ onSelect: () => void;
+}
+
+// Component for the issue cards list on the left
+export const IssueCard = ({ issue, onSelect }: IssueCardProps) => {
+ const [textColor, setTextColor] = React.useState('');
+ const [bgColor, setBgColor] = React.useState('transparent');
+ const [borderColor, setBorderColor] = React.useState('transparent');
+
+ const resetColors = () => {
+ const darkMode = isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor));
+ const colors = darkMode ? darkColors : lightColors;
+ setTextColor(colors.text);
+ setBorderColor(colors.border);
+ setBgColor('transparent');
+ };
+
+ const handlePointerOver = () => {
+ const darkMode = isDarkMode(StrCast(Doc.UserDoc().userColor));
+ setTextColor(darkMode ? darkColors.text : lightColors.text);
+ setBorderColor(StrCast(Doc.UserDoc().userColor));
+ setBgColor(StrCast(Doc.UserDoc().userColor));
+ };
+
+ React.useEffect(() => {
+ resetColors();
+ }, []);
+
+ return (
+ <div className="issue-card" onClick={onSelect} style={{ color: textColor, backgroundColor: bgColor, borderColor: borderColor }} onPointerOver={handlePointerOver} onPointerOut={resetColors}>
+ <div className="issue-top">
+ <label className="issue-label">#{issue.number}</label>
+ <div className="issue-tags">
+ {issue.labels.map(label => {
+ const labelString = typeof label === 'string' ? label : label.name ?? '';
+ const colors = getLabelColors(labelString);
+ return <Tag key={labelString} text={labelString} backgroundColor={colors[0]} color={colors[1]} />;
+ })}
+ </div>
+ </div>
+ <h3 className="issue-title">{issue.title}</h3>
+ </div>
+ );
+};
+
+interface IssueViewProps {
+ issue: Issue;
+}
+
+// Detailed issue view that displays on the right
+export const IssueView = ({ issue }: IssueViewProps) => {
+ const [issueBody, setIssueBody] = React.useState('');
+
+ // Parses the issue body into a formatted markdown (main functionality is replacing urls with tags)
+ const parseBody = async (body: string) => {
+ const imgTagRegex = /<img\b[^>]*\/?>/;
+ const videoTagRegex = /<video\b[^>]*\/?>/;
+ const audioTagRegex = /<audio\b[^>]*\/?>/;
+
+ const fileRegex = /https:\/\/browndash\.com\/files/;
+ const localRegex = /http:\/\/localhost:1050\/files/;
+ const parts = body.split('\n');
+
+ const modifiedParts = await Promise.all(
+ parts.map(async part => {
+ if (imgTagRegex.test(part) || videoTagRegex.test(part) || audioTagRegex.test(part)) {
+ return `\n${await parseFileTag(part)}\n`;
+ } else if (fileRegex.test(part)) {
+ const tag = await parseDashFiles(part);
+ return tag;
+ } else if (localRegex.test(part)) {
+ const tag = await parseLocalFiles(part);
+ return tag;
+ } else {
+ return part;
+ }
+ })
+ );
+
+ setIssueBody(modifiedParts.join('\n'));
+ };
+
+ // Extracts the src from an image tag and either returns the raw url if not accessible or a new image tag
+ const parseFileTag = async (tag: string): Promise<string> => {
+ const regex = /src="([^"]+)"/;
+ let url = '';
+ const match = tag.match(regex);
+ if (!match) return tag;
+ url = match[1];
+ if (!url) return tag;
+
+ const mimeType = url.split('.').pop();
+ if (!mimeType) return tag;
+
+ switch (mimeType) {
+ // image
+ case '.jpg':
+ case '.png':
+ case '.jpeg':
+ case '.gif':
+ return await getDisplayedFile(url, 'image');
+ // video
+ case '.mp4':
+ case '.mpeg':
+ case '.webm':
+ case '.mov':
+ return await getDisplayedFile(url, 'video');
+ //audio
+ case '.mp3':
+ case '.wav':
+ case '.ogg':
+ return await getDisplayedFile(url, 'audio');
+ }
+ return tag;
+ };
+
+ // Returns the corresponding HTML tag for a src url
+ const parseDashFiles = async (url: string) => {
+ const dashImgRegex = /https:\/\/browndash\.com\/files[/\\]images/;
+ const dashVideoRegex = /https:\/\/browndash\.com\/files[/\\]videos/;
+ const dashAudioRegex = /https:\/\/browndash\.com\/files[/\\]audio/;
+
+ if (dashImgRegex.test(url)) {
+ return await getDisplayedFile(url, 'image');
+ } else if (dashVideoRegex.test(url)) {
+ return await getDisplayedFile(url, 'video');
+ } else if (dashAudioRegex.test(url)) {
+ return await getDisplayedFile(url, 'audio');
+ } else {
+ return url;
+ }
+ };
+
+ // Returns the corresponding HTML tag for a src url
+ const parseLocalFiles = async (url: string) => {
+ const imgRegex = /http:\/\/localhost:1050\/files[/\\]images/;
+ const dashVideoRegex = /http:\/\/localhost:1050\.com\/files[/\\]videos/;
+ const dashAudioRegex = /http:\/\/localhost:1050\.com\/files[/\\]audio/;
+
+ if (imgRegex.test(url)) {
+ return await getDisplayedFile(url, 'image');
+ } else if (dashVideoRegex.test(url)) {
+ return await getDisplayedFile(url, 'video');
+ } else if (dashAudioRegex.test(url)) {
+ return await getDisplayedFile(url, 'audio');
+ } else {
+ return url;
+ }
+ };
+
+ const getDisplayedFile = async (url: string, fileType: 'image' | 'video' | 'audio'): Promise<string> => {
+ switch (fileType) {
+ case 'image':
+ const imgValid = await isImgValid(url);
+ if (!imgValid) return `\n${url} (This image could not be loaded)\n`;
+ return `\n${url}\n<img width="100%" alt="Issue asset" src=${url} />\n`;
+ case 'video':
+ const videoValid = await isVideoValid(url);
+ if (!videoValid) return `\n${url} (This video could not be loaded)\n`;
+ return `\n${url}\n<video class="report-default-video" width="100%" controls alt="Issue asset" src=${url} />\n`;
+ case 'audio':
+ const audioValid = await isAudioValid(url);
+ if (!audioValid) return `\n${url} (This audio could not be loaded)\n`;
+ return `\n${url}\n<audio src=${url} controls />\n`;
+ }
+ };
+
+ // Loads an image and returns a promise that resolves as whether the image is valid or not
+ const isImgValid = (src: string): Promise<boolean> => {
+ const imgElement = document.createElement('img');
+ const validPromise: Promise<boolean> = new Promise(resolve => {
+ imgElement.addEventListener('load', () => resolve(true));
+ imgElement.addEventListener('error', () => resolve(false));
+ });
+ imgElement.src = src;
+ return validPromise;
+ };
+
+ // Loads a video and returns a promise that resolves as whether the video is valid or not
+ const isVideoValid = (src: string): Promise<boolean> => {
+ const videoElement = document.createElement('video');
+ const validPromise: Promise<boolean> = new Promise(resolve => {
+ videoElement.addEventListener('loadeddata', () => resolve(true));
+ videoElement.addEventListener('error', () => resolve(false));
+ });
+ videoElement.src = src;
+ return validPromise;
+ };
+
+ // Loads audio and returns a promise that resolves as whether the audio is valid or not
+ const isAudioValid = (src: string): Promise<boolean> => {
+ const audioElement = document.createElement('audio');
+ const validPromise: Promise<boolean> = new Promise(resolve => {
+ audioElement.addEventListener('loadeddata', () => resolve(true));
+ audioElement.addEventListener('error', () => resolve(false));
+ });
+ audioElement.src = src;
+ return validPromise;
+ };
+
+ // Called on mount to parse the body
+ React.useEffect(() => {
+ setIssueBody('Loading...');
+ parseBody((issue.body as string) ?? '');
+ }, [issue]);
+
+ return (
+ <div className="issue-view">
+ <span className="issue-label">
+ Issue{' '}
+ <a className="issue-link" href={issue.html_url} target="_blank">
+ #{issue.number}
+ </a>
+ </span>
+ <h2 className="issue-title">{issue.title}</h2>
+ <div className="issue-date">
+ Opened on {new Date(issue.created_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })} {issue.user?.login && `by ${issue.user?.login}`}
+ </div>
+ {issue.labels.length > 0 && (
+ <div>
+ <div className="issue-tags">
+ {issue.labels.map(label => {
+ const labelString = typeof label === 'string' ? label : label.name ?? '';
+ const colors = getLabelColors(labelString);
+ return <Tag key={labelString} text={labelString} backgroundColor={colors[0]} color={colors[1]} fontSize="12px" />;
+ })}
+ </div>
+ </div>
+ )}
+ <ReactMarkdown children={issueBody} className="issue-content" linkTarget={'_blank'} remarkPlugins={[remarkGfm]} rehypePlugins={[rehypeRaw]} />
+ </div>
+ );
+};
+
+interface TagProps {
+ text: string;
+ fontSize?: string;
+ color?: string;
+ backgroundColor?: string;
+ borderColor?: string;
+ border?: boolean;
+ onClick?: () => void;
+}
+
+// Small tag for labels of the issue
+export const Tag = ({ text, color, backgroundColor, fontSize, border, borderColor, onClick }: TagProps) => {
+ return (
+ <div
+ onClick={onClick ?? (() => {})}
+ className="report-tag"
+ style={{ color: color ?? '#ffffff', backgroundColor: backgroundColor ?? '#347bff', cursor: onClick ? 'pointer' : 'auto', fontSize: fontSize ?? '10px', border: border ? '1px solid' : 'none', borderColor: borderColor ?? '#94a3b8' }}>
+ {text}
+ </div>
+ );
+};
+
+interface FormInputProps {
+ value: string;
+ placeholder: string;
+ onChange: (val: string) => void;
+}
+export const FormInput = ({ value, placeholder, onChange }: FormInputProps) => {
+ const [inputBorderColor, setInputBorderColor] = React.useState('');
+
+ return (
+ <input
+ className="report-input"
+ style={{ borderBottom: `1px solid ${inputBorderColor}` }}
+ value={value}
+ type="text"
+ placeholder={placeholder}
+ onChange={e => onChange(e.target.value)}
+ required
+ onPointerOver={() => {
+ if (inputBorderColor === dashBlue) return;
+ setInputBorderColor(isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor)) ? darkColors.textGrey : lightColors.textGrey);
+ }}
+ onPointerOut={() => {
+ if (inputBorderColor === dashBlue) return;
+ setInputBorderColor(isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor)) ? darkColors.border : lightColors.border);
+ }}
+ onFocus={() => {
+ setInputBorderColor(dashBlue);
+ }}
+ onBlur={() => {
+ setInputBorderColor(isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor)) ? darkColors.border : lightColors.border);
+ }}
+ />
+ );
+};
+
+export const FormTextArea = ({ value, placeholder, onChange }: FormInputProps) => {
+ const [textAreaBorderColor, setTextAreaBorderColor] = React.useState('');
+
+ return (
+ <textarea
+ className="report-textarea"
+ value={value}
+ placeholder={placeholder}
+ onChange={e => onChange(e.target.value)}
+ required
+ style={{ border: `1px solid ${textAreaBorderColor}` }}
+ onPointerOver={() => {
+ if (textAreaBorderColor === dashBlue) return;
+ setTextAreaBorderColor(isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor)) ? darkColors.textGrey : lightColors.textGrey);
+ }}
+ onPointerOut={() => {
+ if (textAreaBorderColor === dashBlue) return;
+ setTextAreaBorderColor(isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor)) ? darkColors.border : lightColors.border);
+ }}
+ onFocus={() => {
+ setTextAreaBorderColor(dashBlue);
+ }}
+ onBlur={() => {
+ setTextAreaBorderColor(isDarkMode(StrCast(Doc.UserDoc().userBackgroundColor)) ? darkColors.border : lightColors.border);
+ }}
+ />
+ );
+};
diff --git a/src/client/util/reportManager/reportManagerSchema.ts b/src/client/util/reportManager/reportManagerSchema.ts
new file mode 100644
index 000000000..9a1c7c3e9
--- /dev/null
+++ b/src/client/util/reportManager/reportManagerSchema.ts
@@ -0,0 +1,877 @@
+/**
+ * Issue interface schema from Github.
+ */
+export interface Issue {
+ active_lock_reason?: null | string;
+ assignee: null | PurpleSimpleUser;
+ assignees?: AssigneeElement[] | null;
+ /**
+ * How the author is associated with the repository.
+ */
+ author_association: AuthorAssociation;
+ /**
+ * Contents of the issue
+ */
+ body?: null | string;
+ body_html?: string;
+ body_text?: string;
+ closed_at: Date | null;
+ closed_by?: null | FluffySimpleUser;
+ comments: number;
+ comments_url: string;
+ created_at: Date;
+ draft?: boolean;
+ events_url: string;
+ html_url: string;
+ id: number;
+ /**
+ * Labels to associate with this issue; pass one or more label names to replace the set of
+ * labels on this issue; send an empty array to clear all labels from the issue; note that
+ * the labels are silently dropped for users without push access to the repository
+ */
+ labels: Array<LabelObject | string>;
+ labels_url: string;
+ locked: boolean;
+ milestone: null | Milestone;
+ node_id: string;
+ /**
+ * Number uniquely identifying the issue within its repository
+ */
+ number: number;
+ performed_via_github_app?: null | GitHubApp;
+ pull_request?: PullRequest;
+ reactions?: ReactionRollup;
+ /**
+ * A repository on GitHub.
+ */
+ repository?: Repository;
+ repository_url: string;
+ /**
+ * State of the issue; either 'open' or 'closed'
+ */
+ state: string;
+ /**
+ * The reason for the current state
+ */
+ state_reason?: StateReason | null;
+ timeline_url?: string;
+ /**
+ * Title of the issue
+ */
+ title: string;
+ updated_at: Date;
+ /**
+ * URL for the issue
+ */
+ url: string;
+ user: null | TentacledSimpleUser;
+ [property: string]: any;
+}
+
+/**
+ * A GitHub user.
+ */
+export interface PurpleSimpleUser {
+ avatar_url: string;
+ email?: null | string;
+ events_url: string;
+ followers_url: string;
+ following_url: string;
+ gists_url: string;
+ gravatar_id: null | string;
+ html_url: string;
+ id: number;
+ login: string;
+ name?: null | string;
+ node_id: string;
+ organizations_url: string;
+ received_events_url: string;
+ repos_url: string;
+ site_admin: boolean;
+ starred_at?: string;
+ starred_url: string;
+ subscriptions_url: string;
+ type: string;
+ url: string;
+ [property: string]: any;
+}
+
+/**
+ * A GitHub user.
+ */
+export interface AssigneeElement {
+ avatar_url: string;
+ email?: null | string;
+ events_url: string;
+ followers_url: string;
+ following_url: string;
+ gists_url: string;
+ gravatar_id: null | string;
+ html_url: string;
+ id: number;
+ login: string;
+ name?: null | string;
+ node_id: string;
+ organizations_url: string;
+ received_events_url: string;
+ repos_url: string;
+ site_admin: boolean;
+ starred_at?: string;
+ starred_url: string;
+ subscriptions_url: string;
+ type: string;
+ url: string;
+ [property: string]: any;
+}
+
+/**
+ * How the author is associated with the repository.
+ */
+export enum AuthorAssociation {
+ Collaborator = 'COLLABORATOR',
+ Contributor = 'CONTRIBUTOR',
+ FirstTimeContributor = 'FIRST_TIME_CONTRIBUTOR',
+ FirstTimer = 'FIRST_TIMER',
+ Mannequin = 'MANNEQUIN',
+ Member = 'MEMBER',
+ None = 'NONE',
+ Owner = 'OWNER',
+}
+
+/**
+ * A GitHub user.
+ */
+export interface FluffySimpleUser {
+ avatar_url: string;
+ email?: null | string;
+ events_url: string;
+ followers_url: string;
+ following_url: string;
+ gists_url: string;
+ gravatar_id: null | string;
+ html_url: string;
+ id: number;
+ login: string;
+ name?: null | string;
+ node_id: string;
+ organizations_url: string;
+ received_events_url: string;
+ repos_url: string;
+ site_admin: boolean;
+ starred_at?: string;
+ starred_url: string;
+ subscriptions_url: string;
+ type: string;
+ url: string;
+ [property: string]: any;
+}
+
+export interface LabelObject {
+ color?: null | string;
+ default?: boolean;
+ description?: null | string;
+ id?: number;
+ name?: string;
+ node_id?: string;
+ url?: string;
+ [property: string]: any;
+}
+
+/**
+ * A collection of related issues and pull requests.
+ */
+export interface Milestone {
+ closed_at: Date | null;
+ closed_issues: number;
+ created_at: Date;
+ creator: null | MilestoneSimpleUser;
+ description: null | string;
+ due_on: Date | null;
+ html_url: string;
+ id: number;
+ labels_url: string;
+ node_id: string;
+ /**
+ * The number of the milestone.
+ */
+ number: number;
+ open_issues: number;
+ /**
+ * The state of the milestone.
+ */
+ state: State;
+ /**
+ * The title of the milestone.
+ */
+ title: string;
+ updated_at: Date;
+ url: string;
+ [property: string]: any;
+}
+
+/**
+ * A GitHub user.
+ */
+export interface MilestoneSimpleUser {
+ avatar_url: string;
+ email?: null | string;
+ events_url: string;
+ followers_url: string;
+ following_url: string;
+ gists_url: string;
+ gravatar_id: null | string;
+ html_url: string;
+ id: number;
+ login: string;
+ name?: null | string;
+ node_id: string;
+ organizations_url: string;
+ received_events_url: string;
+ repos_url: string;
+ site_admin: boolean;
+ starred_at?: string;
+ starred_url: string;
+ subscriptions_url: string;
+ type: string;
+ url: string;
+ [property: string]: any;
+}
+
+/**
+ * The state of the milestone.
+ */
+export enum State {
+ Closed = 'closed',
+ Open = 'open',
+}
+
+/**
+ * GitHub apps are a new way to extend GitHub. They can be installed directly on
+ * organizations and user accounts and granted access to specific repositories. They come
+ * with granular permissions and built-in webhooks. GitHub apps are first class actors
+ * within GitHub.
+ */
+export interface GitHubApp {
+ client_id?: string;
+ client_secret?: string;
+ created_at: Date;
+ description: null | string;
+ /**
+ * The list of events for the GitHub app
+ */
+ events: string[];
+ external_url: string;
+ html_url: string;
+ /**
+ * Unique identifier of the GitHub app
+ */
+ id: number;
+ /**
+ * The number of installations associated with the GitHub app
+ */
+ installations_count?: number;
+ /**
+ * The name of the GitHub app
+ */
+ name: string;
+ node_id: string;
+ owner: null | GitHubAppSimpleUser;
+ pem?: string;
+ /**
+ * The set of permissions for the GitHub app
+ */
+ permissions: GitHubAppPermissions;
+ /**
+ * The slug name of the GitHub app
+ */
+ slug?: string;
+ updated_at: Date;
+ webhook_secret?: null | string;
+ [property: string]: any;
+}
+
+/**
+ * A GitHub user.
+ */
+export interface GitHubAppSimpleUser {
+ avatar_url: string;
+ email?: null | string;
+ events_url: string;
+ followers_url: string;
+ following_url: string;
+ gists_url: string;
+ gravatar_id: null | string;
+ html_url: string;
+ id: number;
+ login: string;
+ name?: null | string;
+ node_id: string;
+ organizations_url: string;
+ received_events_url: string;
+ repos_url: string;
+ site_admin: boolean;
+ starred_at?: string;
+ starred_url: string;
+ subscriptions_url: string;
+ type: string;
+ url: string;
+ [property: string]: any;
+}
+
+/**
+ * The set of permissions for the GitHub app
+ */
+export interface GitHubAppPermissions {
+ checks?: string;
+ contents?: string;
+ deployments?: string;
+ issues?: string;
+ metadata?: string;
+}
+
+export interface PullRequest {
+ diff_url: null | string;
+ html_url: null | string;
+ merged_at?: Date | null;
+ patch_url: null | string;
+ url: null | string;
+ [property: string]: any;
+}
+
+export interface ReactionRollup {
+ '+1': number;
+ '-1': number;
+ confused: number;
+ eyes: number;
+ heart: number;
+ hooray: number;
+ laugh: number;
+ rocket: number;
+ total_count: number;
+ url: string;
+ [property: string]: any;
+}
+
+/**
+ * A repository on GitHub.
+ */
+export interface Repository {
+ /**
+ * Whether to allow Auto-merge to be used on pull requests.
+ */
+ allow_auto_merge?: boolean;
+ /**
+ * Whether to allow forking this repo
+ */
+ allow_forking?: boolean;
+ /**
+ * Whether to allow merge commits for pull requests.
+ */
+ allow_merge_commit?: boolean;
+ /**
+ * Whether to allow rebase merges for pull requests.
+ */
+ allow_rebase_merge?: boolean;
+ /**
+ * Whether to allow squash merges for pull requests.
+ */
+ allow_squash_merge?: boolean;
+ /**
+ * Whether or not a pull request head branch that is behind its base branch can always be
+ * updated even if it is not required to be up to date before merging.
+ */
+ allow_update_branch?: boolean;
+ /**
+ * Whether anonymous git access is enabled for this repository
+ */
+ anonymous_access_enabled?: boolean;
+ archive_url: string;
+ /**
+ * Whether the repository is archived.
+ */
+ archived: boolean;
+ assignees_url: string;
+ blobs_url: string;
+ branches_url: string;
+ clone_url: string;
+ collaborators_url: string;
+ comments_url: string;
+ commits_url: string;
+ compare_url: string;
+ contents_url: string;
+ contributors_url: string;
+ created_at: Date | null;
+ /**
+ * The default branch of the repository.
+ */
+ default_branch: string;
+ /**
+ * Whether to delete head branches when pull requests are merged
+ */
+ delete_branch_on_merge?: boolean;
+ deployments_url: string;
+ description: null | string;
+ /**
+ * Returns whether or not this repository disabled.
+ */
+ disabled: boolean;
+ downloads_url: string;
+ events_url: string;
+ fork: boolean;
+ forks: number;
+ forks_count: number;
+ forks_url: string;
+ full_name: string;
+ git_commits_url: string;
+ git_refs_url: string;
+ git_tags_url: string;
+ git_url: string;
+ /**
+ * Whether discussions are enabled.
+ */
+ has_discussions?: boolean;
+ /**
+ * Whether downloads are enabled.
+ */
+ has_downloads: boolean;
+ /**
+ * Whether issues are enabled.
+ */
+ has_issues: boolean;
+ has_pages: boolean;
+ /**
+ * Whether projects are enabled.
+ */
+ has_projects: boolean;
+ /**
+ * Whether the wiki is enabled.
+ */
+ has_wiki: boolean;
+ homepage: null | string;
+ hooks_url: string;
+ html_url: string;
+ /**
+ * Unique identifier of the repository
+ */
+ id: number;
+ /**
+ * Whether this repository acts as a template that can be used to generate new repositories.
+ */
+ is_template?: boolean;
+ issue_comment_url: string;
+ issue_events_url: string;
+ issues_url: string;
+ keys_url: string;
+ labels_url: string;
+ language: null | string;
+ languages_url: string;
+ license: null | LicenseSimple;
+ master_branch?: string;
+ /**
+ * The default value for a merge commit message.
+ *
+ * - `PR_TITLE` - default to the pull request's title.
+ * - `PR_BODY` - default to the pull request's body.
+ * - `BLANK` - default to a blank commit message.
+ */
+ merge_commit_message?: MergeCommitMessage;
+ /**
+ * The default value for a merge commit title.
+ *
+ * - `PR_TITLE` - default to the pull request's title.
+ * - `MERGE_MESSAGE` - default to the classic title for a merge message (e.g., Merge pull
+ * request #123 from branch-name).
+ */
+ merge_commit_title?: MergeCommitTitle;
+ merges_url: string;
+ milestones_url: string;
+ mirror_url: null | string;
+ /**
+ * The name of the repository.
+ */
+ name: string;
+ network_count?: number;
+ node_id: string;
+ notifications_url: string;
+ open_issues: number;
+ open_issues_count: number;
+ organization?: null | RepositorySimpleUser;
+ /**
+ * A GitHub user.
+ */
+ owner: OwnerObject;
+ permissions?: RepositoryPermissions;
+ /**
+ * Whether the repository is private or public.
+ */
+ private: boolean;
+ pulls_url: string;
+ pushed_at: Date | null;
+ releases_url: string;
+ /**
+ * The size of the repository. Size is calculated hourly. When a repository is initially
+ * created, the size is 0.
+ */
+ size: number;
+ /**
+ * The default value for a squash merge commit message:
+ *
+ * - `PR_BODY` - default to the pull request's body.
+ * - `COMMIT_MESSAGES` - default to the branch's commit messages.
+ * - `BLANK` - default to a blank commit message.
+ */
+ squash_merge_commit_message?: SquashMergeCommitMessage;
+ /**
+ * The default value for a squash merge commit title:
+ *
+ * - `PR_TITLE` - default to the pull request's title.
+ * - `COMMIT_OR_PR_TITLE` - default to the commit's title (if only one commit) or the pull
+ * request's title (when more than one commit).
+ */
+ squash_merge_commit_title?: SquashMergeCommitTitle;
+ ssh_url: string;
+ stargazers_count: number;
+ stargazers_url: string;
+ starred_at?: string;
+ statuses_url: string;
+ subscribers_count?: number;
+ subscribers_url: string;
+ subscription_url: string;
+ svn_url: string;
+ tags_url: string;
+ teams_url: string;
+ temp_clone_token?: string;
+ template_repository?: null | TemplateRepository;
+ topics?: string[];
+ trees_url: string;
+ updated_at: Date | null;
+ url: string;
+ /**
+ * Whether a squash merge commit can use the pull request title as default. **This property
+ * has been deprecated. Please use `squash_merge_commit_title` instead.
+ */
+ use_squash_pr_title_as_default?: boolean;
+ /**
+ * The repository visibility: public, private, or internal.
+ */
+ visibility?: string;
+ watchers: number;
+ watchers_count: number;
+ /**
+ * Whether to require contributors to sign off on web-based commits
+ */
+ web_commit_signoff_required?: boolean;
+ [property: string]: any;
+}
+
+/**
+ * License Simple
+ */
+export interface LicenseSimple {
+ html_url?: string;
+ key: string;
+ name: string;
+ node_id: string;
+ spdx_id: null | string;
+ url: null | string;
+ [property: string]: any;
+}
+
+/**
+ * The default value for a merge commit message.
+ *
+ * - `PR_TITLE` - default to the pull request's title.
+ * - `PR_BODY` - default to the pull request's body.
+ * - `BLANK` - default to a blank commit message.
+ */
+export enum MergeCommitMessage {
+ Blank = 'BLANK',
+ PRBody = 'PR_BODY',
+ PRTitle = 'PR_TITLE',
+}
+
+/**
+ * The default value for a merge commit title.
+ *
+ * - `PR_TITLE` - default to the pull request's title.
+ * - `MERGE_MESSAGE` - default to the classic title for a merge message (e.g., Merge pull
+ * request #123 from branch-name).
+ */
+export enum MergeCommitTitle {
+ MergeMessage = 'MERGE_MESSAGE',
+ PRTitle = 'PR_TITLE',
+}
+
+/**
+ * A GitHub user.
+ */
+export interface RepositorySimpleUser {
+ avatar_url: string;
+ email?: null | string;
+ events_url: string;
+ followers_url: string;
+ following_url: string;
+ gists_url: string;
+ gravatar_id: null | string;
+ html_url: string;
+ id: number;
+ login: string;
+ name?: null | string;
+ node_id: string;
+ organizations_url: string;
+ received_events_url: string;
+ repos_url: string;
+ site_admin: boolean;
+ starred_at?: string;
+ starred_url: string;
+ subscriptions_url: string;
+ type: string;
+ url: string;
+ [property: string]: any;
+}
+
+/**
+ * A GitHub user.
+ */
+export interface OwnerObject {
+ avatar_url: string;
+ email?: null | string;
+ events_url: string;
+ followers_url: string;
+ following_url: string;
+ gists_url: string;
+ gravatar_id: null | string;
+ html_url: string;
+ id: number;
+ login: string;
+ name?: null | string;
+ node_id: string;
+ organizations_url: string;
+ received_events_url: string;
+ repos_url: string;
+ site_admin: boolean;
+ starred_at?: string;
+ starred_url: string;
+ subscriptions_url: string;
+ type: string;
+ url: string;
+ [property: string]: any;
+}
+
+export interface RepositoryPermissions {
+ admin: boolean;
+ maintain?: boolean;
+ pull: boolean;
+ push: boolean;
+ triage?: boolean;
+ [property: string]: any;
+}
+
+/**
+ * The default value for a squash merge commit message:
+ *
+ * - `PR_BODY` - default to the pull request's body.
+ * - `COMMIT_MESSAGES` - default to the branch's commit messages.
+ * - `BLANK` - default to a blank commit message.
+ */
+export enum SquashMergeCommitMessage {
+ Blank = 'BLANK',
+ CommitMessages = 'COMMIT_MESSAGES',
+ PRBody = 'PR_BODY',
+}
+
+/**
+ * The default value for a squash merge commit title:
+ *
+ * - `PR_TITLE` - default to the pull request's title.
+ * - `COMMIT_OR_PR_TITLE` - default to the commit's title (if only one commit) or the pull
+ * request's title (when more than one commit).
+ */
+export enum SquashMergeCommitTitle {
+ CommitOrPRTitle = 'COMMIT_OR_PR_TITLE',
+ PRTitle = 'PR_TITLE',
+}
+
+export interface TemplateRepository {
+ allow_auto_merge?: boolean;
+ allow_merge_commit?: boolean;
+ allow_rebase_merge?: boolean;
+ allow_squash_merge?: boolean;
+ allow_update_branch?: boolean;
+ archive_url?: string;
+ archived?: boolean;
+ assignees_url?: string;
+ blobs_url?: string;
+ branches_url?: string;
+ clone_url?: string;
+ collaborators_url?: string;
+ comments_url?: string;
+ commits_url?: string;
+ compare_url?: string;
+ contents_url?: string;
+ contributors_url?: string;
+ created_at?: string;
+ default_branch?: string;
+ delete_branch_on_merge?: boolean;
+ deployments_url?: string;
+ description?: string;
+ disabled?: boolean;
+ downloads_url?: string;
+ events_url?: string;
+ fork?: boolean;
+ forks_count?: number;
+ forks_url?: string;
+ full_name?: string;
+ git_commits_url?: string;
+ git_refs_url?: string;
+ git_tags_url?: string;
+ git_url?: string;
+ has_downloads?: boolean;
+ has_issues?: boolean;
+ has_pages?: boolean;
+ has_projects?: boolean;
+ has_wiki?: boolean;
+ homepage?: string;
+ hooks_url?: string;
+ html_url?: string;
+ id?: number;
+ is_template?: boolean;
+ issue_comment_url?: string;
+ issue_events_url?: string;
+ issues_url?: string;
+ keys_url?: string;
+ labels_url?: string;
+ language?: string;
+ languages_url?: string;
+ /**
+ * The default value for a merge commit message.
+ *
+ * - `PR_TITLE` - default to the pull request's title.
+ * - `PR_BODY` - default to the pull request's body.
+ * - `BLANK` - default to a blank commit message.
+ */
+ merge_commit_message?: MergeCommitMessage;
+ /**
+ * The default value for a merge commit title.
+ *
+ * - `PR_TITLE` - default to the pull request's title.
+ * - `MERGE_MESSAGE` - default to the classic title for a merge message (e.g., Merge pull
+ * request #123 from branch-name).
+ */
+ merge_commit_title?: MergeCommitTitle;
+ merges_url?: string;
+ milestones_url?: string;
+ mirror_url?: string;
+ name?: string;
+ network_count?: number;
+ node_id?: string;
+ notifications_url?: string;
+ open_issues_count?: number;
+ owner?: Owner;
+ permissions?: TemplateRepositoryPermissions;
+ private?: boolean;
+ pulls_url?: string;
+ pushed_at?: string;
+ releases_url?: string;
+ size?: number;
+ /**
+ * The default value for a squash merge commit message:
+ *
+ * - `PR_BODY` - default to the pull request's body.
+ * - `COMMIT_MESSAGES` - default to the branch's commit messages.
+ * - `BLANK` - default to a blank commit message.
+ */
+ squash_merge_commit_message?: SquashMergeCommitMessage;
+ /**
+ * The default value for a squash merge commit title:
+ *
+ * - `PR_TITLE` - default to the pull request's title.
+ * - `COMMIT_OR_PR_TITLE` - default to the commit's title (if only one commit) or the pull
+ * request's title (when more than one commit).
+ */
+ squash_merge_commit_title?: SquashMergeCommitTitle;
+ ssh_url?: string;
+ stargazers_count?: number;
+ stargazers_url?: string;
+ statuses_url?: string;
+ subscribers_count?: number;
+ subscribers_url?: string;
+ subscription_url?: string;
+ svn_url?: string;
+ tags_url?: string;
+ teams_url?: string;
+ temp_clone_token?: string;
+ topics?: string[];
+ trees_url?: string;
+ updated_at?: string;
+ url?: string;
+ use_squash_pr_title_as_default?: boolean;
+ visibility?: string;
+ watchers_count?: number;
+ [property: string]: any;
+}
+
+export interface Owner {
+ avatar_url?: string;
+ events_url?: string;
+ followers_url?: string;
+ following_url?: string;
+ gists_url?: string;
+ gravatar_id?: string;
+ html_url?: string;
+ id?: number;
+ login?: string;
+ node_id?: string;
+ organizations_url?: string;
+ received_events_url?: string;
+ repos_url?: string;
+ site_admin?: boolean;
+ starred_url?: string;
+ subscriptions_url?: string;
+ type?: string;
+ url?: string;
+ [property: string]: any;
+}
+
+export interface TemplateRepositoryPermissions {
+ admin?: boolean;
+ maintain?: boolean;
+ pull?: boolean;
+ push?: boolean;
+ triage?: boolean;
+ [property: string]: any;
+}
+
+export enum StateReason {
+ Completed = 'completed',
+ NotPlanned = 'not_planned',
+ Reopened = 'reopened',
+}
+
+/**
+ * A GitHub user.
+ */
+export interface TentacledSimpleUser {
+ avatar_url: string;
+ email?: null | string;
+ events_url: string;
+ followers_url: string;
+ following_url: string;
+ gists_url: string;
+ gravatar_id: null | string;
+ html_url: string;
+ id: number;
+ login: string;
+ name?: null | string;
+ node_id: string;
+ organizations_url: string;
+ received_events_url: string;
+ repos_url: string;
+ site_admin: boolean;
+ starred_at?: string;
+ starred_url: string;
+ subscriptions_url: string;
+ type: string;
+ url: string;
+ [property: string]: any;
+}
diff --git a/src/client/util/reportManager/reportManagerUtils.ts b/src/client/util/reportManager/reportManagerUtils.ts
new file mode 100644
index 000000000..b95417aa1
--- /dev/null
+++ b/src/client/util/reportManager/reportManagerUtils.ts
@@ -0,0 +1,254 @@
+// Final file url reference: "https://browndash.com/files/images/upload_cb31bc0fda59c96ca14193ec494f80cf_o.jpg" />
+
+import { Octokit } from '@octokit/core';
+import { Networking } from '../../Network';
+import { Issue } from './reportManagerSchema';
+
+// enums and interfaces
+
+export enum ViewState {
+ VIEW,
+ CREATE,
+}
+
+export enum Priority {
+ HIGH = 'priority-high',
+ MEDIUM = 'priority-medium',
+ LOW = 'priority-low',
+}
+
+export enum BugType {
+ BUG = 'bug',
+ COSMETIC = 'cosmetic',
+ DOCUMENTATION = 'documentation',
+ ENHANCEMENT = 'enhancement',
+}
+
+export interface FileData {
+ _id: string;
+ file: File;
+}
+
+export interface ReportForm {
+ title: string;
+ description: string;
+ type: BugType;
+ priority: Priority;
+ mediaFiles: FileData[];
+}
+
+export type ReportFormKey = keyof ReportForm;
+
+export const emptyReportForm = {
+ title: '',
+ description: '',
+ type: BugType.BUG,
+ priority: Priority.MEDIUM,
+ mediaFiles: [],
+};
+
+// interfacing with Github
+
+/**
+ * Fetches issues from Github.
+ * @returns array of all issues
+ */
+export const getAllIssues = async (octokit: Octokit): Promise<any[]> => {
+ const res = await 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');
+ }
+};
+
+/**
+ * Formats issue title.
+ *
+ * @param title title of issue
+ * @param userEmail email of issue submitter
+ * @returns formatted title
+ */
+export const formatTitle = (title: string, userEmail: string): string => `${title} - ${userEmail.replace('@brown.edu', '')}`;
+
+// uploading
+
+// 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
+export const fileLinktoServerLink = (fileLink: string): string => {
+ const serverUrl = window.location.href.includes('browndash') ? 'https://browndash.com/' : 'http://localhost:1050/';
+
+ 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
+ */
+export const 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
+ */
+export const uploadFilesToServer = async (mediaFiles: FileData[]): Promise<string[] | undefined> => {
+ try {
+ // need to always upload to browndash
+ const links = await Networking.UploadFilesToServer(mediaFiles.map(file => ({ file: file.file })));
+ return (links ?? []).map(getServerPath).map(fileLinktoServerLink);
+ } catch (err) {
+ if (err instanceof Error) {
+ alert(err.message);
+ } else {
+ alert(err);
+ }
+ }
+};
+
+// helper functions
+
+/**
+ * Returns when the issue passes the current filters.
+ *
+ * @param issue issue to check
+ * @returns boolean indicating whether the issue passes the current filters
+ */
+export const passesTagFilter = (issue: Issue, priorityFilter: string | null, bugFilter: string | null) => {
+ let passesPriority = true;
+ let passesBug = true;
+ if (priorityFilter) {
+ passesPriority = issue.labels.some(label => {
+ if (typeof label === 'string') {
+ return label === priorityFilter;
+ } else {
+ return label.name === priorityFilter;
+ }
+ });
+ }
+ if (bugFilter) {
+ passesBug = issue.labels.some(label => {
+ if (typeof label === 'string') {
+ return label === bugFilter;
+ } else {
+ return label.name === bugFilter;
+ }
+ });
+ }
+ return passesPriority && passesBug;
+};
+
+// sets and lists
+
+export const prioritySet = new Set(Object.values(Priority));
+export const bugSet = new Set(Object.values(BugType));
+
+export const priorityDropdownItems = [
+ {
+ text: 'Low',
+ val: Priority.LOW,
+ },
+ {
+ text: 'Medium',
+ val: Priority.MEDIUM,
+ },
+ {
+ text: 'High',
+ val: Priority.HIGH,
+ },
+];
+
+export const bugDropdownItems = [
+ {
+ text: 'Bug',
+ val: BugType.BUG,
+ },
+ {
+ text: 'Poor Design or Cosmetic',
+ val: BugType.COSMETIC,
+ },
+ {
+ text: 'Documentation',
+ val: BugType.DOCUMENTATION,
+ },
+ {
+ text: 'New feature or request',
+ val: BugType.ENHANCEMENT,
+ },
+];
+
+// colors
+
+// [bgColor, color]
+export const priorityColors: { [key: string]: string[] } = {
+ 'priority-low': ['#d4e0ff', '#000000'],
+ 'priority-medium': ['#6a91f6', '#ffffff'],
+ 'priority-high': ['#003cd5', '#ffffff'],
+};
+
+// [bgColor, color]
+export const bugColors: { [key: string]: string[] } = {
+ bug: ['#fe6d6d', '#ffffff'],
+ cosmetic: ['#c650f4', '#ffffff'],
+ documentation: ['#36acf0', '#ffffff'],
+ enhancement: ['#36d4f0', '#ffffff'],
+};
+
+export const getLabelColors = (label: string): string[] => {
+ if (prioritySet.has(label as Priority)) {
+ return priorityColors[label];
+ } else if (bugSet.has(label as BugType)) {
+ return bugColors[label];
+ }
+ return ['#0f73f6', '#ffffff'];
+};
+
+const hexToRgb = (hex: string) => {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+ return result
+ ? {
+ r: parseInt(result[1], 16),
+ g: parseInt(result[2], 16),
+ b: parseInt(result[3], 16),
+ }
+ : {
+ r: 0,
+ g: 0,
+ b: 0,
+ };
+};
+
+// function that returns whether text should be light on the given bg color
+export const isDarkMode = (bgHex: string): boolean => {
+ const { r, g, b } = hexToRgb(bgHex);
+ return r * 0.299 + g * 0.587 + b * 0.114 <= 186;
+};
+
+export const lightColors = {
+ text: '#000000',
+ textGrey: '#5c5c5c',
+ border: '#b8b8b8',
+};
+
+export const darkColors = {
+ text: '#ffffff',
+ textGrey: '#d6d6d6',
+ border: '#717171',
+};
+
+export const dashBlue = '#4476f7';