From 6174fc55683048f2c52af7ecd3411828cd8ae863 Mon Sep 17 00:00:00 2001 From: Eva Decker Date: Thu, 28 Nov 2024 00:28:11 -0500 Subject: [PATCH] feat: Enable quest editing directly from quest (#233) --- .changeset/clean-jars-trade.md | 5 + .github/workflows/ci.yml | 22 ++ biome.json | 2 +- convex/quests.ts | 36 +++ package.json | 5 + pnpm-lock.yaml | 92 +++++++ src/components/common/Dialog/Dialog.tsx | 2 +- src/components/common/Field/Field.tsx | 8 +- src/components/common/Form/Form.tsx | 2 +- src/components/common/Modal/Modal.tsx | 26 +- .../common/NumberField/NumberField.tsx | 2 +- src/components/common/Popover/Popover.tsx | 3 +- .../EditQuestCostsModal.test.tsx | 207 ++++++++++++++ .../EditQuestCostsModal.tsx | 157 +++++++++++ .../quests/EditQuestCostsModal/index.ts | 1 + .../EditQuestTimeRequiredModal.test.tsx | 258 ++++++++++++++++++ .../EditQuestTimeRequiredModal.tsx | 159 +++++++++++ .../EditQuestTimeRequiredModal/index.ts | 1 + .../quests/QuestCosts/QuestCosts.test.tsx | 91 ++++++ .../quests/QuestCosts/QuestCosts.tsx | 46 +++- .../QuestTimeRequired.test.tsx | 93 +++++++ .../QuestTimeRequired/QuestTimeRequired.tsx | 47 +++- src/components/quests/StatGroup/StatGroup.tsx | 4 +- src/components/quests/index.ts | 2 + src/routeTree.gen.ts | 82 ++++-- .../_home/quests.$questId.edit.tsx | 68 +++++ ...$questId.tsx => quests.$questId.index.tsx} | 25 +- .../_authenticated/admin/quests/$questId.tsx | 185 +------------ src/styles/index.css | 6 + vite.config.ts | 32 +-- vitest.config.ts | 39 +++ vitest.setup.ts | 15 + 32 files changed, 1456 insertions(+), 267 deletions(-) create mode 100644 .changeset/clean-jars-trade.md create mode 100644 src/components/quests/EditQuestCostsModal/EditQuestCostsModal.test.tsx create mode 100644 src/components/quests/EditQuestCostsModal/EditQuestCostsModal.tsx create mode 100644 src/components/quests/EditQuestCostsModal/index.ts create mode 100644 src/components/quests/EditQuestTimeRequiredModal/EditQuestTimeRequiredModal.test.tsx create mode 100644 src/components/quests/EditQuestTimeRequiredModal/EditQuestTimeRequiredModal.tsx create mode 100644 src/components/quests/EditQuestTimeRequiredModal/index.ts create mode 100644 src/components/quests/QuestCosts/QuestCosts.test.tsx create mode 100644 src/components/quests/QuestTimeRequired/QuestTimeRequired.test.tsx create mode 100644 src/routes/_authenticated/_home/quests.$questId.edit.tsx rename src/routes/_authenticated/_home/{quests.$questId.tsx => quests.$questId.index.tsx} (82%) create mode 100644 vitest.config.ts create mode 100644 vitest.setup.ts diff --git a/.changeset/clean-jars-trade.md b/.changeset/clean-jars-trade.md new file mode 100644 index 00000000..04887109 --- /dev/null +++ b/.changeset/clean-jars-trade.md @@ -0,0 +1,5 @@ +--- +"namesake": minor +--- + +Allow editing quest costs and time required directly from the quest page diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bdfc7084..6e7d790d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,6 +40,28 @@ jobs: - name: Biome Linter, Formatter, and Import Sorter run: pnpm biome ci + test: + name: Unit Test + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PNPM + uses: pnpm/action-setup@v4 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: 20 + cache: "pnpm" + + - name: Install dependencies + run: pnpm install + + - name: Run tests + run: pnpm test + # Enable this once preview deploys are working and we can run on the preview directly # e2e: # name: End-to-end Test diff --git a/biome.json b/biome.json index 26479117..3cca8b4b 100644 --- a/biome.json +++ b/biome.json @@ -1,7 +1,7 @@ { "$schema": "https://biomejs.dev/schemas/1.8.3/schema.json", "files": { - "ignore": ["convex/_generated", "routeTree.gen.ts", "dist"] + "ignore": ["convex/_generated", "routeTree.gen.ts", "dist", "node_modules"] }, "formatter": { "enabled": true, "indentStyle": "space", "indentWidth": 2 }, "linter": { diff --git a/convex/quests.ts b/convex/quests.ts index 32a403ec..601ebec6 100644 --- a/convex/quests.ts +++ b/convex/quests.ts @@ -98,6 +98,42 @@ export const updateQuest = userMutation({ }, }); +export const updateQuestCosts = userMutation({ + args: { + questId: v.id("quests"), + costs: v.optional( + v.array( + v.object({ + cost: v.number(), + description: v.string(), + }), + ), + ), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.questId, { costs: args.costs }); + }, +}); + +export const updateQuestTimeRequired = userMutation({ + args: { + questId: v.id("quests"), + timeRequired: v.optional( + v.object({ + min: v.number(), + max: v.number(), + unit: timeRequiredUnit, + description: v.optional(v.string()), + }), + ), + }, + handler: async (ctx, args) => { + await ctx.db.patch(args.questId, { + timeRequired: args.timeRequired, + }); + }, +}); + export const deleteQuest = userMutation({ args: { questId: v.id("quests") }, handler: async (ctx, args) => { diff --git a/package.json b/package.json index 385f8c82..a6436634 100644 --- a/package.json +++ b/package.json @@ -79,6 +79,10 @@ "@tailwindcss/typography": "^0.5.15", "@tanstack/router-devtools": "^1.82.8", "@tanstack/router-plugin": "^1.81.9", + "@testing-library/dom": "^10.4.0", + "@testing-library/jest-dom": "^6.6.3", + "@testing-library/react": "^16.0.1", + "@testing-library/user-event": "^14.5.2", "@types/node": "^22.9.3", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", @@ -100,6 +104,7 @@ "tailwindcss-react-aria-components": "^1.2.0", "typescript": "^5.7.2", "vite": "^5.4.11", + "vite-tsconfig-paths": "^5.1.3", "vitest": "^2.1.5" }, "packageManager": "pnpm@9.11.0" diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ddb12df5..8c35ca09 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -151,6 +151,18 @@ importers: '@tanstack/router-plugin': specifier: ^1.81.9 version: 1.81.9(vite@5.4.11(@types/node@22.9.3)) + '@testing-library/dom': + specifier: ^10.4.0 + version: 10.4.0 + '@testing-library/jest-dom': + specifier: ^6.6.3 + version: 6.6.3 + '@testing-library/react': + specifier: ^16.0.1 + version: 16.0.1(@testing-library/dom@10.4.0)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@testing-library/user-event': + specifier: ^14.5.2 + version: 14.5.2(@testing-library/dom@10.4.0) '@types/node': specifier: ^22.9.3 version: 22.9.3 @@ -214,6 +226,9 @@ importers: vite: specifier: ^5.4.11 version: 5.4.11(@types/node@22.9.3) + vite-tsconfig-paths: + specifier: ^5.1.3 + version: 5.1.3(typescript@5.7.2)(vite@5.4.11(@types/node@22.9.3)) vitest: specifier: ^2.1.5 version: 2.1.5(@edge-runtime/vm@4.0.4)(@types/node@22.9.3)(@vitest/ui@2.1.5)(jsdom@25.0.0(canvas@2.11.2)) @@ -2444,6 +2459,25 @@ packages: resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==} engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + '@testing-library/jest-dom@6.6.3': + resolution: {integrity: sha512-IteBhl4XqYNkM54f4ejhLRJiZNqcSCoXUOG2CPK7qbD322KjQozM4kHQOfkG2oln9b9HTYqs+Sae8vBATubxxA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + + '@testing-library/react@16.0.1': + resolution: {integrity: sha512-dSmwJVtJXmku+iocRhWOUFbrERC76TX2Mnf0ATODz8brzAZrMBbzLwQixlBSanZxR6LddK3eiwpSFZgDET1URg==} + engines: {node: '>=18'} + peerDependencies: + '@testing-library/dom': ^10.0.0 + '@types/react': ^18.0.0 + '@types/react-dom': ^18.0.0 + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@testing-library/user-event@14.5.2': resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==} engines: {node: '>=12', npm: '>=6'} @@ -3682,6 +3716,9 @@ packages: resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} engines: {node: '>=10'} + globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + goober@2.1.16: resolution: {integrity: sha512-erjk19y1U33+XAMe1VTvIONHYoSqE4iS7BYUZfHaqeohLmnC0FdxEh7rQU+6MZ4OajItzjZFSRtVANrQwNq6/g==} peerDependencies: @@ -5746,6 +5783,16 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tsconfck@3.1.4: + resolution: {integrity: sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + tsconfig-paths@4.2.0: resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} engines: {node: '>=6'} @@ -5854,6 +5901,14 @@ packages: engines: {node: ^18.0.0 || >=20.0.0} hasBin: true + vite-tsconfig-paths@5.1.3: + resolution: {integrity: sha512-0bz+PDlLpGfP2CigeSKL9NFTF1KtXkeHGZSSaGQSuPZH77GhoiQaA8IjYgOaynSuwlDTolSUEU0ErVvju3NURg==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + vite@5.4.11: resolution: {integrity: sha512-c7jFQRklXua0mTzneGW9QVyxFjUgwcihC4bXEtujIo2ouWCe1Ajt/amn2PCxYnhYfd5k09JX3SB7OYWFKYqj8Q==} engines: {node: ^18.0.0 || >=20.0.0} @@ -8583,6 +8638,26 @@ snapshots: lodash: 4.17.21 redent: 3.0.0 + '@testing-library/jest-dom@6.6.3': + dependencies: + '@adobe/css-tools': 4.4.1 + aria-query: 5.3.2 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + lodash: 4.17.21 + redent: 3.0.0 + + '@testing-library/react@16.0.1(@testing-library/dom@10.4.0)(@types/react-dom@18.3.1)(@types/react@18.3.12)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@babel/runtime': 7.26.0 + '@testing-library/dom': 10.4.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.12 + '@types/react-dom': 18.3.1 + '@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0)': dependencies: '@testing-library/dom': 10.4.0 @@ -10166,6 +10241,8 @@ snapshots: merge2: 1.4.1 slash: 3.0.0 + globrex@0.1.2: {} + goober@2.1.16(csstype@3.1.3): dependencies: csstype: 3.1.3 @@ -12598,6 +12675,10 @@ snapshots: ts-interface-checker@0.1.13: {} + tsconfck@3.1.4(typescript@5.7.2): + optionalDependencies: + typescript: 5.7.2 + tsconfig-paths@4.2.0: dependencies: json5: 2.2.3 @@ -12754,6 +12835,17 @@ snapshots: - supports-color - terser + vite-tsconfig-paths@5.1.3(typescript@5.7.2)(vite@5.4.11(@types/node@22.9.3)): + dependencies: + debug: 4.3.7 + globrex: 0.1.2 + tsconfck: 3.1.4(typescript@5.7.2) + optionalDependencies: + vite: 5.4.11(@types/node@22.9.3) + transitivePeerDependencies: + - supports-color + - typescript + vite@5.4.11(@types/node@22.9.3): dependencies: esbuild: 0.21.5 diff --git a/src/components/common/Dialog/Dialog.tsx b/src/components/common/Dialog/Dialog.tsx index 528b177d..6a7d4944 100644 --- a/src/components/common/Dialog/Dialog.tsx +++ b/src/components/common/Dialog/Dialog.tsx @@ -13,7 +13,7 @@ export function Dialog(props: DialogProps) { &]:p-4 max-h-[inherit] overflow-auto relative", + "outline outline-0 p-0 max-h-[inherit] relative", props.className, )} /> diff --git a/src/components/common/Field/Field.tsx b/src/components/common/Field/Field.tsx index e7a10751..f936babf 100644 --- a/src/components/common/Field/Field.tsx +++ b/src/components/common/Field/Field.tsx @@ -54,8 +54,8 @@ export function FieldError(props: FieldErrorProps) { export const fieldBorderStyles = tv({ variants: { isFocusWithin: { - false: "border-gray-dim forced-colors:border-[ButtonBorder]", - true: "border-gray-normal forced-colors:border-[Highlight]", + false: "border-black/10 dark:border-white/10", + true: "border-black/15 dark:border-white/15", }, isInvalid: { true: "border-red-normal bg-red-subtle forced-colors:border-[Mark]", @@ -68,7 +68,7 @@ export const fieldBorderStyles = tv({ export const fieldGroupStyles = tv({ extend: focusRing, - base: "group flex items-center bg-gray-subtle forced-colors:bg-[Field] border rounded-lg overflow-hidden", + base: "group h-10 flex items-center bg-gray-subtle forced-colors:bg-[Field] border rounded-lg overflow-hidden", variants: fieldBorderStyles.variants, }); @@ -84,7 +84,7 @@ export function FieldGroup(props: GroupProps) { } export const inputStyles = - "px-3 py-2 flex-1 min-w-0 outline outline-0 bg-transparent text-gray-normal disabled:text-gray-dim"; + "px-3 h-10 flex-1 min-w-0 outline outline-0 bg-transparent text-gray-normal disabled:text-gray-dim"; export function Input(props: InputProps) { return ( diff --git a/src/components/common/Form/Form.tsx b/src/components/common/Form/Form.tsx index 1a9bbb7a..a045bd5c 100644 --- a/src/components/common/Form/Form.tsx +++ b/src/components/common/Form/Form.tsx @@ -5,7 +5,7 @@ export function Form(props: FormProps) { return ( ); } diff --git a/src/components/common/Modal/Modal.tsx b/src/components/common/Modal/Modal.tsx index 5ace1763..0cac8cf4 100644 --- a/src/components/common/Modal/Modal.tsx +++ b/src/components/common/Modal/Modal.tsx @@ -1,9 +1,11 @@ import { Modal as AriaModal, + Heading, ModalOverlay, type ModalOverlayProps, } from "react-aria-components"; import { tv } from "tailwind-variants"; +import { Dialog } from "../Dialog"; const overlayStyles = tv({ base: "fixed top-0 left-0 w-full h-[--visual-viewport-height] isolate z-20 bg-black/[15%] flex items-center justify-center p-4 backdrop-blur-lg", @@ -18,7 +20,7 @@ const overlayStyles = tv({ }); const modalStyles = tv({ - base: "p-4 w-full max-w-md max-h-full rounded-2xl bg-gray-subtle forced-colors:bg-[Canvas] flex flex-col items-start gap-4 shadow-2xl bg-clip-padding border border-gray-dim", + base: "p-5 w-full max-w-md max-h-full rounded-2xl bg-gray-subtle forced-colors:bg-[Canvas] flex flex-col items-start gap-4 shadow-2xl bg-clip-padding border border-gray-dim", variants: { isEntering: { true: "animate-in zoom-in-105 ease-out duration-2", @@ -32,7 +34,27 @@ const modalStyles = tv({ export function Modal(props: ModalOverlayProps) { return ( - + + + {props.children} + + ); } + +type ModalHeaderProps = { + title: string; + children?: React.ReactNode; +}; + +export function ModalHeader({ title, children }: ModalHeaderProps) { + return ( +
+ + {title} + + {children} +
+ ); +} diff --git a/src/components/common/NumberField/NumberField.tsx b/src/components/common/NumberField/NumberField.tsx index eb27cea4..f5445c4a 100644 --- a/src/components/common/NumberField/NumberField.tsx +++ b/src/components/common/NumberField/NumberField.tsx @@ -38,7 +38,7 @@ export function NumberField({ "group flex flex-col gap-1.5", )} > - + {label && } {(renderProps) => ( <> diff --git a/src/components/common/Popover/Popover.tsx b/src/components/common/Popover/Popover.tsx index 2d032bfe..190cbad0 100644 --- a/src/components/common/Popover/Popover.tsx +++ b/src/components/common/Popover/Popover.tsx @@ -7,6 +7,7 @@ import { useSlottedContext, } from "react-aria-components"; import { tv } from "tailwind-variants"; +import { Dialog } from "../Dialog"; export interface PopoverProps extends Omit { children: React.ReactNode; @@ -37,7 +38,7 @@ export function Popover({ children, className, ...props }: PopoverProps) { styles({ ...renderProps, className }), )} > - {children} + {children} ); } diff --git a/src/components/quests/EditQuestCostsModal/EditQuestCostsModal.test.tsx b/src/components/quests/EditQuestCostsModal/EditQuestCostsModal.test.tsx new file mode 100644 index 00000000..f5b35a8c --- /dev/null +++ b/src/components/quests/EditQuestCostsModal/EditQuestCostsModal.test.tsx @@ -0,0 +1,207 @@ +import type { Doc, Id } from "@convex/_generated/dataModel"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useMutation } from "convex/react"; +import { toast } from "sonner"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EditQuestCostsModal } from "./EditQuestCostsModal"; + +describe("EditQuestCostsModal", () => { + const mockQuest = { + _id: "quest123" as Id<"quests">, + costs: [ + { cost: 100, description: "Application fee" }, + { cost: 50, description: "Certified copies" }, + ], + } as Doc<"quests">; + + const mockUpdateCosts = vi.fn(); + const mockOnOpenChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (useMutation as unknown as ReturnType).mockReturnValue( + mockUpdateCosts, + ); + }); + + it("renders with initial costs", () => { + render( + , + ); + + // Check if modal title is rendered + expect(screen.getByText("Edit costs")).toBeInTheDocument(); + + // Check if cost inputs are rendered with correct values + const costInputs = screen.getAllByLabelText("Cost") as HTMLInputElement[]; + expect(costInputs).toHaveLength(2); + expect(costInputs[0].value).toBe("100"); + expect(costInputs[1].value).toBe("50"); + + // Check if description inputs are rendered with correct values + const descriptionInputs = screen.getAllByLabelText( + "For", + ) as HTMLInputElement[]; + expect(descriptionInputs).toHaveLength(2); + expect(descriptionInputs[0].value).toBe("Application fee"); + expect(descriptionInputs[1].value).toBe("Certified copies"); + }); + + it("toggles between free and paid costs", async () => { + const user = userEvent.setup(); + render( + , + ); + + // Toggle to free + const freeSwitch = screen.getByRole("switch", { name: "Free" }); + await user.click(freeSwitch); + + // Cost inputs should be removed + expect(screen.queryByLabelText("Cost")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("For")).not.toBeInTheDocument(); + + // Toggle back to paid + await user.click(freeSwitch); + expect(screen.getByLabelText("Cost")).toBeInTheDocument(); + expect(screen.getByLabelText("For")).toBeInTheDocument(); + }); + + it("enforces maxLength and maxValue constraints on inputs", async () => { + const user = userEvent.setup(); + render( + , + ); + + // Test cost field max value (2000) + const costInputs = screen.getAllByLabelText("Cost") as HTMLInputElement[]; + await user.clear(costInputs[0]); + await user.type(costInputs[0], "3000"); + await user.click(document.body); + expect(costInputs[0].value).toBe("2,000"); + + // Test description field max length (32 characters) + const descriptionInputs = screen.getAllByLabelText( + "For", + ) as HTMLInputElement[]; + const longDescription = + "This is a very long description that exceeds the maximum length"; + await user.clear(descriptionInputs[0]); + await user.type(descriptionInputs[0], longDescription); + expect(descriptionInputs[0].value.length).toBeLessThanOrEqual(32); + expect(descriptionInputs[0].maxLength).toBe(32); + }); + + it("adds and removes cost items", async () => { + const user = userEvent.setup(); + render( + , + ); + + // Add new cost + await user.click(screen.getByRole("button", { name: "Add cost" })); + expect(screen.getAllByLabelText("Cost")).toHaveLength(3); + + // Remove a cost + const removeButtons = screen.getAllByRole("button", { name: "Remove" }); + await user.click(removeButtons[0]); + expect(screen.getAllByLabelText("Cost")).toHaveLength(2); + }); + + it("saves changes successfully", async () => { + const user = userEvent.setup(); + mockUpdateCosts.mockResolvedValueOnce(undefined); + + render( + , + ); + + // Modify a cost + const costInputs = screen.getAllByLabelText("Cost"); + await user.clear(costInputs[0] as HTMLInputElement); + await user.type(costInputs[0] as HTMLInputElement, "200"); + + // Save changes + await user.click(screen.getByRole("button", { name: "Save" })); + + // Verify mutation was called + expect(mockUpdateCosts).toHaveBeenCalledWith({ + costs: [ + { cost: 200, description: "Application fee" }, + { cost: 50, description: "Certified copies" }, + ], + questId: mockQuest._id, + }); + + // Verify success toast and modal close + expect(toast.success).toHaveBeenCalledWith("Updated costs"); + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); + + it("handles save failure", async () => { + const user = userEvent.setup(); + mockUpdateCosts.mockRejectedValueOnce(new Error("Update failed")); + + render( + , + ); + + // Try to save + await user.click(screen.getByRole("button", { name: "Save" })); + + // Verify error toast is shown and modal stays open + expect(toast.error).toHaveBeenCalledWith("Failed to update costs"); + expect(mockOnOpenChange).not.toHaveBeenCalled(); + + // Success toast should not be shown + expect(toast.success).not.toHaveBeenCalled(); + }); + + it("cancels editing without saving", async () => { + const user = userEvent.setup(); + render( + , + ); + + // Modify a cost + const costInputs = screen.getAllByLabelText("Cost"); + await user.clear(costInputs[0] as HTMLInputElement); + await user.type(costInputs[0] as HTMLInputElement, "200"); + + // Cancel changes + await user.click(screen.getByRole("button", { name: "Cancel" })); + + // Verify mutation was not called and modal was closed + expect(mockUpdateCosts).not.toHaveBeenCalled(); + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/components/quests/EditQuestCostsModal/EditQuestCostsModal.tsx b/src/components/quests/EditQuestCostsModal/EditQuestCostsModal.tsx new file mode 100644 index 00000000..2683178c --- /dev/null +++ b/src/components/quests/EditQuestCostsModal/EditQuestCostsModal.tsx @@ -0,0 +1,157 @@ +import { + Button, + Form, + Modal, + ModalHeader, + NumberField, + Switch, + TextField, +} from "@/components/common"; +import { api } from "@convex/_generated/api"; +import type { Doc } from "@convex/_generated/dataModel"; +import type { Cost } from "@convex/constants"; +import { useMutation } from "convex/react"; +import { Plus, Trash } from "lucide-react"; +import { memo, useState } from "react"; +import { toast } from "sonner"; + +type CostInputProps = { + cost: Cost; + onChange: (cost: Cost) => void; + onRemove: (cost: Cost) => void; +}; + +const CostInput = memo(function CostInput({ + cost, + onChange, + onRemove, +}: CostInputProps) { + return ( +
+ + onChange({ cost: value, description: cost.description }) + } + maxValue={2000} + isRequired + /> + onChange({ cost: cost.cost, description: value })} + isRequired + maxLength={32} + /> +
+ ); +}); + +type EditCostsModalProps = { + quest: Doc<"quests">; + open: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +export const EditQuestCostsModal = ({ + quest, + open, + onOpenChange, +}: EditCostsModalProps) => { + const [costsInput, setCostsInput] = useState(quest.costs ?? null); + const [isSubmitting, setIsSubmitting] = useState(false); + + const updateCosts = useMutation(api.quests.updateQuestCosts); + + const handleCancel = () => { + setCostsInput(quest.costs ?? null); + onOpenChange(false); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (isSubmitting) return; + + setIsSubmitting(true); + updateCosts({ costs: costsInput ?? undefined, questId: quest._id }) + .then(() => { + toast.success("Updated costs"); + onOpenChange(false); + }) + .catch(() => { + toast.error("Failed to update costs"); + }) + .finally(() => { + setIsSubmitting(false); + }); + }; + + return ( + +
+ + + setCostsInput(isSelected ? null : [{ cost: 0, description: "" }]) + } + className="justify-self-start" + > + Free + + + + {costsInput && ( +
+ {costsInput.map((cost, index) => ( + { + const newCosts = [...costsInput]; + newCosts[index] = value; + setCostsInput(newCosts); + }} + onRemove={() => { + setCostsInput(costsInput.filter((_, i) => i !== index)); + }} + /> + ))} + +
+ )} +
+ + +
+
+
+ ); +}; diff --git a/src/components/quests/EditQuestCostsModal/index.ts b/src/components/quests/EditQuestCostsModal/index.ts new file mode 100644 index 00000000..dc2ea269 --- /dev/null +++ b/src/components/quests/EditQuestCostsModal/index.ts @@ -0,0 +1 @@ +export * from "./EditQuestCostsModal"; diff --git a/src/components/quests/EditQuestTimeRequiredModal/EditQuestTimeRequiredModal.test.tsx b/src/components/quests/EditQuestTimeRequiredModal/EditQuestTimeRequiredModal.test.tsx new file mode 100644 index 00000000..af3808f7 --- /dev/null +++ b/src/components/quests/EditQuestTimeRequiredModal/EditQuestTimeRequiredModal.test.tsx @@ -0,0 +1,258 @@ +import type { Doc, Id } from "@convex/_generated/dataModel"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { useMutation } from "convex/react"; +import { toast } from "sonner"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { EditQuestTimeRequiredModal } from "./EditQuestTimeRequiredModal"; + +describe("EditQuestTimeRequiredModal", () => { + const mockQuest = { + _id: "quest123" as Id<"quests">, + timeRequired: { + min: 2, + max: 4, + unit: "weeks", + description: "Depends on court processing time", + }, + } as Doc<"quests">; + + const mockUpdateTimeRequired = vi.fn(); + const mockOnOpenChange = vi.fn(); + + beforeEach(() => { + vi.clearAllMocks(); + (useMutation as unknown as ReturnType).mockReturnValue( + mockUpdateTimeRequired, + ); + }); + + it("renders with initial time required values", () => { + render( + , + ); + + // Check if modal title is rendered + expect(screen.getByText("Edit time required")).toBeInTheDocument(); + + // Check if time inputs are rendered with correct values + const minInput = screen + .getAllByLabelText("Est. min time")[0] + .closest("input") as HTMLInputElement; + const maxInput = screen + .getAllByLabelText("Est. max time")[0] + .closest("input") as HTMLInputElement; + expect(minInput.value).toBe("2"); + expect(maxInput.value).toBe("4"); + + // Check if unit select is rendered with correct value + expect(screen.getByLabelText("Unit")).toHaveTextContent("Weeks"); + + // Check if description input is rendered with correct value + const descriptionInput = screen.getByLabelText( + "Description", + ) as HTMLInputElement; + expect(descriptionInput.value).toBe("Depends on court processing time"); + }); + + it("updates max time value", async () => { + const user = userEvent.setup(); + mockUpdateTimeRequired.mockResolvedValueOnce(undefined); + + render( + , + ); + + const maxInput = screen + .getAllByLabelText("Est. max time")[0] + .closest("input") as HTMLInputElement; + + await user.clear(maxInput); + await user.type(maxInput, "6"); + + await user.click(screen.getByText("Save")); + + expect(mockUpdateTimeRequired).toHaveBeenCalledWith({ + timeRequired: { + min: 2, + max: 6, + unit: "weeks", + description: "Depends on court processing time", + }, + questId: "quest123", + }); + }); + + it("updates time unit", async () => { + const user = userEvent.setup(); + mockUpdateTimeRequired.mockResolvedValueOnce(undefined); + + render( + , + ); + + const unitSelect = screen.getByLabelText("Unit"); + await user.click(unitSelect); + await user.click(screen.getByRole("option", { name: "Days" })); + + await user.click(screen.getByText("Save")); + + expect(mockUpdateTimeRequired).toHaveBeenCalledWith({ + timeRequired: { + min: 2, + max: 4, + unit: "days", + description: "Depends on court processing time", + }, + questId: "quest123", + }); + }); + + it("updates description", async () => { + const user = userEvent.setup(); + mockUpdateTimeRequired.mockResolvedValueOnce(undefined); + + render( + , + ); + + const descriptionInput = screen.getByLabelText("Description"); + await user.clear(descriptionInput); + await user.type(descriptionInput, "New description"); + + await user.click(screen.getByText("Save")); + + expect(mockUpdateTimeRequired).toHaveBeenCalledWith({ + timeRequired: { + min: 2, + max: 4, + unit: "weeks", + description: "New description", + }, + questId: "quest123", + }); + }); + + it("sets description to undefined when empty", async () => { + const user = userEvent.setup(); + mockUpdateTimeRequired.mockResolvedValueOnce(undefined); + + render( + , + ); + + const descriptionInput = screen.getByLabelText("Description"); + await user.clear(descriptionInput); + + await user.click(screen.getByText("Save")); + + expect(mockUpdateTimeRequired).toHaveBeenCalledWith({ + timeRequired: { + min: 2, + max: 4, + unit: "weeks", + description: undefined, + }, + questId: "quest123", + }); + }); + + it("handles successful save", async () => { + const user = userEvent.setup(); + mockUpdateTimeRequired.mockResolvedValueOnce(undefined); + + render( + , + ); + + // Modify values + const minInput = screen.getAllByLabelText("Est. min time")[0]; + await user.clear(minInput); + await user.type(minInput, "3"); + + // Submit form + await user.click(screen.getByText("Save")); + + // Check if mutation was called with correct args + expect(mockUpdateTimeRequired).toHaveBeenCalledWith({ + timeRequired: { + min: 3, + max: 4, + unit: "weeks", + description: "Depends on court processing time", + }, + questId: "quest123", + }); + + // Check if success toast was shown + expect(toast.success).toHaveBeenCalledWith("Updated time required"); + + // Check if modal was closed + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); + + it("handles save failure", async () => { + const user = userEvent.setup(); + mockUpdateTimeRequired.mockRejectedValueOnce(new Error("Update failed")); + + render( + , + ); + + await user.click(screen.getByText("Save")); + + expect(toast.error).toHaveBeenCalledWith("Failed to update time required"); + expect(mockOnOpenChange).not.toHaveBeenCalled(); + }); + + it("handles cancel", async () => { + const user = userEvent.setup(); + + render( + , + ); + + // Modify a value then cancel + const minInput = screen.getAllByLabelText("Est. min time")[0]; + await user.clear(minInput); + await user.type(minInput, "3"); + + await user.click(screen.getByText("Cancel")); + + // Check if modal was closed without saving + expect(mockUpdateTimeRequired).not.toHaveBeenCalled(); + expect(mockOnOpenChange).toHaveBeenCalledWith(false); + }); +}); diff --git a/src/components/quests/EditQuestTimeRequiredModal/EditQuestTimeRequiredModal.tsx b/src/components/quests/EditQuestTimeRequiredModal/EditQuestTimeRequiredModal.tsx new file mode 100644 index 00000000..2d1515d7 --- /dev/null +++ b/src/components/quests/EditQuestTimeRequiredModal/EditQuestTimeRequiredModal.tsx @@ -0,0 +1,159 @@ +import { + Button, + Form, + Modal, + ModalHeader, + NumberField, + Select, + SelectItem, + TextField, +} from "@/components/common"; +import { api } from "@convex/_generated/api"; +import type { Doc } from "@convex/_generated/dataModel"; +import { + TIME_UNITS, + type TimeRequired, + type TimeUnit, +} from "@convex/constants"; +import { useMutation } from "convex/react"; +import { memo, useState } from "react"; +import { toast } from "sonner"; + +type EditTimeRequiredModalProps = { + quest: Doc<"quests">; + open: boolean; + onOpenChange: (isOpen: boolean) => void; +}; + +const TimeRequiredInput = memo(function TimeRequiredInput({ + timeRequired, + onChange, +}: { + timeRequired: TimeRequired; + onChange: (timeRequired: TimeRequired) => void; +}) { + if (!timeRequired) return null; + + return ( +
+
+ + onChange({ + ...timeRequired, + min: value, + }) + } + /> + + onChange({ + ...timeRequired, + max: value, + }) + } + /> + +
+ + onChange({ + ...timeRequired, + description: value || undefined, + }) + } + /> +
+ ); +}); + +export const EditQuestTimeRequiredModal = ({ + quest, + open, + onOpenChange, +}: EditTimeRequiredModalProps) => { + const [timeInput, setTimeInput] = useState( + (quest.timeRequired as TimeRequired) ?? null, + ); + const [isSubmitting, setIsSubmitting] = useState(false); + + const updateTimeRequired = useMutation(api.quests.updateQuestTimeRequired); + + const handleCancel = () => { + setTimeInput((quest.timeRequired as TimeRequired) ?? null); + onOpenChange(false); + }; + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (isSubmitting) return; + + setIsSubmitting(true); + updateTimeRequired({ + timeRequired: timeInput ?? undefined, + questId: quest._id, + }) + .then(() => { + toast.success("Updated time required"); + onOpenChange(false); + }) + .catch(() => { + toast.error("Failed to update time required"); + }) + .finally(() => { + setIsSubmitting(false); + }); + }; + + return ( + +
+ + {timeInput && ( + + )} +
+ + +
+ +
+ ); +}; diff --git a/src/components/quests/EditQuestTimeRequiredModal/index.ts b/src/components/quests/EditQuestTimeRequiredModal/index.ts new file mode 100644 index 00000000..3b897e35 --- /dev/null +++ b/src/components/quests/EditQuestTimeRequiredModal/index.ts @@ -0,0 +1 @@ +export * from "./EditQuestTimeRequiredModal"; diff --git a/src/components/quests/QuestCosts/QuestCosts.test.tsx b/src/components/quests/QuestCosts/QuestCosts.test.tsx new file mode 100644 index 00000000..9d98f8d0 --- /dev/null +++ b/src/components/quests/QuestCosts/QuestCosts.test.tsx @@ -0,0 +1,91 @@ +import type { Doc, Id } from "@convex/_generated/dataModel"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { describe, expect, it } from "vitest"; +import { QuestCosts } from "./QuestCosts"; + +describe("QuestCosts", () => { + const mockQuest = { + _id: "quest123" as Id<"quests">, + costs: [ + { cost: 100, description: "Application fee" }, + { cost: 50, description: "Certified copies" }, + ], + } as Doc<"quests">; + + const mockQuestFree = { + _id: "quest456" as Id<"quests">, + costs: [], + } as Partial>; + + const mockQuestZeroCost = { + _id: "quest789" as Id<"quests">, + costs: [ + { cost: 0, description: "Free application" }, + { cost: 0, description: "No filing fee" }, + ], + } as Doc<"quests">; + + it("displays costs in a description list with total", async () => { + render(); + + // Check if total cost is displayed + expect(screen.getByText("$150")).toBeInTheDocument(); + + // Open the cost breakdown popover + const popoverTrigger = screen.getByRole("button", { + name: "See cost breakdown", + }); + userEvent.click(popoverTrigger); + + // Check if popover is open + const popover = await screen.findByRole("dialog"); + expect(popover).toBeInTheDocument(); + + // Check if costs are displayed in description list + const descriptions = screen.getAllByRole("term"); + const values = screen.getAllByRole("definition"); + + expect(descriptions).toHaveLength(3); + expect(values).toHaveLength(3); + + // Check individual costs + expect(descriptions[0]).toHaveTextContent("Application fee"); + expect(values[0]).toHaveTextContent("$100"); + expect(descriptions[1]).toHaveTextContent("Certified copies"); + expect(values[1]).toHaveTextContent("$50"); + expect(descriptions[2]).toHaveTextContent("Total"); + expect(values[2]).toHaveTextContent("$150"); + }); + + it("displays 'Free' when there are no costs", () => { + render(} />); + expect(screen.getByText("Free")).toBeInTheDocument(); + }); + + it("displays 'Free' when total cost is 0 but costs array is not empty", () => { + render(); + expect(screen.getByText("Free")).toBeInTheDocument(); + }); + + it("shows edit button when editable prop is true", async () => { + const user = userEvent.setup(); + render(); + + // Check if edit button is present + const editButton = screen.getByRole("button", { name: "Edit costs" }); + expect(editButton).toBeInTheDocument(); + + // Click edit button and check if modal opens + user.click(editButton); + const modal = await screen.findByRole("dialog"); + expect(modal).toBeInTheDocument(); + }); + + it("hides edit button when editable prop is false", () => { + render(); + expect( + screen.queryByRole("button", { name: "Edit costs" }), + ).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/quests/QuestCosts/QuestCosts.tsx b/src/components/quests/QuestCosts/QuestCosts.tsx index ea289602..f930f48c 100644 --- a/src/components/quests/QuestCosts/QuestCosts.tsx +++ b/src/components/quests/QuestCosts/QuestCosts.tsx @@ -1,14 +1,29 @@ -import { StatGroup, StatPopover } from "@/components/quests"; +import { Button, Tooltip, TooltipTrigger } from "@/components/common"; +import { + EditQuestCostsModal, + StatGroup, + StatPopover, +} from "@/components/quests"; +import type { Doc } from "@convex/_generated/dataModel"; import type { Cost } from "@convex/constants"; +import { Pencil } from "lucide-react"; +import { useState } from "react"; import { Fragment } from "react/jsx-runtime"; type QuestCostsProps = { - costs?: Cost[]; + quest: Doc<"quests">; + editable?: boolean; }; -export const QuestCosts = ({ costs }: QuestCostsProps) => { +export const QuestCosts = ({ quest, editable = false }: QuestCostsProps) => { + const [isEditing, setIsEditing] = useState(false); + + if (quest === undefined) return null; + + const { costs } = quest; + const getTotalCosts = (costs?: Cost[]) => { - if (!costs) return "Free"; + if (!costs || costs.length === 0) return "Free"; const total = costs.reduce((acc, cost) => acc + cost.cost, 0); return total > 0 @@ -22,13 +37,13 @@ export const QuestCosts = ({ costs }: QuestCostsProps) => { return ( - {costs?.length && ( + {costs && costs.length > 0 && (
{costs.map(({ cost, description }) => (
{description}
-
+
{cost.toLocaleString("en-US", { style: "currency", currency: "USD", @@ -46,6 +61,25 @@ export const QuestCosts = ({ costs }: QuestCostsProps) => {
)} + {editable && ( + <> + + {tooltip} @@ -33,7 +33,7 @@ export type StatGroupProps = { export const StatGroup = ({ label, value, children }: StatGroupProps) => (
{label}
-
+
{value} {children}
diff --git a/src/components/quests/index.ts b/src/components/quests/index.ts index 8de26895..1ef61141 100644 --- a/src/components/quests/index.ts +++ b/src/components/quests/index.ts @@ -1,4 +1,6 @@ export * from "./DocumentCard"; +export * from "./EditQuestCostsModal"; +export * from "./EditQuestTimeRequiredModal"; export * from "./QuestCosts"; export * from "./QuestForms"; export * from "./QuestTimeRequired"; diff --git a/src/routeTree.gen.ts b/src/routeTree.gen.ts index f17e9f09..f2316783 100644 --- a/src/routeTree.gen.ts +++ b/src/routeTree.gen.ts @@ -29,7 +29,8 @@ import { Route as AuthenticatedAdminFieldsIndexImport } from './routes/_authenti import { Route as AuthenticatedHomeQuestsIndexImport } from './routes/_authenticated/_home/quests.index' import { Route as AuthenticatedAdminQuestsQuestIdImport } from './routes/_authenticated/admin/quests/$questId' import { Route as AuthenticatedAdminFormsFormIdImport } from './routes/_authenticated/admin/forms/$formId' -import { Route as AuthenticatedHomeQuestsQuestIdImport } from './routes/_authenticated/_home/quests.$questId' +import { Route as AuthenticatedHomeQuestsQuestIdIndexImport } from './routes/_authenticated/_home/quests.$questId.index' +import { Route as AuthenticatedHomeQuestsQuestIdEditImport } from './routes/_authenticated/_home/quests.$questId.edit' // Create/Update Routes @@ -149,10 +150,17 @@ const AuthenticatedAdminFormsFormIdRoute = getParentRoute: () => AuthenticatedAdminRouteRoute, } as any) -const AuthenticatedHomeQuestsQuestIdRoute = - AuthenticatedHomeQuestsQuestIdImport.update({ - id: '/quests/$questId', - path: '/quests/$questId', +const AuthenticatedHomeQuestsQuestIdIndexRoute = + AuthenticatedHomeQuestsQuestIdIndexImport.update({ + id: '/quests/$questId/', + path: '/quests/$questId/', + getParentRoute: () => AuthenticatedHomeRoute, + } as any) + +const AuthenticatedHomeQuestsQuestIdEditRoute = + AuthenticatedHomeQuestsQuestIdEditImport.update({ + id: '/quests/$questId/edit', + path: '/quests/$questId/edit', getParentRoute: () => AuthenticatedHomeRoute, } as any) @@ -244,13 +252,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedSettingsIndexImport parentRoute: typeof AuthenticatedSettingsRouteImport } - '/_authenticated/_home/quests/$questId': { - id: '/_authenticated/_home/quests/$questId' - path: '/quests/$questId' - fullPath: '/quests/$questId' - preLoaderRoute: typeof AuthenticatedHomeQuestsQuestIdImport - parentRoute: typeof AuthenticatedHomeImport - } '/_authenticated/admin/forms/$formId': { id: '/_authenticated/admin/forms/$formId' path: '/forms/$formId' @@ -293,6 +294,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthenticatedAdminQuestsIndexImport parentRoute: typeof AuthenticatedAdminRouteImport } + '/_authenticated/_home/quests/$questId/edit': { + id: '/_authenticated/_home/quests/$questId/edit' + path: '/quests/$questId/edit' + fullPath: '/quests/$questId/edit' + preLoaderRoute: typeof AuthenticatedHomeQuestsQuestIdEditImport + parentRoute: typeof AuthenticatedHomeImport + } + '/_authenticated/_home/quests/$questId/': { + id: '/_authenticated/_home/quests/$questId/' + path: '/quests/$questId' + fullPath: '/quests/$questId' + preLoaderRoute: typeof AuthenticatedHomeQuestsQuestIdIndexImport + parentRoute: typeof AuthenticatedHomeImport + } } } @@ -342,14 +357,18 @@ const AuthenticatedSettingsRouteRouteWithChildren = interface AuthenticatedHomeRouteChildren { AuthenticatedHomeIndexRoute: typeof AuthenticatedHomeIndexRoute - AuthenticatedHomeQuestsQuestIdRoute: typeof AuthenticatedHomeQuestsQuestIdRoute AuthenticatedHomeQuestsIndexRoute: typeof AuthenticatedHomeQuestsIndexRoute + AuthenticatedHomeQuestsQuestIdEditRoute: typeof AuthenticatedHomeQuestsQuestIdEditRoute + AuthenticatedHomeQuestsQuestIdIndexRoute: typeof AuthenticatedHomeQuestsQuestIdIndexRoute } const AuthenticatedHomeRouteChildren: AuthenticatedHomeRouteChildren = { AuthenticatedHomeIndexRoute: AuthenticatedHomeIndexRoute, - AuthenticatedHomeQuestsQuestIdRoute: AuthenticatedHomeQuestsQuestIdRoute, AuthenticatedHomeQuestsIndexRoute: AuthenticatedHomeQuestsIndexRoute, + AuthenticatedHomeQuestsQuestIdEditRoute: + AuthenticatedHomeQuestsQuestIdEditRoute, + AuthenticatedHomeQuestsQuestIdIndexRoute: + AuthenticatedHomeQuestsQuestIdIndexRoute, } const AuthenticatedHomeRouteWithChildren = @@ -396,13 +415,14 @@ export interface FileRoutesByFullPath { '/admin/': typeof AuthenticatedAdminIndexRoute '/browse': typeof AuthenticatedBrowseIndexRoute '/settings/': typeof AuthenticatedSettingsIndexRoute - '/quests/$questId': typeof AuthenticatedHomeQuestsQuestIdRoute '/admin/forms/$formId': typeof AuthenticatedAdminFormsFormIdRoute '/admin/quests/$questId': typeof AuthenticatedAdminQuestsQuestIdRoute '/quests': typeof AuthenticatedHomeQuestsIndexRoute '/admin/fields': typeof AuthenticatedAdminFieldsIndexRoute '/admin/forms': typeof AuthenticatedAdminFormsIndexRoute '/admin/quests': typeof AuthenticatedAdminQuestsIndexRoute + '/quests/$questId/edit': typeof AuthenticatedHomeQuestsQuestIdEditRoute + '/quests/$questId': typeof AuthenticatedHomeQuestsQuestIdIndexRoute } export interface FileRoutesByTo { @@ -414,13 +434,14 @@ export interface FileRoutesByTo { '/admin': typeof AuthenticatedAdminIndexRoute '/browse': typeof AuthenticatedBrowseIndexRoute '/settings': typeof AuthenticatedSettingsIndexRoute - '/quests/$questId': typeof AuthenticatedHomeQuestsQuestIdRoute '/admin/forms/$formId': typeof AuthenticatedAdminFormsFormIdRoute '/admin/quests/$questId': typeof AuthenticatedAdminQuestsQuestIdRoute '/quests': typeof AuthenticatedHomeQuestsIndexRoute '/admin/fields': typeof AuthenticatedAdminFieldsIndexRoute '/admin/forms': typeof AuthenticatedAdminFormsIndexRoute '/admin/quests': typeof AuthenticatedAdminQuestsIndexRoute + '/quests/$questId/edit': typeof AuthenticatedHomeQuestsQuestIdEditRoute + '/quests/$questId': typeof AuthenticatedHomeQuestsQuestIdIndexRoute } export interface FileRoutesById { @@ -437,13 +458,14 @@ export interface FileRoutesById { '/_authenticated/admin/': typeof AuthenticatedAdminIndexRoute '/_authenticated/browse/': typeof AuthenticatedBrowseIndexRoute '/_authenticated/settings/': typeof AuthenticatedSettingsIndexRoute - '/_authenticated/_home/quests/$questId': typeof AuthenticatedHomeQuestsQuestIdRoute '/_authenticated/admin/forms/$formId': typeof AuthenticatedAdminFormsFormIdRoute '/_authenticated/admin/quests/$questId': typeof AuthenticatedAdminQuestsQuestIdRoute '/_authenticated/_home/quests/': typeof AuthenticatedHomeQuestsIndexRoute '/_authenticated/admin/fields/': typeof AuthenticatedAdminFieldsIndexRoute '/_authenticated/admin/forms/': typeof AuthenticatedAdminFormsIndexRoute '/_authenticated/admin/quests/': typeof AuthenticatedAdminQuestsIndexRoute + '/_authenticated/_home/quests/$questId/edit': typeof AuthenticatedHomeQuestsQuestIdEditRoute + '/_authenticated/_home/quests/$questId/': typeof AuthenticatedHomeQuestsQuestIdIndexRoute } export interface FileRouteTypes { @@ -459,13 +481,14 @@ export interface FileRouteTypes { | '/admin/' | '/browse' | '/settings/' - | '/quests/$questId' | '/admin/forms/$formId' | '/admin/quests/$questId' | '/quests' | '/admin/fields' | '/admin/forms' | '/admin/quests' + | '/quests/$questId/edit' + | '/quests/$questId' fileRoutesByTo: FileRoutesByTo to: | '' @@ -476,13 +499,14 @@ export interface FileRouteTypes { | '/admin' | '/browse' | '/settings' - | '/quests/$questId' | '/admin/forms/$formId' | '/admin/quests/$questId' | '/quests' | '/admin/fields' | '/admin/forms' | '/admin/quests' + | '/quests/$questId/edit' + | '/quests/$questId' id: | '__root__' | '/_authenticated' @@ -497,13 +521,14 @@ export interface FileRouteTypes { | '/_authenticated/admin/' | '/_authenticated/browse/' | '/_authenticated/settings/' - | '/_authenticated/_home/quests/$questId' | '/_authenticated/admin/forms/$formId' | '/_authenticated/admin/quests/$questId' | '/_authenticated/_home/quests/' | '/_authenticated/admin/fields/' | '/_authenticated/admin/forms/' | '/_authenticated/admin/quests/' + | '/_authenticated/_home/quests/$questId/edit' + | '/_authenticated/_home/quests/$questId/' fileRoutesById: FileRoutesById } @@ -572,8 +597,9 @@ export const routeTree = rootRoute "parent": "/_authenticated", "children": [ "/_authenticated/_home/", - "/_authenticated/_home/quests/$questId", - "/_authenticated/_home/quests/" + "/_authenticated/_home/quests/", + "/_authenticated/_home/quests/$questId/edit", + "/_authenticated/_home/quests/$questId/" ] }, "/_unauthenticated/signin": { @@ -604,10 +630,6 @@ export const routeTree = rootRoute "filePath": "_authenticated/settings/index.tsx", "parent": "/_authenticated/settings" }, - "/_authenticated/_home/quests/$questId": { - "filePath": "_authenticated/_home/quests.$questId.tsx", - "parent": "/_authenticated/_home" - }, "/_authenticated/admin/forms/$formId": { "filePath": "_authenticated/admin/forms/$formId.tsx", "parent": "/_authenticated/admin" @@ -631,6 +653,14 @@ export const routeTree = rootRoute "/_authenticated/admin/quests/": { "filePath": "_authenticated/admin/quests/index.tsx", "parent": "/_authenticated/admin" + }, + "/_authenticated/_home/quests/$questId/edit": { + "filePath": "_authenticated/_home/quests.$questId.edit.tsx", + "parent": "/_authenticated/_home" + }, + "/_authenticated/_home/quests/$questId/": { + "filePath": "_authenticated/_home/quests.$questId.index.tsx", + "parent": "/_authenticated/_home" } } } diff --git a/src/routes/_authenticated/_home/quests.$questId.edit.tsx b/src/routes/_authenticated/_home/quests.$questId.edit.tsx new file mode 100644 index 00000000..7107b7f4 --- /dev/null +++ b/src/routes/_authenticated/_home/quests.$questId.edit.tsx @@ -0,0 +1,68 @@ +import { AppContent, PageHeader } from "@/components/app"; +import { Badge, Empty, Link, RichText } from "@/components/common"; +import { + QuestCosts, + QuestForms, + QuestTimeRequired, + QuestUrls, +} from "@/components/quests"; +import { api } from "@convex/_generated/api"; +import type { Id } from "@convex/_generated/dataModel"; +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { useQuery } from "convex/react"; +import { Check, Milestone } from "lucide-react"; + +export const Route = createFileRoute( + "/_authenticated/_home/quests/$questId/edit", +)({ + beforeLoad: ({ context }) => { + const isAdmin = context.role === "admin"; + + // For now, only admins can edit quests + if (!isAdmin) { + throw redirect({ + to: "/", + statusCode: 401, + replace: true, + }); + } + }, + component: QuestEditRoute, +}); + +function QuestEditRoute() { + const { questId } = Route.useParams(); + + // TODO: Opportunity to combine these queries? + const quest = useQuery(api.quests.getQuest, { + questId: questId as Id<"quests">, + }); + + // TODO: Improve loading state to prevent flash of empty + if (quest === undefined) return; + if (quest === null) return ; + + return ( + + {quest.jurisdiction}} + > + + + Save + + +
+ + +
+ + + +
+ ); +} diff --git a/src/routes/_authenticated/_home/quests.$questId.tsx b/src/routes/_authenticated/_home/quests.$questId.index.tsx similarity index 82% rename from src/routes/_authenticated/_home/quests.$questId.tsx rename to src/routes/_authenticated/_home/quests.$questId.index.tsx index 86c71ff5..daff6a65 100644 --- a/src/routes/_authenticated/_home/quests.$questId.tsx +++ b/src/routes/_authenticated/_home/quests.$questId.index.tsx @@ -3,6 +3,7 @@ import { Badge, Button, Empty, + Link, Menu, MenuItem, MenuTrigger, @@ -17,19 +18,22 @@ import { } from "@/components/quests"; import { api } from "@convex/_generated/api"; import type { Id } from "@convex/_generated/dataModel"; -import type { Status, TimeRequired } from "@convex/constants"; +import type { Status } from "@convex/constants"; import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { useMutation, useQuery } from "convex/react"; -import { Ellipsis, Milestone } from "lucide-react"; +import { Ellipsis, Milestone, Pencil } from "lucide-react"; import { toast } from "sonner"; -export const Route = createFileRoute("/_authenticated/_home/quests/$questId")({ +export const Route = createFileRoute("/_authenticated/_home/quests/$questId/")({ component: QuestDetailRoute, }); function QuestDetailRoute() { const { questId } = Route.useParams(); const navigate = useNavigate(); + const user = useQuery(api.users.getCurrentUser); + const canEdit = user?.role === "admin"; + // TODO: Opportunity to combine these queries? const quest = useQuery(api.quests.getQuest, { questId: questId as Id<"quests">, @@ -68,6 +72,17 @@ function QuestDetailRoute() { onChange={handleStatusChange} isCore={quest.category === "core"} /> + {canEdit && ( + + + Edit + + )} -
- ); -}); - -const CostInput = memo(function CostInput({ - cost, - onChange, - onRemove, - hideLabel = false, -}: { - cost: Cost; - onChange: (cost: Cost) => void; - onRemove: (cost: Cost) => void; - hideLabel?: boolean; -}) { - return ( -
- - onChange({ cost: value, description: cost.description }) - } - /> - onChange({ cost: cost.cost, description: value })} - /> - -
- ); -}); - -const CostsInput = memo(function CostsInput({ - costs, - onChange, -}: { - costs: Cost[] | null; - onChange: (costs: Cost[] | null) => void; -}) { - return ( - - - onChange(isSelected ? null : [{ cost: 0, description: "" }]) - } - /> - {costs && ( -
- {costs.map((cost, index) => ( - { - const newCosts = [...costs]; - newCosts[index] = value; - onChange(newCosts); - }} - onRemove={() => { - onChange(costs.filter((_, i) => i !== index)); - }} - hideLabel={index > 0} - /> - ))} - -
- )} -
- ); -}); - -const TimeRequiredInput = memo(function TimeRequiredInput({ - timeRequired, - onChange, -}: { - timeRequired: TimeRequired; - onChange: (timeRequired: TimeRequired) => void; -}) { - if (!timeRequired) return null; - - return ( -
-
- - onChange({ - ...timeRequired, - min: value, - }) - } - /> - - onChange({ - ...timeRequired, - max: value, - }) - } - /> - -
- - onChange({ - ...timeRequired, - description: value || undefined, - }) - } + className="flex-1" /> +
); }); @@ -226,10 +60,6 @@ function AdminQuestDetailRoute() { const [title, setTitle] = useState(""); const [category, setCategory] = useState(null); const [jurisdiction, setJurisdiction] = useState(null); - const [costs, setCosts] = useState(null); - const [timeRequired, setTimeRequired] = useState( - DEFAULT_TIME_REQUIRED, - ); const [urls, setUrls] = useState([]); const [content, setContent] = useState(""); @@ -238,8 +68,6 @@ function AdminQuestDetailRoute() { setTitle(quest.title ?? ""); setCategory(quest.category as Category); setJurisdiction(quest.jurisdiction as Jurisdiction); - setCosts(quest.costs as Cost[]); - setTimeRequired(quest.timeRequired as TimeRequired); setUrls(quest.urls ?? []); setContent(quest.content ?? ""); } @@ -255,8 +83,6 @@ function AdminQuestDetailRoute() { title, category: category ?? undefined, jurisdiction: jurisdiction ?? undefined, - costs: costs ?? undefined, - timeRequired: timeRequired ?? undefined, urls: urls ?? undefined, content, }).then(() => { @@ -305,11 +131,6 @@ function AdminQuestDetailRoute() { ))} - -
{urls.map((url, index) => ( ({ + useMutation: vi.fn(), +})); + +// Mock toast notifications +vi.mock("sonner", () => ({ + toast: { + success: vi.fn(), + error: vi.fn(), + }, +}));