mirror of
https://github.com/wasp-lang/wasp.git
synced 2024-09-11 15:26:19 +03:00
wasp-ai: Added feedback form.
This commit is contained in:
parent
bcbd138244
commit
63d078dc44
@ -63,6 +63,12 @@ page StatsPage {
|
|||||||
authRequired: true
|
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 }
|
route LoginRoute { path: "/login", to: LoginPage }
|
||||||
page LoginPage {
|
page LoginPage {
|
||||||
component: import { LoginPage } from "@client/pages/LoginPage.jsx",
|
component: import { LoginPage } from "@client/pages/LoginPage.jsx",
|
||||||
@ -80,6 +86,17 @@ action registerZipDownload {
|
|||||||
entities: [Project]
|
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 {
|
query getAppGenerationResult {
|
||||||
fn: import { getAppGenerationResult } from "@server/operations.js",
|
fn: import { getAppGenerationResult } from "@server/operations.js",
|
||||||
entities: [
|
entities: [
|
||||||
@ -127,6 +144,17 @@ entity Project {=psl
|
|||||||
user User? @relation(fields: [userId], references: [id])
|
user User? @relation(fields: [userId], references: [id])
|
||||||
files File[]
|
files File[]
|
||||||
logs Log[]
|
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=}
|
psl=}
|
||||||
|
|
||||||
entity File {=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 getAppGenerationResult from "@wasp/queries/getAppGenerationResult";
|
||||||
import startGeneratingNewApp from "@wasp/actions/startGeneratingNewApp";
|
import startGeneratingNewApp from "@wasp/actions/startGeneratingNewApp";
|
||||||
import registerZipDownload from "@wasp/actions/registerZipDownload";
|
import registerZipDownload from "@wasp/actions/registerZipDownload";
|
||||||
|
import createFeedback from "@wasp/actions/createFeedback"
|
||||||
import { useQuery } from "@wasp/queries";
|
import { useQuery } from "@wasp/queries";
|
||||||
import { CodeHighlight } from "../components/CodeHighlight";
|
import { CodeHighlight } from "../components/CodeHighlight";
|
||||||
import { FileTree } from "../components/FileTree";
|
import { FileTree } from "../components/FileTree";
|
||||||
@ -9,6 +10,7 @@ import { createFilesAndDownloadZip } from "../zip/zipHelpers";
|
|||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useHistory } from "react-router-dom";
|
import { useHistory } from "react-router-dom";
|
||||||
|
import { RadioGroup } from '@headlessui/react'
|
||||||
import { Loader } from "../components/Loader";
|
import { Loader } from "../components/Loader";
|
||||||
import { MyDialog } from "../components/Dialog";
|
import { MyDialog } from "../components/Dialog";
|
||||||
import { Logs } from "../components/Logs";
|
import { Logs } from "../components/Logs";
|
||||||
@ -20,6 +22,7 @@ import {
|
|||||||
PiLaptopDuotone,
|
PiLaptopDuotone,
|
||||||
PiDownloadDuotone,
|
PiDownloadDuotone,
|
||||||
PiCheckDuotone,
|
PiCheckDuotone,
|
||||||
|
PiChatBold
|
||||||
} from "react-icons/pi";
|
} from "react-icons/pi";
|
||||||
import { RxQuestionMarkCircled } from "react-icons/rx";
|
import { RxQuestionMarkCircled } from "react-icons/rx";
|
||||||
|
|
||||||
@ -287,6 +290,7 @@ export const ResultPage = () => {
|
|||||||
<div>
|
<div>
|
||||||
<ShareButton />
|
<ShareButton />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FileTree
|
<FileTree
|
||||||
paths={interestingFilePaths}
|
paths={interestingFilePaths}
|
||||||
activeFilePath={activeFilePath}
|
activeFilePath={activeFilePath}
|
||||||
@ -303,8 +307,14 @@ export const ResultPage = () => {
|
|||||||
<main
|
<main
|
||||||
className={isMobileFileBrowserOpen ? "hidden md:block" : ""}
|
className={isMobileFileBrowserOpen ? "hidden md:block" : ""}
|
||||||
>
|
>
|
||||||
<div className="font-bold text-sm bg-slate-200 text-slate-700 p-3 rounded rounded-b-none">
|
<div
|
||||||
{activeFilePath}:
|
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>
|
||||||
<div
|
<div
|
||||||
key={activeFilePath}
|
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() {
|
function ShareButton() {
|
||||||
const [copying, setCopying] = useState(false);
|
const [copying, setCopying] = useState(false);
|
||||||
function copy() {
|
function copy() {
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
RegisterZipDownload,
|
RegisterZipDownload,
|
||||||
StartGeneratingNewApp,
|
StartGeneratingNewApp,
|
||||||
|
CreateFeedback
|
||||||
} from "@wasp/actions/types";
|
} 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 HttpError from "@wasp/core/HttpError.js";
|
||||||
import { checkPendingAppsJob } from "@wasp/jobs/checkPendingAppsJob.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) => {
|
export const getAppGenerationResult = (async (args, context) => {
|
||||||
const appId = args.appId;
|
const appId = args.appId;
|
||||||
const { Project } = context.entities;
|
const { Project } = context.entities;
|
||||||
@ -115,6 +136,32 @@ export const getAppGenerationResult = (async (args, context) => {
|
|||||||
appId: string;
|
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) => {
|
export const getStats = (async (_args, context) => {
|
||||||
const emailsWhitelist = process.env.ADMIN_EMAILS_WHITELIST?.split(",") || [];
|
const emailsWhitelist = process.env.ADMIN_EMAILS_WHITELIST?.split(",") || [];
|
||||||
if (!context.user || !emailsWhitelist.includes(context.user.email)) {
|
if (!context.user || !emailsWhitelist.includes(context.user.email)) {
|
||||||
|
Loading…
Reference in New Issue
Block a user