mirror of
https://github.com/wasp-lang/wasp.git
synced 2024-08-16 18:50:44 +03:00
wasp-ai: Added feedback form.
This commit is contained in:
parent
bcbd138244
commit
63d078dc44
@ -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
|
||||
|
@ -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;
|
@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "Feedback" ADD COLUMN "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP;
|
82
wasp-ai/src/client/pages/FeedbackPage.jsx
Normal file
82
wasp-ai/src/client/pages/FeedbackPage.jsx
Normal 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 →
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
@ -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() {
|
||||
|
@ -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)) {
|
||||
|
Loading…
Reference in New Issue
Block a user