diff options
Diffstat (limited to 'src/client/util/reportManager')
-rw-r--r-- | src/client/util/reportManager/ReportManager.scss | 364 | ||||
-rw-r--r-- | src/client/util/reportManager/ReportManager.tsx | 406 | ||||
-rw-r--r-- | src/client/util/reportManager/ReportManagerComponents.tsx | 381 | ||||
-rw-r--r-- | src/client/util/reportManager/reportManagerSchema.ts | 877 | ||||
-rw-r--r-- | src/client/util/reportManager/reportManagerUtils.ts | 254 |
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'; |