From e93f7cc3ad17db374d187ba6cf3f220c826d46c4 Mon Sep 17 00:00:00 2001 From: Eva Decker Date: Thu, 26 Sep 2024 21:15:30 -0400 Subject: [PATCH] feat: Display quest icons, refactor quest steps schema (#108) --- .changeset/good-chairs-peel.md | 5 + convex/_generated/api.d.ts | 4 +- convex/constants.ts | 171 +++++++++++++ convex/formFields.ts | 15 -- convex/questSteps.ts | 46 ++++ convex/quests.ts | 26 +- convex/schema.ts | 236 ++++++++++-------- convex/seed.ts | 13 + convex/types.ts | 7 +- src/components/Button/Button.tsx | 4 +- src/components/Link/Link.tsx | 18 +- src/components/PageHeader/PageHeader.tsx | 5 + .../_authenticated/admin/forms/$formId.tsx | 6 - .../_authenticated/admin/quests/$questId.tsx | 54 ++-- .../_authenticated/admin/quests/index.tsx | 40 ++- src/routes/_authenticated/quests/$questId.tsx | 31 ++- src/routes/_authenticated/quests/route.tsx | 38 +-- 17 files changed, 511 insertions(+), 208 deletions(-) create mode 100644 .changeset/good-chairs-peel.md delete mode 100644 convex/formFields.ts create mode 100644 convex/questSteps.ts diff --git a/.changeset/good-chairs-peel.md b/.changeset/good-chairs-peel.md new file mode 100644 index 0000000..8f90f0b --- /dev/null +++ b/.changeset/good-chairs-peel.md @@ -0,0 +1,5 @@ +--- +"namesake": minor +--- + +Display icons for quests and improve quest step appearance diff --git a/convex/_generated/api.d.ts b/convex/_generated/api.d.ts index 0b0c537..8d317fc 100644 --- a/convex/_generated/api.d.ts +++ b/convex/_generated/api.d.ts @@ -17,10 +17,10 @@ import type { } from "convex/server"; import type * as auth from "../auth.js"; import type * as constants from "../constants.js"; -import type * as formFields from "../formFields.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 questSteps from "../questSteps.js"; import type * as quests from "../quests.js"; import type * as seed from "../seed.js"; import type * as types from "../types.js"; @@ -38,10 +38,10 @@ import type * as usersQuests from "../usersQuests.js"; declare const fullApi: ApiFromModules<{ auth: typeof auth; constants: typeof constants; - formFields: typeof formFields; forms: typeof forms; helpers: typeof helpers; http: typeof http; + questSteps: typeof questSteps; quests: typeof quests; seed: typeof seed; types: typeof types; diff --git a/convex/constants.ts b/convex/constants.ts index c9165f1..d9b9c12 100644 --- a/convex/constants.ts +++ b/convex/constants.ts @@ -1,3 +1,174 @@ +import { + type RemixiconComponentType, + RiAccountCircleLine, + RiAppleFill, + RiAuctionLine, + RiAwardLine, + RiBankCardLine, + RiBankLine, + RiBasketballLine, + RiBlueskyFill, + RiBuildingLine, + RiCakeLine, + RiCapsuleLine, + RiCarLine, + RiChat1Line, + RiChromeFill, + RiCodeLine, + RiCommunityLine, + RiContactsBookLine, + RiDiscordFill, + RiDropboxFill, + RiFacebookCircleFill, + RiFileLine, + RiFlowerLine, + RiFolder2Line, + RiGithubFill, + RiGlobalLine, + RiGoogleFill, + RiGovernmentLine, + RiGraduationCapLine, + RiHandHeartLine, + RiHeartLine, + RiHomeLine, + RiHospitalLine, + RiIdCardLine, + RiImageLine, + RiInstagramFill, + RiKeyLine, + RiLightbulbLine, + RiLinkedinFill, + RiLock2Line, + RiMailLine, + RiMediumFill, + RiMentalHealthLine, + RiMusic2Line, + RiNewsLine, + RiPatreonFill, + RiPhoneLine, + RiPinterestFill, + RiPlantLine, + RiPlaystationFill, + RiPoliceBadgeLine, + RiRedditFill, + RiRestaurantLine, + RiRobot2Line, + RiScales3Line, + RiSchoolLine, + RiSettings3Line, + RiShapesLine, + RiShieldUserLine, + RiSignpostLine, + RiSlackFill, + RiSmartphoneLine, + RiSnapchatFill, + RiSofaLine, + RiSoundcloudFill, + RiSparklingLine, + RiSpotifyFill, + RiStore2Line, + RiSwitchFill, + RiSwordLine, + RiTeamLine, + RiTentLine, + RiTiktokFill, + RiToothLine, + RiTrophyLine, + RiTv2Line, + RiTwitterXFill, + RiUserLine, + RiWalletLine, + RiWhatsappFill, + RiWindowsFill, + RiYoutubeFill, +} from "@remixicon/react"; + +export const ICONS: Record = { + account: RiAccountCircleLine, + bank: RiBankLine, + basketball: RiBasketballLine, + building: RiBuildingLine, + cake: RiCakeLine, + car: RiCarLine, + certificate: RiAwardLine, + chat: RiChat1Line, + code: RiCodeLine, + college: RiGraduationCapLine, + community: RiCommunityLine, + contacts: RiContactsBookLine, + creditCard: RiBankCardLine, + file: RiFileLine, + flower: RiFlowerLine, + folder: RiFolder2Line, + food: RiRestaurantLine, + gavel: RiAuctionLine, + giving: RiHandHeartLine, + global: RiGlobalLine, + government: RiGovernmentLine, + heart: RiHeartLine, + home: RiHomeLine, + hospital: RiHospitalLine, + id: RiIdCardLine, + image: RiImageLine, + key: RiKeyLine, + lightbulb: RiLightbulbLine, + lock: RiLock2Line, + mail: RiMailLine, + mentalHealth: RiMentalHealthLine, + music: RiMusic2Line, + news: RiNewsLine, + phone: RiPhoneLine, + pill: RiCapsuleLine, + plant: RiPlantLine, + police: RiPoliceBadgeLine, + robot: RiRobot2Line, + scale: RiScales3Line, + school: RiSchoolLine, + settings: RiSettings3Line, + shapes: RiShapesLine, + signpost: RiSignpostLine, + smartphone: RiSmartphoneLine, + socialSecurity: RiShieldUserLine, + sofa: RiSofaLine, + sparkles: RiSparklingLine, + store: RiStore2Line, + sword: RiSwordLine, + team: RiTeamLine, + tent: RiTentLine, + tooth: RiToothLine, + trophy: RiTrophyLine, + tv: RiTv2Line, + user: RiUserLine, + wallet: RiWalletLine, + + // Logos + apple: RiAppleFill, + bluesky: RiBlueskyFill, + chrome: RiChromeFill, + discord: RiDiscordFill, + dropbox: RiDropboxFill, + facebook: RiFacebookCircleFill, + github: RiGithubFill, + google: RiGoogleFill, + instagram: RiInstagramFill, + linkedin: RiLinkedinFill, + medium: RiMediumFill, + patreon: RiPatreonFill, + pinterest: RiPinterestFill, + playstation: RiPlaystationFill, + reddit: RiRedditFill, + slack: RiSlackFill, + snapchat: RiSnapchatFill, + soundcloud: RiSoundcloudFill, + spotify: RiSpotifyFill, + switch: RiSwitchFill, + tiktok: RiTiktokFill, + twitter: RiTwitterXFill, + whatsapp: RiWhatsappFill, + windows: RiWindowsFill, + youtube: RiYoutubeFill, +}; + export enum JURISDICTIONS { AK = "Alaska", AL = "Alabama", diff --git a/convex/formFields.ts b/convex/formFields.ts deleted file mode 100644 index a9dd997..0000000 --- a/convex/formFields.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { v } from "convex/values"; -import { query } from "./_generated/server"; - -// TODO: Add `returns` value validation -// https://docs.convex.dev/functions/validation - -export const getAllFieldsForForm = query({ - args: { formId: v.id("forms") }, - handler: async (ctx, args) => { - return await ctx.db - .query("formFields") - .withIndex("formId", (q) => q.eq("formId", args.formId)) - .collect(); - }, -}); diff --git a/convex/questSteps.ts b/convex/questSteps.ts new file mode 100644 index 0000000..fbc84a7 --- /dev/null +++ b/convex/questSteps.ts @@ -0,0 +1,46 @@ +import { v } from "convex/values"; +import { query } from "./_generated/server"; +import { userMutation } from "./helpers"; + +// TODO: Add `returns` value validation +// https://docs.convex.dev/functions/validation + +export const create = userMutation({ + args: { + questId: v.id("quests"), + title: v.string(), + description: v.optional(v.string()), + }, + handler: async (ctx, args) => { + const questStepId = await ctx.db.insert("questSteps", { + questId: args.questId, + title: args.title, + description: args.description, + creationUser: ctx.userId, + }); + + const quest = await ctx.db.get(args.questId); + + if (!quest) throw new Error("Quest not found"); + + await ctx.db.patch(args.questId, { + steps: [...(quest.steps ?? []), questStepId], + }); + }, +}); + +export const getStepsForQuest = query({ + args: { questId: v.id("quests") }, + handler: async (ctx, args) => { + const quest = await ctx.db.get(args.questId); + if (!quest) throw new Error("Quest not found"); + if (!quest.steps) return []; + + const steps = await Promise.all( + quest.steps.map(async (stepId) => { + return await ctx.db.get(stepId); + }), + ); + return steps; + }, +}); diff --git a/convex/quests.ts b/convex/quests.ts index f33e42e..305668b 100644 --- a/convex/quests.ts +++ b/convex/quests.ts @@ -1,7 +1,7 @@ import { v } from "convex/values"; import { query } from "./_generated/server"; import { userMutation } from "./helpers"; -import { jurisdiction } from "./types"; +import { icon, jurisdiction } from "./types"; // TODO: Add `returns` value validation // https://docs.convex.dev/functions/validation @@ -31,33 +31,21 @@ export const getQuest = query({ }); export const createQuest = userMutation({ - args: { title: v.string(), jurisdiction: v.optional(jurisdiction) }, + args: { + title: v.string(), + jurisdiction: v.optional(jurisdiction), + icon: icon, + }, handler: async (ctx, args) => { return await ctx.db.insert("quests", { title: args.title, + icon: args.icon, jurisdiction: args.jurisdiction, creationUser: ctx.userId, }); }, }); -export const addQuestStep = userMutation({ - args: { questId: v.id("quests"), title: v.string(), body: v.string() }, - handler: async (ctx, args) => { - const existingSteps = (await ctx.db.get(args.questId))?.steps ?? []; - - await ctx.db.patch(args.questId, { - steps: [ - ...existingSteps, - { - title: args.title, - body: args.body, - }, - ], - }); - }, -}); - export const deleteQuest = userMutation({ args: { questId: v.id("quests") }, handler: async (ctx, args) => { diff --git a/convex/schema.ts b/convex/schema.ts index 208acbe..a35f40d 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1,122 +1,142 @@ import { authTables } from "@convex-dev/auth/server"; import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; -import { jurisdiction, role, theme } from "./types"; +import { icon, jurisdiction, role, theme } from "./types"; -export default defineSchema({ - ...authTables, - - /** - * 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. - */ - 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 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 single field of a form. - * @param formId - The form this field belongs to. - * @param label - The label of the field. - * @param type - The data type of the field. - * @param maxLength - The maximum character length of the field. - */ - formFields: defineTable({ - formId: v.id("forms"), - label: v.string(), - // TODO: Make this a constant - type: v.union( - v.literal("boolean"), - v.literal("date"), - v.literal("number"), - v.literal("string"), - ), - maxLength: v.optional(v.number()), - }).index("formId", ["formId"]), +/** + * Represents a collection of steps and forms for a user to complete. + * @param title - The title of the quest. (e.g. "Court Order") + * @param creationUser - The user who created the quest. + * @param state - The US State the quest applies to. (e.g. "MA") + * @param deletionTime - Time in ms since epoch when the quest was deleted. + * @param steps - An ordered list of steps to complete the quest. + */ +const quests = defineTable({ + icon: icon, + title: v.string(), + creationUser: v.id("users"), + jurisdiction: v.optional(jurisdiction), + deletionTime: v.optional(v.number()), + steps: v.optional(v.array(v.id("questSteps"))), +}); - /** - * Represents a single question presented to a user as part of a quest. - * May contain one or more form fields. - * @param formId - The form this question is based on. - * @param question - The question to ask the user. - * @param description - Additional context for the question. - * @param fields - Form fields to present to the user. - * @param isRequired - Whether the user must answer the question. - */ - formQuestions: defineTable({ - formId: v.id("forms"), - question: v.string(), - description: v.optional(v.string()), - fields: v.array(v.id("formFields")), - isRequired: v.boolean(), - }).index("formId", ["formId"]), +const sharedFieldProps = { + label: v.string(), + helpText: v.optional(v.string()), + isRequired: v.boolean(), +}; - /** - * Represents a collection of steps and forms for a user to complete. - * @param title - The title of the quest. (e.g. "Court Order") - * @param creationUser - The user who created the quest. - * @param state - The US State the quest applies to. (e.g. "MA") - * @param deletionTime - Time in ms since epoch when the quest was deleted. - * @param steps - An ordered list of steps to complete the quest. - */ - quests: defineTable({ - title: v.string(), - creationUser: v.id("users"), - jurisdiction: v.optional(jurisdiction), - deletionTime: v.optional(v.number()), - steps: v.optional( - v.array( +/** + * Represents a single step in a quest. + * @param title - The title of the step. (e.g. "Fill out form") + * @param description - A description of the step. + * @param fields - An array of form fields to complete the step. + */ +const questSteps = defineTable({ + questId: v.id("quests"), + creationUser: v.id("users"), + title: v.string(), + description: v.optional(v.string()), + fields: v.optional( + v.array( + v.union( + v.object({ + ...sharedFieldProps, + type: v.literal("text"), + }), + v.object({ + ...sharedFieldProps, + type: v.literal("textarea"), + }), + v.object({ + ...sharedFieldProps, + type: v.literal("date"), + }), v.object({ - title: v.string(), - body: v.optional(v.string()), + ...sharedFieldProps, + type: v.literal("select"), + options: v.array(v.string()), + }), + v.object({ + ...sharedFieldProps, + type: v.literal("checkbox"), + }), + v.object({ + ...sharedFieldProps, + type: v.literal("number"), + }), + v.object({ + ...sharedFieldProps, + type: v.literal("email"), + }), + v.object({ + ...sharedFieldProps, + type: v.literal("phone"), }), ), ), - }), + ), +}).index("questId", ["questId"]); + +/** + * Represents a user of Namesake. + * @param name - The user's preferred first name. + * @param role - The user's role: "admin", "editor", or "user". + * @param image - A URL to the user's profile picture. + * @param email - The user's email address. + * @param emailVerificationTime - Time in ms since epoch when the user verified their email. + * @param isAnonymous - Denotes anonymous/unauthenticated users. + * @param isMinor - Denotes users under 18. + * @param preferredTheme - The user's preferred color scheme. + */ +const users = defineTable({ + name: v.optional(v.string()), + role: role, + image: v.optional(v.string()), + email: v.optional(v.string()), + emailVerified: v.boolean(), + isAnonymous: v.optional(v.boolean()), + isMinor: v.optional(v.boolean()), + theme: theme, +}).index("email", ["email"]); - /** - * Represents a user of Namesake. - * @param name - The user's preferred first name. - * @param role - The user's role: "admin", "editor", or "user". - * @param image - A URL to the user's profile picture. - * @param email - The user's email address. - * @param emailVerificationTime - Time in ms since epoch when the user verified their email. - * @param isAnonymous - Denotes anonymous/unauthenticated users. - * @param isMinor - Denotes users under 18. - * @param preferredTheme - The user's preferred color scheme. - */ - users: defineTable({ - name: v.optional(v.string()), - role: role, - image: v.optional(v.string()), - email: v.optional(v.string()), - emailVerified: v.boolean(), - isAnonymous: v.optional(v.boolean()), - isMinor: v.optional(v.boolean()), - theme: theme, - }).index("email", ["email"]), +/** + * Represents a user's unique progress in completing a quest. + * @param userId + * @param questId + * @param completionTime - Time in ms since epoch when the user marked the quest as complete. + */ +const usersQuests = defineTable({ + userId: v.id("users"), + questId: v.id("quests"), + completionTime: v.optional(v.number()), +}) + .index("userId", ["userId"]) + .index("questId", ["questId"]); - /** - * Represents a user's unique progress in completing a quest. - * @param userId - * @param questId - * @param completionTime - Time in ms since epoch when the user marked the quest as complete. - */ - usersQuests: defineTable({ - userId: v.id("users"), - questId: v.id("quests"), - completionTime: v.optional(v.number()), - }) - .index("userId", ["userId"]) - .index("questId", ["questId"]), +export default defineSchema({ + ...authTables, + forms, + quests, + questSteps, + users, + usersQuests, }); diff --git a/convex/seed.ts b/convex/seed.ts index 9e1b550..8a86931 100644 --- a/convex/seed.ts +++ b/convex/seed.ts @@ -44,7 +44,20 @@ const seed = internalMutation(async (ctx) => { const questJurisdiction = faker.helpers.arrayElement( Object.keys(JURISDICTIONS), ); + const iconForQuestTitle = (title: string) => { + switch (title) { + case "Court Order": + return "gavel"; + case "State ID": + return "id"; + case "Birth Certificate": + return "certificate"; + default: + return "signpost"; + } + }; await ctx.db.insert("quests", { + icon: iconForQuestTitle(questTitle), title: questTitle, jurisdiction: questJurisdiction, creationUser: userId, diff --git a/convex/types.ts b/convex/types.ts index 749857d..6f54660 100644 --- a/convex/types.ts +++ b/convex/types.ts @@ -1,5 +1,5 @@ import { type Infer, v } from "convex/values"; -import { JURISDICTIONS } from "./constants"; +import { ICONS, JURISDICTIONS } from "./constants"; export type Jurisdiction = Infer; export const jurisdiction = v.union( @@ -19,3 +19,8 @@ export const role = v.union( v.literal("editor"), v.literal("admin"), ); + +export type Icon = Infer; +export const icon = v.union( + ...Object.keys(ICONS).map((icon) => v.literal(icon)), +); diff --git a/src/components/Button/Button.tsx b/src/components/Button/Button.tsx index d651d35..56bdd0b 100644 --- a/src/components/Button/Button.tsx +++ b/src/components/Button/Button.tsx @@ -10,7 +10,7 @@ export interface ButtonProps extends AriaButtonProps { variant?: "primary" | "secondary" | "destructive" | "icon"; } -const button = tv({ +export const buttonStyles = tv({ extend: focusRing, base: "px-3 py-2 h-10 text-sm font-medium transition rounded-lg flex gap-1 items-center justify-center border border-black/10 dark:border-white/10 cursor-pointer", variants: { @@ -34,7 +34,7 @@ export function Button(props: ButtonProps) { - button({ ...renderProps, variant: props.variant, className }), + buttonStyles({ ...renderProps, variant: props.variant, className }), )} /> ); diff --git a/src/components/Link/Link.tsx b/src/components/Link/Link.tsx index e7f8da9..e28c8ce 100644 --- a/src/components/Link/Link.tsx +++ b/src/components/Link/Link.tsx @@ -4,13 +4,15 @@ import { composeRenderProps, } from "react-aria-components"; import { tv } from "tailwind-variants"; +import { type ButtonProps, buttonStyles } from "../Button"; import { focusRing } from "../utils"; interface LinkProps extends AriaLinkProps { variant?: "primary" | "secondary"; + button?: ButtonProps; } -const styles = tv({ +const linkStyles = tv({ extend: focusRing, base: "underline disabled:no-underline disabled:cursor-default forced-colors:disabled:text-[GrayText] transition rounded", variants: { @@ -24,12 +26,20 @@ const styles = tv({ }, }); -export function Link(props: LinkProps) { +export function Link({ button, ...props }: LinkProps) { return ( - styles({ ...renderProps, className, variant: props.variant }), + className={composeRenderProps( + props.className, + (className, renderProps) => + button + ? buttonStyles({ + ...renderProps, + className, + variant: props.variant, + }) + : linkStyles({ ...renderProps, className, variant: props.variant }), )} /> ); diff --git a/src/components/PageHeader/PageHeader.tsx b/src/components/PageHeader/PageHeader.tsx index e18be10..4bfcfaa 100644 --- a/src/components/PageHeader/PageHeader.tsx +++ b/src/components/PageHeader/PageHeader.tsx @@ -1,5 +1,8 @@ +import type { RemixiconComponentType } from "@remixicon/react"; + export interface PageHeaderProps { title: string; + icon?: RemixiconComponentType; badge?: React.ReactNode; subtitle?: string; children?: React.ReactNode; @@ -7,6 +10,7 @@ export interface PageHeaderProps { export const PageHeader = ({ title, + icon: Icon, badge, subtitle, children, @@ -15,6 +19,7 @@ export const PageHeader = ({
+ {Icon && }

{title}

{badge}
diff --git a/src/routes/_authenticated/admin/forms/$formId.tsx b/src/routes/_authenticated/admin/forms/$formId.tsx index fbb2296..f56ae5b 100644 --- a/src/routes/_authenticated/admin/forms/$formId.tsx +++ b/src/routes/_authenticated/admin/forms/$formId.tsx @@ -13,9 +13,6 @@ function AdminFormDetailRoute() { const form = useQuery(api.forms.getForm, { formId: formId as Id<"forms">, }); - const formFields = useQuery(api.formFields.getAllFieldsForForm, { - formId: formId as Id<"forms">, - }); const fileUrl = useQuery(api.forms.getFormPDFUrl, { formId: formId as Id<"forms">, }); @@ -30,9 +27,6 @@ function AdminFormDetailRoute() { badge={{form.jurisdiction}} subtitle={form.formCode} /> - {formFields?.map((field) => ( -
{field.label}
- ))} {fileUrl && ( , }); - const addQuestStep = useMutation(api.quests.addQuestStep); + const steps = useQuery(api.questSteps.getStepsForQuest, { + questId: questId as Id<"quests">, + }); + const addQuestStep = useMutation(api.questSteps.create); const [isNewStepFormVisible, setIsNewStepFormVisible] = useState(false); const [title, setTitle] = useState(""); - const [body, setBody] = useState(""); + const [description, setDescription] = useState(""); // TODO: Loading and empty states if (quest === undefined) return; @@ -34,12 +38,16 @@ function AdminQuestDetailRoute() { const clearForm = () => { setTitle(""); - setBody(""); + setDescription(""); }; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - addQuestStep({ questId: questId as Id<"quests">, title, body }); + addQuestStep({ + questId: questId as Id<"quests">, + title, + description, + }); clearForm(); setIsNewStepFormVisible(false); }; @@ -47,24 +55,30 @@ function AdminQuestDetailRoute() { return (
{quest.jurisdiction}} />
- {quest.steps ? ( + {steps ? (
    - {quest.steps.map((step, i) => ( -
  1. - -

    {step.title}

    -
    - - {step.body} - -
    -
    -
  2. - ))} + {steps.map( + (step, i) => + step && ( +
  3. + +

    {step.title}

    + {step.description && ( +
    + + {step.description} + +
    + )} +
    +
  4. + ), + )}
) : ( "No steps" @@ -84,7 +98,11 @@ function AdminQuestDetailRoute() { onChange={setTitle} description="Use sentence case and no punctuation" /> - + +