Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
/.env
.DS_Store
node_modules
5 changes: 5 additions & 0 deletions frontend/bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@
"@axe-core/playwright": "^4.10.2",
"@eslint/js": "^9.30.1",
"@ianvs/prettier-plugin-sort-imports": "^4.4.2",
"@octokit/types": "^14.1.0",
"@playwright/test": "^1.53.2",
"@types/cytoscape": "^3.21.9",
"@types/cytoscape-avsdf": "^1.0.3",
Expand Down
17 changes: 7 additions & 10 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import {
RouterProvider,
useLocation,
} from "react-router";
import { isEmpty } from "lodash";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import Footer from "@/components/Footer";
import Header from "@/components/Header";
Expand All @@ -28,6 +27,7 @@ import Testbed from "@/pages/Testbed";
import { getDocBbox, glow, scrollTo } from "@/util/dom";
import { useChanged } from "@/util/hooks";
import { sleep, waitFor, waitForStable } from "@/util/misc";
import { redirectPath, redirectState } from "@/util/url";

/** app entrypoint */
const App = () => <RouterProvider router={router} />;
Expand Down Expand Up @@ -74,15 +74,12 @@ export const routes = [
element: <Home />,
loader: async () => {
/** handle 404 redirect */
const path = window.sessionStorage.redirectPath || "";
const state = JSON.parse(window.sessionStorage.redirectState || "{}");
if (!isEmpty(state)) window.history.replaceState(state, "");
if (path) {
console.debug("Redirecting to:", path);
console.debug("With state:", state);
window.sessionStorage.removeItem("redirectPath");
window.sessionStorage.removeItem("redirectState");
return redirect(path);
if (redirectState !== null)
window.history.replaceState(redirectState, "");
if (redirectPath) {
console.debug("Redirecting to:", redirectPath);
console.debug("With state:", redirectState);
return redirect(redirectPath);
} else return null;
},
},
Expand Down
25 changes: 17 additions & 8 deletions frontend/src/api/feedback.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,21 @@
import { api, request } from "@/api";
import type { Endpoints } from "@octokit/types";
import { request } from "@/api";

/** cloud func */
const api = "https://molevolvr-feedback-211339212967.us-central1.run.app";

/** response type for creating new issue */
type Response =
Endpoints["POST /repos/{owner}/{repo}/issues"]["response"]["data"];

/** feedback form */
export const submitFeedback = async (title: string, body: string) =>
request<{ link: string }>(
`${api}/feedback`,
export const submitFeedback = async (title: string, body: string) => {
const headers = new Headers();
headers.append("Content-Type", "application/json");
const created = await request<Response>(
`${api}/submit`,
{},
{
method: "POST",
body: JSON.stringify({ title, body }),
},
{ method: "POST", headers, body: JSON.stringify({ title, body }) },
);
return { link: created.html_url };
};
55 changes: 34 additions & 21 deletions frontend/src/components/Feedback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,17 +24,23 @@ const { VITE_EMAIL, VITE_ISSUES } = import.meta.env;
/** feedback form on every page. singleton. */
const Feedback = () => {
/** form state, saved to local storage */
const [name, setName] = useLocalStorage("feedback-name", "");
let [name, setName] = useLocalStorage("feedback-name", "");
let [username, setUsername] = useLocalStorage("feedback-username", "");
const [email, setEmail] = useLocalStorage("feedback-email", "");
const [feedback, setFeedback] = useLocalStorage("feedback-body", "");
let [email, setEmail] = useLocalStorage("feedback-email", "");
let [feedback, setFeedback] = useLocalStorage("feedback-body", "");

/** set fallbacks */
name ||= "";
username ||= "";
email ||= "";
feedback ||= "";

const { pathname, search, hash } = useLocation();
const { browser, engine, os, device, cpu } = userAgent;

/** validate username */
if (username && username.length > 0)
username = username.replaceAll(/^@*/g, "@") || "";
username = username.replaceAll(/^@*/g, "@");

/** extra details to include in report */
const details = mapValues(
Expand All @@ -49,17 +55,22 @@ const Feedback = () => {
(value) => value.filter(Boolean).join(" "),
);

/** issue title */
/** feedback title */
const title = truncate(
["Feedback", name || username || "anon."].join(" - "),
[name.trim() || username.trim(), feedback.trim()]
.filter(Boolean)
.join(" - "),
{ length: 250 },
);

/** issue body */
/** feedback body */
const body = [{ name, username, email }, details, { feedback }]
.map((group) =>
Object.entries(group)
.map(([key, value]) => [`**${startCase(key)}**`, value || "-"])
.map(([key, value]) => [
`**${startCase(key)}**`,
value.trim() ? value.trim() : "\\-",
])
.flat()
.join("\n"),
)
Expand All @@ -70,13 +81,15 @@ const Feedback = () => {
mutationKey: ["feedback"],
mutationFn: (params: Parameters<typeof submitFeedback>) =>
submitFeedback(...params),
retry: 3,
retryDelay: (retry) => 2 * retry * 1000,
});

/** on form submit */
const onSubmit = async (event: FormEvent) => {
event.preventDefault();

/** submit issue */
/** submit feedback */
mutate([title, body]);
};

Expand All @@ -96,21 +109,21 @@ const Feedback = () => {
label="Name"
placeholder="Your Name"
tooltip="Optional. So we know who you are."
value={name || ""}
value={name}
onChange={setName}
/>
<TextBox
label="GitHub Username"
placeholder="@yourname"
tooltip="Optional. So we can tag you in the discussion and you can follow it."
value={username || ""}
tooltip="Optional. So we can tag you in the post and you can follow it."
value={username}
onChange={setUsername}
/>
<TextBox
label="Email"
placeholder="your.name@email.com"
tooltip="Optional. So we can contact you directly if needed."
value={email || ""}
value={email}
onChange={setEmail}
/>
</div>
Expand All @@ -121,7 +134,7 @@ const Feedback = () => {
placeholder="Questions, suggestions, bugs, etc."
required
multi
value={feedback || ""}
value={feedback}
onChange={setFeedback}
/>

Expand All @@ -147,8 +160,8 @@ const Feedback = () => {
>
{status === "idle" && (
<>
Submitting will start a <strong>public discussion</strong> on{" "}
<Link to={VITE_ISSUES}>our GitHub issue tracker</Link> with{" "}
Submitting will start a <strong>public post</strong> on{" "}
<Link to={VITE_ISSUES}>our GitHub feedback tracker</Link> with{" "}
<strong>all of the information above</strong>. You'll get a link
to it once it's created.
</>
Expand Down Expand Up @@ -182,17 +195,17 @@ const Feedback = () => {
tooltip="Download a screenshot of the current page"
onClick={async () => {
close();
await downloadJpg(document.body, ["screenshot.jpg"]);
await downloadJpg(document.body, ["screenshot"]);
open();
}}
/>
<Help
tooltip={
<div>
A screenshot of the current page can help us troubleshoot
issues. Currently, we can't <i>automatically</i> attach a
screenshot with your feedback, so you'll have to download
and attach/send it manually.
Downloads a screenshot of the current page, which can help
us troubleshoot issues. Currently, we can't{" "}
<i>automatically</i> attach a screenshot with your feedback,
so you'll have to download and attach/send it manually.
</div>
}
/>
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/main.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
import "@/util/seed";
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { url } from "@/util/url";
import App from "./App";

const { MODE, BASE_URL } = import.meta.env;

console.debug({ env: import.meta.env });

const mock = true;
/** whether to mock network requests with fake responses */
const mock = url.searchParams.get("mock") === "false" ? false : true;

(async () => {
/** mock network/api calls */
Expand Down
8 changes: 2 additions & 6 deletions frontend/src/util/seed.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import seedrandom from "seedrandom";

/** get current url from address bar or 404 redirect */
const redirect = sessionStorage.redirectPath;
const { origin, href } = window.location;
const url = redirect ? origin + redirect : href;
import { url } from "@/util/url";

/** seed for all Math.random calls in app */
export const seed = new URL(url).searchParams.get("seed") || String(Date.now());
export const seed = url.searchParams.get("seed") || String(Date.now());

/** seed all Math.random calls in app */
seedrandom(seed, { global: true });
16 changes: 16 additions & 0 deletions frontend/src/util/url.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
/** see 404.html */

/** load redirect storage items */
export const redirectPath = window.sessionStorage.redirectPath || "";
export const redirectState = JSON.parse(
window.sessionStorage.redirectState || "null",
);

/** remove redirect storage items right after consuming */
window.sessionStorage.removeItem("redirectPath");
window.sessionStorage.removeItem("redirectState");

/** get url from address bar or redirect */
const { origin, href } = window.location;
/** url at app load time (not reactive) */
export const url = new URL(redirectPath ? origin + redirectPath : href);
19 changes: 19 additions & 0 deletions scripts/feedback/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
Feedback form cloud func

Setup:

- Generate GitHub fine-grained personal access token
- Increase expiration as desired
- Grant access to only one relevant repo
- Grant write permissions for issues
- Store in password vault
- Manually deploy cloud func; no auto-deploy (yet)
- Go to appropriate project in Google Cloud Console
- Create a new inline cloud run func named "feedback"
- Use the Node runtime
- Allow unauthenticated requests
- Under container variables & secrets, create a new environment variable with name GITHUB_TOKEN and value of the created fine-grained token
- Set other settings as desired
- Copy/paste files in this folder to source
- Rename "function entry point" to "submit"
- Copy the endpoint URL into frontend/src/api/feedback in this repo
51 changes: 51 additions & 0 deletions scripts/feedback/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
const functions = require("@google-cloud/functions-framework");
const { Octokit } = require("octokit");

/** settings */
const owner = "JRaviLab";
const repo = "molevolvr2.0";
const labels = ["feedback"];
const auth = process.env.GITHUB_TOKEN;

/** endpoint */
functions.http("submit", async (request, response) => {
response.set("Access-Control-Allow-Origin", "*");
response.set("Access-Control-Allow-Methods", "OPTIONS, POST");
response.set("Access-Control-Allow-Headers", "Accept, Content-Type");

/** handle pre-flight */
if (request.method == "OPTIONS") return response.status(200).send();

let octokit;
try {
/** auth with github */
octokit = new Octokit({ auth });
} catch (error) {
/** https://github.com/octokit/request-error.js */
return response.status(error.status).send(error.message);
}

/** get params */
const { title, body } = request.body || {};

/** check for missing params */
if (!title) return response.status(400).send("Missing title");
if (!body) return response.status(400).send("Missing body");

/** create issue */
try {
const { data } = await octokit.rest.issues.create({
owner,
repo,
title,
body,
labels,
});

/** return created issue */
return response.status(200).json(data);
} catch (error) {
/** https://github.com/octokit/request-error.js */
return response.status(error.status).send(error.message);
}
});
6 changes: 6 additions & 0 deletions scripts/feedback/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{
"dependencies": {
"@google-cloud/functions-framework": "^3.0.0",
"octokit": "^5.0.0"
}
}