From dc09f496b7148719a0e747270eb5ad66754c5fb3 Mon Sep 17 00:00:00 2001 From: Eva Decker Date: Fri, 27 Sep 2024 15:24:16 -0400 Subject: [PATCH] feat: Add fields to admin, data to settings (#112) --- .changeset/rare-donkeys-beam.md | 5 + convex/_generated/api.d.ts | 4 + convex/questFields.ts | 28 +++ convex/schema.ts | 73 ++++++-- convex/userData.ts | 13 ++ convex/validators.ts | 45 +---- src/components/Nav/Nav.tsx | 11 +- src/routeTree.gen.ts | 144 ++++++++++++-- .../_authenticated/admin/fields/index.tsx | 176 ++++++++++++++++++ src/routes/_authenticated/admin/route.tsx | 22 +-- src/routes/_authenticated/settings/data.tsx | 21 +++ src/routes/_authenticated/settings/index.tsx | 176 +----------------- .../_authenticated/settings/overview.tsx | 174 +++++++++++++++++ src/routes/_authenticated/settings/route.tsx | 34 ++++ 14 files changed, 658 insertions(+), 268 deletions(-) create mode 100644 .changeset/rare-donkeys-beam.md create mode 100644 convex/questFields.ts create mode 100644 convex/userData.ts create mode 100644 src/routes/_authenticated/admin/fields/index.tsx create mode 100644 src/routes/_authenticated/settings/data.tsx create mode 100644 src/routes/_authenticated/settings/overview.tsx create mode 100644 src/routes/_authenticated/settings/route.tsx diff --git a/.changeset/rare-donkeys-beam.md b/.changeset/rare-donkeys-beam.md new file mode 100644 index 0000000..44a0541 --- /dev/null +++ b/.changeset/rare-donkeys-beam.md @@ -0,0 +1,5 @@ +--- +"namesake": minor +--- + +Support defining quest fields and displaying user data diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 7f99f94..4e64ffc 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -20,9 +20,11 @@ import type * as constants from "../constants.js"; import type * as forms from "../forms.js"; import type * as helpers from "../helpers.js"; import type * as http from "../http.js"; +import type * as questFields from "../questFields.js"; import type * as questSteps from "../questSteps.js"; import type * as quests from "../quests.js"; import type * as seed from "../seed.js"; +import type * as userData from "../userData.js"; import type * as userQuests from "../userQuests.js"; import type * as users from "../users.js"; import type * as validators from "../validators.js"; @@ -41,9 +43,11 @@ declare const fullApi: ApiFromModules<{ forms: typeof forms; helpers: typeof helpers; http: typeof http; + questFields: typeof questFields; questSteps: typeof questSteps; quests: typeof quests; seed: typeof seed; + userData: typeof userData; userQuests: typeof userQuests; users: typeof users; validators: typeof validators; diff --git a/convex/questFields.ts b/convex/questFields.ts new file mode 100644 index 0000000..39b807f --- /dev/null +++ b/convex/questFields.ts @@ -0,0 +1,28 @@ +import { v } from "convex/values"; +import { query } from "./_generated/server"; +import { userMutation } from "./helpers"; +import { field } from "./validators"; + +export const getAllFields = query({ + args: {}, + handler: async (ctx) => { + return await ctx.db.query("questFields").collect(); + }, +}); + +export const createField = userMutation({ + args: { + type: field, + label: v.string(), + slug: v.string(), + helpText: v.optional(v.string()), + }, + handler: async (ctx, args) => { + return await ctx.db.insert("questFields", { + type: args.type, + label: args.label, + slug: args.slug, + helpText: args.helpText, + }); + }, +}); diff --git a/convex/schema.ts b/convex/schema.ts index b489719..b66a471 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -3,24 +3,6 @@ import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; import { field, icon, jurisdiction, role, theme } from "./validators"; -/** - * Represents a PDF form that can be filled out by users. - * @param title - The title of the form. (e.g. "Petition to Change Name of Adult") - * @param formCode - The legal code for the form. (e.g. "CJP 27") - * @param creationUser - The user who created the form. - * @param file - The storageId for the PDF file. - * @param state - The US State the form applies to. (e.g. "MA") - * @param deletionTime - Time in ms since epoch when the form was deleted. - */ -const forms = defineTable({ - title: v.string(), - formCode: v.optional(v.string()), - creationUser: v.id("users"), - file: v.optional(v.id("_storage")), - jurisdiction: jurisdiction, - deletionTime: v.optional(v.number()), -}); - /** * Represents a collection of steps and forms for a user to complete. * @param title - The title of the quest. (e.g. "Court Order") @@ -49,9 +31,48 @@ const questSteps = defineTable({ creationUser: v.id("users"), title: v.string(), description: v.optional(v.string()), - fields: v.optional(v.array(field)), + fields: v.optional( + v.array( + v.object({ + fieldId: v.id("questFields"), + }), + ), + ), }).index("questId", ["questId"]); +/** + * Represents a single input field which may be shared across multiple quests + * or steps. Data entered into these fields are end-to-end encrypted and used + * to pre-fill fields that point to the same data in future quests. + * @param type - The type of field. (e.g. "text", "select") + * @param label - The label for the field. (e.g. "First Name") + * @param helpText - Additional help text for the field. + */ +const questFields = defineTable({ + type: field, + label: v.string(), + slug: v.string(), + helpText: v.optional(v.string()), +}); + +/** + * Represents a PDF form that can be filled out by users. + * @param title - The title of the form. (e.g. "Petition to Change Name of Adult") + * @param formCode - The legal code for the form. (e.g. "CJP 27") + * @param creationUser - The user who created the form. + * @param file - The storageId for the PDF file. + * @param state - The US State the form applies to. (e.g. "MA") + * @param deletionTime - Time in ms since epoch when the form was deleted. + */ +const forms = defineTable({ + title: v.string(), + formCode: v.optional(v.string()), + creationUser: v.id("users"), + file: v.optional(v.id("_storage")), + jurisdiction: jurisdiction, + deletionTime: v.optional(v.number()), +}); + /** * Represents a user of Namesake. * @param name - The user's preferred first name. @@ -69,7 +90,7 @@ const users = defineTable({ image: v.optional(v.string()), email: v.optional(v.string()), emailVerified: v.boolean(), - isAnonymous: v.optional(v.boolean()), + jurisdiction: v.optional(jurisdiction), isMinor: v.optional(v.boolean()), theme: theme, }).index("email", ["email"]); @@ -88,11 +109,23 @@ const userQuests = defineTable({ .index("userId", ["userId"]) .index("questId", ["questId"]); +/** + * Pre-fillable user data entered throughout quests. + * All data in this table is end-to-end encrypted. + */ +const userData = defineTable({ + userId: v.id("users"), + fieldId: v.id("fields"), + value: v.string(), +}).index("userId", ["userId"]); + export default defineSchema({ ...authTables, forms, quests, questSteps, + questFields, users, userQuests, + userData, }); diff --git a/convex/userData.ts b/convex/userData.ts new file mode 100644 index 0000000..4e89090 --- /dev/null +++ b/convex/userData.ts @@ -0,0 +1,13 @@ +import { userQuery } from "./helpers"; + +export const getUserData = userQuery({ + args: {}, + handler: async (ctx, _args) => { + const userData = await ctx.db + .query("userData") + .withIndex("userId", (q) => q.eq("userId", ctx.userId)) + .first(); + + return userData; + }, +}); diff --git a/convex/validators.ts b/convex/validators.ts index b8753a5..0f4b729 100644 --- a/convex/validators.ts +++ b/convex/validators.ts @@ -1,12 +1,5 @@ import { v } from "convex/values"; -import { - FIELDS, - type Field, - ICONS, - JURISDICTIONS, - ROLES, - THEMES, -} from "./constants"; +import { FIELDS, ICONS, JURISDICTIONS, ROLES, THEMES } from "./constants"; export const jurisdiction = v.union( ...Object.keys(JURISDICTIONS).map((jurisdiction) => v.literal(jurisdiction)), @@ -24,40 +17,6 @@ export const icon = v.union( ...Object.keys(ICONS).map((icon) => v.literal(icon)), ); -const sharedFieldProps = { - label: v.string(), - helpText: v.optional(v.string()), - isRequired: v.boolean(), -}; - -const optionsForField: any = (field: Field) => { - switch (field) { - case "select": - return { - options: v.array(v.string()), - }; - case "number": - return { - min: v.optional(v.number()), - max: v.optional(v.number()), - }; - case "text": - case "textarea": - return { - minLength: v.optional(v.number()), - maxLength: v.optional(v.number()), - }; - default: - return {}; - } -}; - export const field = v.union( - ...Object.keys(FIELDS).map((field) => - v.object({ - type: v.literal(field), - ...sharedFieldProps, - ...optionsForField(field as keyof typeof FIELDS), - }), - ), + ...Object.keys(FIELDS).map((field) => v.literal(field)), ); diff --git a/src/components/Nav/Nav.tsx b/src/components/Nav/Nav.tsx index 52fb13f..fec0721 100644 --- a/src/components/Nav/Nav.tsx +++ b/src/components/Nav/Nav.tsx @@ -6,10 +6,7 @@ import { focusRing } from "../utils"; interface NavProps { routes: { - icon: { - default: RemixiconComponentType; - active: RemixiconComponentType; - }; + icon: RemixiconComponentType; href: LinkProps; label: string; }[]; @@ -17,7 +14,7 @@ interface NavProps { const styles = tv({ extend: focusRing, - base: "rounded-lg no-underline flex items-center gap-2 text-gray-dim hover:text-gray-normal py-1 aria-current:font-semibold aria-current:text-gray-normal", + base: "rounded-lg no-underline flex items-center gap-2 text-gray-dim hover:text-gray-normal py-1.5 aria-current:font-semibold aria-current:text-gray-normal", }); export const Nav = ({ routes }: NavProps) => { @@ -27,7 +24,7 @@ export const Nav = ({ routes }: NavProps) => {