wasp-ai: Added feedback form.

This commit is contained in:
Matija Sosic 2023-07-15 12:42:20 +02:00
parent bcbd138244
commit 63d078dc44
6 changed files with 289 additions and 3 deletions

View File

@ -63,6 +63,12 @@ page StatsPage {
authRequired: true
}
route FeedbackRoute { path: "/feedback", to: FeedbackPage }
page FeedbackPage {
component: import { Feedback } from "@client/pages/FeedbackPage.jsx",
authRequired: true
}
route LoginRoute { path: "/login", to: LoginPage }
page LoginPage {
component: import { LoginPage } from "@client/pages/LoginPage.jsx",
@ -80,6 +86,17 @@ action registerZipDownload {
entities: [Project]
}
action createFeedback {
fn: import { createFeedback } from "@server/operations.js",
entities: [Feedback]
}
query getFeedback {
fn: import { getFeedback } from "@server/operations.js",
entities: [Feedback]
}
query getAppGenerationResult {
fn: import { getAppGenerationResult } from "@server/operations.js",
entities: [
@ -127,6 +144,17 @@ entity Project {=psl
user User? @relation(fields: [userId], references: [id])
files File[]
logs Log[]
feedbacks Feedback[]
psl=}
entity Feedback {=psl
id String @id @default(uuid())
score Int
message String
createdAt DateTime @default(now())
projectId String
project Project @relation(fields: [projectId], references: [id])
psl=}
entity File {=psl

View File

@ -0,0 +1,12 @@
-- CreateTable
CREATE TABLE "Feedback" (
"id" TEXT NOT NULL,
"score" INTEGER NOT NULL,
"message" TEXT NOT NULL,
"projectId" TEXT NOT NULL,
CONSTRAINT "Feedback_pkey" PRIMARY KEY ("id")
);
-- AddForeignKey
ALTER TABLE "Feedback" ADD CONSTRAINT "Feedback_projectId_fkey" FOREIGN KEY ("projectId") REFERENCES "Project"("id") ON DELETE RESTRICT ON UPDATE CASCADE;

View File

@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Feedback" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;

View File

@ -0,0 +1,82 @@
import { useState, useMemo } from "react";
import getFeedback from "@wasp/queries/getFeedback";
import { useQuery } from "@wasp/queries";
import { Link } from "react-router-dom";
import { format } from "timeago.js";
export function Feedback() {
const { data: feedback, isLoading, error } = useQuery(getFeedback);
console.log(feedback)
return (
<>
{isLoading && <p>Loading...</p>}
{error && <p>Error: {error.message}</p>}
{feedback && feedback.feedbackEntries.length === 0 && (
<p className="text-sm text-slate-500">No feedback yet.</p>
)}
{feedback && feedback.feedbackEntries.length > 0 && (
<div className="relative overflow-x-auto shadow-md sm:rounded-lg">
<table className="w-full text-sm text-left text-slate-500">
<thead className="text-xs text-slate-700 uppercase bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3">
App Name
</th>
<th scope="col" className="px-6 py-3">
Created At
</th>
<th scope="col" className="px-6 py-3">
Score
</th>
<th scope="col" className="px-6 py-3">
Message
</th>
<th scope="col" className="px-6 py-3"></th>
</tr>
</thead>
<tbody>
{feedback.feedbackEntries.map((entry, idx) => (
<tr className="bg-white border-b" key={idx}>
<th
scope="row"
className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap flex items-center gap-2"
>
<span title={entry.project.description}>{entry.project.name}</span>{" "}
</th>
<td
className="px-6 py-4"
title={`${entry.createdAt.toLocaleDateString()} ${entry.createdAt.toLocaleTimeString()}`}
>
{format(entry.createdAt)}
</td>
<td
scope="row"
className="px-6 py-4 font-medium text-gray-900 whitespace-nowrap flex items-center gap-2"
>
{entry.score}
</td>
<td className="px-6 py-4">
{entry.message}
</td>
<td className="px-6 py-4">
<Link
to={`/result/${entry.projectId}`}
className="font-medium text-sky-600 hover:underline"
>
View the app &rarr;
</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</>
)
}

View File

@ -2,6 +2,7 @@ import { useState, useEffect, useMemo } from "react";
import getAppGenerationResult from "@wasp/queries/getAppGenerationResult";
import startGeneratingNewApp from "@wasp/actions/startGeneratingNewApp";
import registerZipDownload from "@wasp/actions/registerZipDownload";
import createFeedback from "@wasp/actions/createFeedback"
import { useQuery } from "@wasp/queries";
import { CodeHighlight } from "../components/CodeHighlight";
import { FileTree } from "../components/FileTree";
@ -9,6 +10,7 @@ import { createFilesAndDownloadZip } from "../zip/zipHelpers";
import { useParams } from "react-router-dom";
import { Link } from "react-router-dom";
import { useHistory } from "react-router-dom";
import { RadioGroup } from '@headlessui/react'
import { Loader } from "../components/Loader";
import { MyDialog } from "../components/Dialog";
import { Logs } from "../components/Logs";
@ -20,6 +22,7 @@ import {
PiLaptopDuotone,
PiDownloadDuotone,
PiCheckDuotone,
PiChatBold
} from "react-icons/pi";
import { RxQuestionMarkCircled } from "react-icons/rx";
@ -287,6 +290,7 @@ export const ResultPage = () => {
<div>
<ShareButton />
</div>
<FileTree
paths={interestingFilePaths}
activeFilePath={activeFilePath}
@ -303,8 +307,14 @@ export const ResultPage = () => {
<main
className={isMobileFileBrowserOpen ? "hidden md:block" : ""}
>
<div className="font-bold text-sm bg-slate-200 text-slate-700 p-3 rounded rounded-b-none">
{activeFilePath}:
<div
className={`
font-bold text-sm bg-slate-200 text-slate-700 p-3 rounded rounded-b-none
flex items-center md:justify-between
`}>
<span className="mr-3">{activeFilePath}:</span>
<Feedback projectId={appId} />
</div>
<div
key={activeFilePath}
@ -525,6 +535,111 @@ function WarningAboutAI() {
);
}
function Feedback({ projectId }) {
const [isModalOpened, setIsModalOpened] = useState(false);
const [feedbackText, setFeedbackText] = useState("")
const [score, setScore] = useState(0)
const scoreOptions = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
const handleSubmit = async (e) => {
e.preventDefault()
console.log(score, feedbackText, projectId)
try {
await createFeedback({ score, message: feedbackText, projectId })
} catch (e) {
console.error('Could not create feedback')
}
setIsModalOpened(false)
setScore(0)
setFeedbackText('')
}
return (
<div>
<button
className={`
text-gray-500
border border-gray-500
py-1 px-2 rounded-md mb-1
flex items-center space-x-2 justify-center
font-bold
transition duration-150
hover:bg-gray-300
`}
onClick={() => setIsModalOpened(true)}
>
<span>💬 Give us feedback!</span>
</button>
<MyDialog
isOpen={isModalOpened}
onClose={() => setIsModalOpened(false)}
title={
<span>
Let us know how it went!
</span>
}
>
<form onSubmit={handleSubmit}>
<label className="text-slate-700 block mb-2 mt-8">
How likely are you to recommend this tool to a friend? <span className="text-red-500">*</span>
</label>
<div className="mx-auto w-full max-w-md">
<RadioGroup value={score} onChange={setScore}>
<div className="flex space-x-2">
{scoreOptions.map((option) => (
<RadioGroup.Option value={option}>
{({ active, checked }) => (
<div
className={`
${active ? 'ring-2 ring-white ring-opacity-60 ring-offset-2 ring-offset-sky-300' : ''}
${checked ? 'bg-sky-900 bg-opacity-75 text-white' : ''}
cursor-pointer px-3 py-2 shadow-md focus:outline-none
rounded-md
`}
>
{option}
</div>
)}
</RadioGroup.Option>
))}
</div>
</RadioGroup>
</div>
<label htmlFor="feedbackText" className="text-slate-700 block mb-2 mt-8">
How did it go? <span className="text-red-500">*</span>
</label>
<textarea
id="feedback"
required
placeholder="How happy are you with the result? What could have been better?"
value={feedbackText}
rows="5"
cols="50"
onChange={(e) => setFeedbackText(e.target.value)}
/>
<button
className='button black mt-4'
type="submit"
>
Submit
</button>
</form>
</MyDialog>
</div>
)
}
function ShareButton() {
const [copying, setCopying] = useState(false);
function copy() {

View File

@ -1,8 +1,9 @@
import {
RegisterZipDownload,
StartGeneratingNewApp,
CreateFeedback
} from "@wasp/actions/types";
import { GetAppGenerationResult, GetStats } from "@wasp/queries/types";
import { GetAppGenerationResult, GetStats, GetFeedback } from "@wasp/queries/types";
import HttpError from "@wasp/core/HttpError.js";
import { checkPendingAppsJob } from "@wasp/jobs/checkPendingAppsJob.js";
@ -76,6 +77,26 @@ export const registerZipDownload: RegisterZipDownload<{
}
};
export const createFeedback: CreateFeedback<
{ score: number , message: string, projectId: string }
> = (async (args, context) => {
if (!args.score) {
throw new HttpError(422, "Score is required.");
}
if (!args.message) {
throw new HttpError(422, "Message is required.");
}
const feedback = await context.entities.Feedback.create({
data: {
score: args.score,
message: args.message,
projectId: args.projectId
}
})
})
export const getAppGenerationResult = (async (args, context) => {
const appId = args.appId;
const { Project } = context.entities;
@ -115,6 +136,32 @@ export const getAppGenerationResult = (async (args, context) => {
appId: string;
}>;
export const getFeedback = (async (args, context) => {
// TODO(matija): extract this, since it's used at multiple locations?
const emailsWhitelist = process.env.ADMIN_EMAILS_WHITELIST?.split(",") || [];
if (!context.user || !emailsWhitelist.includes(context.user.email)) {
throw new HttpError(401, "Only admins can access stats.");
}
const feedbackEntries = await context.entities.Feedback.findMany({
orderBy: {
createdAt: "desc",
},
include: {
project: {
select: {
name: true,
description: true,
}
}
}
})
return {
feedbackEntries
}
}) satisfies GetFeedback<{}>;
export const getStats = (async (_args, context) => {
const emailsWhitelist = process.env.ADMIN_EMAILS_WHITELIST?.split(",") || [];
if (!context.user || !emailsWhitelist.includes(context.user.email)) {