Skip to content

Commit

Permalink
Create end-to-end form filling with SurveyJS
Browse files Browse the repository at this point in the history
  • Loading branch information
evadecker committed Dec 7, 2024
1 parent 9b0e814 commit 4d3c09d
Show file tree
Hide file tree
Showing 24 changed files with 498 additions and 260 deletions.
4 changes: 2 additions & 2 deletions convex/_generated/api.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import type * as questions from "../questions.js";
import type * as quests from "../quests.js";
import type * as seed from "../seed.js";
import type * as topics from "../topics.js";
import type * as userEncryptedData from "../userEncryptedData.js";
import type * as userFormData from "../userFormData.js";
import type * as userQuests from "../userQuests.js";
import type * as userSettings from "../userSettings.js";
import type * as users from "../users.js";
Expand All @@ -48,7 +48,7 @@ declare const fullApi: ApiFromModules<{
quests: typeof quests;
seed: typeof seed;
topics: typeof topics;
userEncryptedData: typeof userEncryptedData;
userFormData: typeof userFormData;
userQuests: typeof userQuests;
userSettings: typeof userSettings;
users: typeof users;
Expand Down
24 changes: 24 additions & 0 deletions convex/quests.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { v } from "convex/values";
import { Model } from "survey-core";
import { query } from "./_generated/server";
import { DEFAULT_TIME_REQUIRED } from "./constants";
import { userMutation } from "./helpers";
Expand Down Expand Up @@ -144,6 +145,29 @@ export const setContent = userMutation({
},
});

export const setFormSchema = userMutation({
args: {
questId: v.id("quests"),
saveNo: v.number(),
formSchema: v.optional(v.string()),
},
handler: async (ctx, args) => {
if (!args.formSchema) return;
try {
const submittedSchema = JSON.parse(args.formSchema);
const survey = new Model(submittedSchema);
const validatedSchema = survey.toJSON();

console.log("Validated schema:", JSON.stringify(validatedSchema));
await ctx.db.patch(args.questId, {
formSchema: JSON.stringify(validatedSchema),
});
} catch (e) {
console.error(e);
}
},
});

export const softDelete = userMutation({
args: { questId: v.id("quests") },
handler: async (ctx, args) => {
Expand Down
19 changes: 12 additions & 7 deletions convex/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,8 @@ const quests = defineTable({
deletionTime: v.optional(v.number()),
/** Rich text comprising the contents of the quest, stored as HTML. */
content: v.optional(v.string()),
/** A JSON schema defining the form fields for this quest. */
formSchema: v.optional(v.string()),
}).index("category", ["category"]);

/**
Expand Down Expand Up @@ -130,14 +132,17 @@ const users = defineTable({

/**
* A unique piece of user data that has been enteed through filling a form.
* End-to-end encrypted.
* TODO: Implement
*/
const userEncryptedData = defineTable({
const userFormData = defineTable({
/** The user who owns the data. */
userId: v.id("users"),
fieldId: v.string(),
value: v.string(),
}).index("userId", ["userId"]);
/** The name of the field, e.g. "firstName". */
field: v.string(),
/** The value of the field. */
value: v.any(),
})
.index("userId", ["userId"])
.index("userIdAndField", ["userId", "field"]);

/**
* A user's preferences.
Expand Down Expand Up @@ -174,7 +179,7 @@ export default defineSchema({
documents,
quests,
users,
userEncryptedData,
userFormData,
userSettings,
userQuests,
});
13 changes: 0 additions & 13 deletions convex/userEncryptedData.ts

This file was deleted.

42 changes: 42 additions & 0 deletions convex/userFormData.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { v } from "convex/values";
import { userMutation, userQuery } from "./helpers";

export const get = userQuery({
args: {},
handler: async (ctx, _args) => {
const userData = await ctx.db
.query("userFormData")
.withIndex("userId", (q) => q.eq("userId", ctx.userId))
.first();

return userData;
},
});

export const set = userMutation({
args: {
field: v.string(),
value: v.any(),
},
handler: async (ctx, args) => {
// If data already exists, update it
const existingData = await ctx.db
.query("userFormData")
.withIndex("userIdAndField", (q) =>
q.eq("userId", ctx.userId).eq("field", args.field),
)
.first();

if (existingData) {
await ctx.db.patch(existingData._id, { value: args.value });
return;
}

// Otherwise, insert new data
await ctx.db.insert("userFormData", {
userId: ctx.userId,
field: args.field,
value: args.value,
});
},
});
2 changes: 1 addition & 1 deletion src/components/common/Button/Button.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ export function Button({
}),
)}
>
{Icon && <Icon size={size === "small" ? 12 : 16} />}
{Icon && <Icon size={16} />}
{children}
</AriaButton>
);
Expand Down
10 changes: 8 additions & 2 deletions src/components/common/Modal/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
Heading,
ModalOverlay,
type ModalOverlayProps,
composeRenderProps,
} from "react-aria-components";
import { tv } from "tailwind-variants";
import { Dialog } from "../Dialog";
Expand Down Expand Up @@ -31,11 +32,16 @@ const modalStyles = tv({
},
});

export function Modal(props: ModalOverlayProps) {
export function Modal({ className, ...props }: ModalOverlayProps) {
return (
<ModalOverlay {...props} className={overlayStyles}>
<Dialog>
<AriaModal {...props} className={modalStyles}>
<AriaModal
{...props}
className={composeRenderProps(className, (className, renderProps) =>
modalStyles({ ...renderProps, className }),
)}
>
{props.children}
</AriaModal>
</Dialog>
Expand Down
10 changes: 6 additions & 4 deletions src/components/quests/DocumentCard/DocumentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ export const DocumentCard = ({
const fileTitle = code ? `${code} ${title}` : title;

return (
<div className="flex flex-col w-48 h-60 shrink-0 p-4 bg-gray-1 dark:bg-graydark-3 shadow-md rounded">
{code && <p className="text-gray-dim text-sm mb-1">{code}</p>}
<header className="font-medium text-pretty leading-tight">{title}</header>
<div className="flex flex-col w-48 h-60 shrink-0 p-4 bg-white shadow-md rounded">
{code && <p className="text-gray-11 text-sm mb-1">{code}</p>}
<header className="font-medium text-pretty leading-tight text-gray-12">
{title}
</header>
<div className="mt-auto -mb-2 -mr-2 flex justify-end">
{downloadUrl && (
<TooltipTrigger>
Expand All @@ -28,7 +30,7 @@ export const DocumentCard = ({
className="mt-auto self-end"
download={fileTitle}
>
<CircleArrowDown size={16} className="text-gray-dim" />
<CircleArrowDown size={16} className="text-gray-11" />
</Link>
<Tooltip>Download</Tooltip>
</TooltipTrigger>
Expand Down
61 changes: 61 additions & 0 deletions src/components/quests/QuestForm/QuestForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import "survey-core/defaultV2.min.css";

import { Button, Modal } from "@/components/common";
import { api } from "@convex/_generated/api";
import type { Doc } from "@convex/_generated/dataModel";
import { useMutation } from "convex/react";
import { useTheme } from "next-themes";
import { useState } from "react";
import type { CompleteEvent, SurveyModel } from "survey-core";
import {
LayeredDarkPanelless,
LayeredLightPanelless,
} from "survey-core/themes";
import { Model, Survey } from "survey-react-ui";

export type QuestFormProps = {
quest: Doc<"quests">;
};

export const QuestForm = ({ quest }: QuestFormProps) => {
const { resolvedTheme } = useTheme();
const [isSurveyOpen, setIsSurveyOpen] = useState(false);
const saveData = useMutation(api.userFormData.set);

if (!quest.formSchema) return null;

const survey = new Model(quest.formSchema);
survey.applyTheme(
resolvedTheme === "dark" ? LayeredDarkPanelless : LayeredLightPanelless,
);

const handleSubmit = async (sender: SurveyModel, _options: CompleteEvent) => {
try {
// Validate JSON against schema and remove invalid values
sender.clearIncorrectValues(true);
for (const key in sender.data) {
const question = sender.getQuestionByName(key);
if (question) {
saveData({
field: key,
value: question.value,
});
}
}
setIsSurveyOpen(false);
} catch (err) {
console.error(err);
}
};

survey.onComplete.add(handleSubmit);

return (
<>
<Button onPress={() => setIsSurveyOpen(true)}>Get Started</Button>
<Modal isOpen={isSurveyOpen}>
<Survey model={survey} />
</Modal>
</>
);
};
1 change: 1 addition & 0 deletions src/components/quests/QuestForm/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from "./QuestForm";
37 changes: 0 additions & 37 deletions src/components/quests/QuestSurvey/QuestSurvey.tsx

This file was deleted.

1 change: 0 additions & 1 deletion src/components/quests/QuestSurvey/index.ts

This file was deleted.

2 changes: 1 addition & 1 deletion src/components/quests/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ export * from "./EditQuestCostsModal";
export * from "./EditQuestTimeRequiredModal";
export * from "./QuestCosts";
export * from "./QuestForms";
export * from "./QuestSurvey";
export * from "./QuestForm";
export * from "./QuestTimeRequired";
export * from "./QuestUrls";
export * from "./ReadingScore";
Expand Down
2 changes: 2 additions & 0 deletions src/main.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,10 @@ import { ThemeProvider } from "next-themes";
import { StrictMode, useEffect } from "react";
import { createRoot } from "react-dom/client";
import { HelmetProvider } from "react-helmet-async";
import { setLicenseKey } from "survey-core";
import { routeTree } from "./routeTree.gen";

setLicenseKey(import.meta.env.VITE_SURVEY_JS_KEY);
const convex = new ConvexReactClient(import.meta.env.VITE_CONVEX_URL);

const NotFoundComponent = () => (
Expand Down
Loading

0 comments on commit 4d3c09d

Please sign in to comment.