From b8f60bc03c2b308c0ee54983276184e93071ca7f Mon Sep 17 00:00:00 2001 From: Eva Decker Date: Sun, 20 Oct 2024 17:28:23 -0400 Subject: [PATCH] feat: Add quest sorting (#140) --- .changeset/five-ducks-reflect.md | 5 + convex/auth.ts | 1 + convex/constants.ts | 229 +++++++++--------- convex/schema.ts | 10 +- convex/seed.ts | 1 + convex/userQuests.ts | 11 +- convex/users.ts | 9 +- convex/validators.ts | 13 +- src/components/GridList/GridList.tsx | 4 +- src/components/ListBox/ListBox.tsx | 4 +- src/routes/_authenticated/quests/$questId.tsx | 2 +- src/routes/_authenticated/quests/route.tsx | 94 +++++-- 12 files changed, 247 insertions(+), 136 deletions(-) create mode 100644 .changeset/five-ducks-reflect.md diff --git a/.changeset/five-ducks-reflect.md b/.changeset/five-ducks-reflect.md new file mode 100644 index 0000000..8186fd9 --- /dev/null +++ b/.changeset/five-ducks-reflect.md @@ -0,0 +1,5 @@ +--- +"namesake": minor +--- + +Update sidebar nav and add quest sorting diff --git a/convex/auth.ts b/convex/auth.ts index 33b54e2..bf4d4e4 100644 --- a/convex/auth.ts +++ b/convex/auth.ts @@ -28,6 +28,7 @@ export const { auth, signIn, signOut, store } = convexAuth({ emailVerified: args.profile.emailVerified ?? false, role: "user", theme: "system", + sortQuestsBy: "newest", }); }, diff --git a/convex/constants.ts b/convex/constants.ts index fc0543e..4039bcb 100644 --- a/convex/constants.ts +++ b/convex/constants.ts @@ -1,152 +1,153 @@ import { type RemixiconComponentType, - RiAccountCircleLine, + RiAccountCircleFill, RiAppleFill, RiAtLine, - RiAuctionLine, - RiAwardLine, - RiBankCardLine, - RiBankLine, - RiBasketballLine, + RiAuctionFill, + RiAwardFill, + RiBankCardFill, + RiBankFill, + RiBasketballFill, RiBlueskyFill, - RiBuildingLine, - RiCakeLine, + RiBuildingFill, + RiCakeFill, RiCalendarLine, - RiCapsuleLine, - RiCarLine, - RiChat1Line, + RiCapsuleFill, + RiCarFill, + RiChat1Fill, RiCheckboxLine, RiChromeFill, - RiCodeLine, - RiCommunityLine, - RiContactsBookLine, + RiCodeFill, + RiCommunityFill, + RiContactsBookFill, RiDiscordFill, RiDropboxFill, RiDropdownList, RiFacebookCircleFill, - RiFileLine, - RiFlowerLine, - RiFolder2Line, + RiFileFill, + RiFlowerFill, + RiFolder2Fill, RiGithubFill, - RiGlobalLine, + RiGlobalFill, RiGoogleFill, - RiGovernmentLine, - RiGraduationCapLine, - RiHandHeartLine, + RiGovernmentFill, + RiGraduationCapFill, + RiHandHeartFill, RiHashtag, - RiHeartLine, - RiHomeLine, - RiHospitalLine, - RiIdCardLine, - RiImageLine, + RiHeartFill, + RiHomeFill, + RiHospitalFill, + RiIdCardFill, + RiImageFill, RiInputField, RiInstagramFill, - RiKeyLine, - RiLightbulbLine, + RiKeyFill, + RiLightbulbFill, RiLinkedinFill, - RiLock2Line, - RiMailLine, + RiLock2Fill, + RiMailFill, RiMediumFill, - RiMentalHealthLine, - RiMusic2Line, - RiNewsLine, + RiMentalHealthFill, + RiMusic2Fill, + RiNewsFill, RiParagraph, RiPatreonFill, + RiPhoneFill, RiPhoneLine, RiPinterestFill, - RiPlantLine, + RiPlantFill, RiPlaystationFill, - RiPoliceBadgeLine, + RiPoliceBadgeFill, RiRedditFill, - RiRestaurantLine, - RiRobot2Line, - RiScales3Line, - RiSchoolLine, - RiSettings3Line, - RiShapesLine, - RiShieldUserLine, - RiSignpostLine, + RiRestaurantFill, + RiRobot2Fill, + RiScales3Fill, + RiSchoolFill, + RiSettings3Fill, + RiShapesFill, + RiShieldUserFill, + RiSignpostFill, RiSlackFill, - RiSmartphoneLine, + RiSmartphoneFill, RiSnapchatFill, - RiSofaLine, + RiSofaFill, RiSoundcloudFill, - RiSparklingLine, + RiSparklingFill, RiSpotifyFill, - RiStore2Line, + RiStore2Fill, RiSwitchFill, - RiSwordLine, - RiTeamLine, - RiTentLine, + RiSwordFill, + RiTeamFill, + RiTentFill, RiTiktokFill, - RiToothLine, - RiTrophyLine, - RiTv2Line, + RiToothFill, + RiTrophyFill, + RiTv2Fill, RiTwitterXFill, - RiUserLine, - RiWalletLine, + RiUserFill, + RiWalletFill, 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, + account: RiAccountCircleFill, + bank: RiBankFill, + basketball: RiBasketballFill, + building: RiBuildingFill, + cake: RiCakeFill, + car: RiCarFill, + certificate: RiAwardFill, + chat: RiChat1Fill, + code: RiCodeFill, + college: RiGraduationCapFill, + community: RiCommunityFill, + contacts: RiContactsBookFill, + creditCard: RiBankCardFill, + file: RiFileFill, + flower: RiFlowerFill, + folder: RiFolder2Fill, + food: RiRestaurantFill, + gavel: RiAuctionFill, + giving: RiHandHeartFill, + global: RiGlobalFill, + government: RiGovernmentFill, + heart: RiHeartFill, + home: RiHomeFill, + hospital: RiHospitalFill, + id: RiIdCardFill, + image: RiImageFill, + key: RiKeyFill, + lightbulb: RiLightbulbFill, + lock: RiLock2Fill, + mail: RiMailFill, + mentalHealth: RiMentalHealthFill, + music: RiMusic2Fill, + news: RiNewsFill, + phone: RiPhoneFill, + pill: RiCapsuleFill, + plant: RiPlantFill, + police: RiPoliceBadgeFill, + robot: RiRobot2Fill, + scale: RiScales3Fill, + school: RiSchoolFill, + settings: RiSettings3Fill, + shapes: RiShapesFill, + signpost: RiSignpostFill, + smartphone: RiSmartphoneFill, + socialSecurity: RiShieldUserFill, + sofa: RiSofaFill, + sparkles: RiSparklingFill, + store: RiStore2Fill, + sword: RiSwordFill, + team: RiTeamFill, + tent: RiTentFill, + tooth: RiToothFill, + trophy: RiTrophyFill, + tv: RiTv2Fill, + user: RiUserFill, + wallet: RiWalletFill, // Logos apple: RiAppleFill, @@ -286,3 +287,9 @@ export const ROLES = { admin: "Admin", } as const; export type Role = keyof typeof ROLES; + +export const SORT_QUESTS_BY = { + newest: "Newest", + oldest: "Oldest", +} as const; +export type SortQuestsBy = keyof typeof SORT_QUESTS_BY; diff --git a/convex/schema.ts b/convex/schema.ts index 96e792e..e606560 100644 --- a/convex/schema.ts +++ b/convex/schema.ts @@ -1,7 +1,14 @@ import { authTables } from "@convex-dev/auth/server"; import { defineSchema, defineTable } from "convex/server"; import { v } from "convex/values"; -import { field, icon, jurisdiction, role, theme } from "./validators"; +import { + field, + icon, + jurisdiction, + role, + sortQuestsBy, + theme, +} from "./validators"; /** * Represents a collection of steps and forms for a user to complete. @@ -92,6 +99,7 @@ const users = defineTable({ jurisdiction: v.optional(jurisdiction), isMinor: v.optional(v.boolean()), theme: theme, + sortQuestsBy: v.optional(sortQuestsBy), }).index("email", ["email"]); /** diff --git a/convex/seed.ts b/convex/seed.ts index 8a86931..8865aac 100644 --- a/convex/seed.ts +++ b/convex/seed.ts @@ -33,6 +33,7 @@ const seed = internalMutation(async (ctx) => { role: "admin", emailVerified: faker.datatype.boolean(), theme: faker.helpers.arrayElement(["system", "light", "dark"]), + sortQuestsBy: "newest", }); console.log(`Created user ${firstName} ${lastName}`); diff --git a/convex/userQuests.ts b/convex/userQuests.ts index d91a80a..22b72b2 100644 --- a/convex/userQuests.ts +++ b/convex/userQuests.ts @@ -13,11 +13,16 @@ export const getQuestsForCurrentUser = userQuery({ .withIndex("userId", (q) => q.eq("userId", ctx.userId)) .collect(); - const quests = Promise.all( - userQuests.map((quest) => ctx.db.get(quest.questId)), + const quests = await Promise.all( + userQuests.map(async (userQuest) => { + const quest = await ctx.db.get(userQuest.questId); + return quest && quest.deletionTime === undefined + ? { ...quest, completionTime: userQuest.completionTime } + : null; + }), ); - return (await quests).filter((quest) => quest?.deletionTime === undefined); + return quests.filter((quest) => quest !== null); }, }); diff --git a/convex/users.ts b/convex/users.ts index 6fa752d..174d8e7 100644 --- a/convex/users.ts +++ b/convex/users.ts @@ -3,7 +3,7 @@ import { v } from "convex/values"; import { query } from "./_generated/server"; import type { Role } from "./constants"; import { userMutation, userQuery } from "./helpers"; -import { theme } from "./validators"; +import { sortQuestsBy, theme } from "./validators"; // TODO: Add `returns` value validation // https://docs.convex.dev/functions/validation @@ -64,6 +64,13 @@ export const setUserTheme = userMutation({ }, }); +export const setSortQuestsBy = userMutation({ + args: { sortQuestsBy: sortQuestsBy }, + handler: async (ctx, args) => { + await ctx.db.patch(ctx.userId, { sortQuestsBy: args.sortQuestsBy }); + }, +}); + // TODO: This throws an error when deleting own account // Implement RLS check for whether this is the user's own account // or a different account being deleted by an admin diff --git a/convex/validators.ts b/convex/validators.ts index 0f4b729..ed2704f 100644 --- a/convex/validators.ts +++ b/convex/validators.ts @@ -1,5 +1,12 @@ import { v } from "convex/values"; -import { FIELDS, ICONS, JURISDICTIONS, ROLES, THEMES } from "./constants"; +import { + FIELDS, + ICONS, + JURISDICTIONS, + ROLES, + SORT_QUESTS_BY, + THEMES, +} from "./constants"; export const jurisdiction = v.union( ...Object.keys(JURISDICTIONS).map((jurisdiction) => v.literal(jurisdiction)), @@ -20,3 +27,7 @@ export const icon = v.union( export const field = v.union( ...Object.keys(FIELDS).map((field) => v.literal(field)), ); + +export const sortQuestsBy = v.union( + ...Object.keys(SORT_QUESTS_BY).map((sortQuestsBy) => v.literal(sortQuestsBy)), +); diff --git a/src/components/GridList/GridList.tsx b/src/components/GridList/GridList.tsx index 32355b0..cd6d7c9 100644 --- a/src/components/GridList/GridList.tsx +++ b/src/components/GridList/GridList.tsx @@ -28,11 +28,11 @@ export function GridList({ const itemStyles = tv({ extend: focusRing, - base: "relative text-gray-normal flex items-center gap-3 cursor-pointer select-none py-2 px-3 text-sm border-y border-gray-dim first:border-t-0 last:border-b-0 first:rounded-t-md last:rounded-b-md -mb-px last:mb-0 -outline-offset-2", + base: "relative text-gray-normal flex items-center gap-3 cursor-pointer select-none py-2 px-3 text-sm first:rounded-t-md last:rounded-b-md -mb-px last:mb-0 -outline-offset-2", variants: { isSelected: { false: "", - true: "bg-purple-subtle border-y border-purple-dim z-20", + true: "bg-purple-3 dark:bg-purple-dark-3 z-20", }, isDisabled: { true: "opacity-50 cursor-default forced-colors:text-[GrayText] z-10", diff --git a/src/components/ListBox/ListBox.tsx b/src/components/ListBox/ListBox.tsx index 7359023..45909de 100644 --- a/src/components/ListBox/ListBox.tsx +++ b/src/components/ListBox/ListBox.tsx @@ -114,8 +114,8 @@ export function DropdownSection( props: DropdownSectionProps, ) { return ( -
-
+
+
{props.title}
{props.children} diff --git a/src/routes/_authenticated/quests/$questId.tsx b/src/routes/_authenticated/quests/$questId.tsx index a7fbfd3..3c7f5c9 100644 --- a/src/routes/_authenticated/quests/$questId.tsx +++ b/src/routes/_authenticated/quests/$questId.tsx @@ -74,7 +74,7 @@ function QuestDetailRoute() { size="small" icon={RiMoreFill} /> - + {!userQuest.completionTime && ( handleMarkComplete(quest._id)}> Mark complete diff --git a/src/routes/_authenticated/quests/route.tsx b/src/routes/_authenticated/quests/route.tsx index 0009911..bf800d2 100644 --- a/src/routes/_authenticated/quests/route.tsx +++ b/src/routes/_authenticated/quests/route.tsx @@ -6,13 +6,22 @@ import { Form, GridList, GridListItem, + Menu, + MenuItem, + MenuSection, + MenuTrigger, Modal, ProgressBar, } from "@/components"; import { api } from "@convex/_generated/api"; import type { Id } from "@convex/_generated/dataModel"; -import { ICONS } from "@convex/constants"; -import { RiAddLine, RiSignpostLine } from "@remixicon/react"; +import { ICONS, type SortQuestsBy } from "@convex/constants"; +import { + RiAddLine, + RiCheckLine, + RiFilter3Line, + RiSignpostLine, +} from "@remixicon/react"; import { Outlet, createFileRoute } from "@tanstack/react-router"; import { Authenticated, @@ -22,6 +31,7 @@ import { } from "convex/react"; import { useState } from "react"; import type { Selection } from "react-aria-components"; +import { twMerge } from "tailwind-merge"; export const Route = createFileRoute("/_authenticated/quests")({ component: IndexRoute, @@ -110,8 +120,15 @@ const NewQuestModal = ({ }; function IndexRoute() { + const currentUser = useQuery(api.users.getCurrentUser); + const setSortQuestsBy = useMutation(api.users.setSortQuestsBy); + const [isNewQuestModalOpen, setIsNewQuestModalOpen] = useState(false); + const handleSortByChange = (sortBy: SortQuestsBy) => { + setSortQuestsBy({ sortQuestsBy: sortBy }); + }; + const MyQuests = () => { const myQuests = useQuery(api.userQuests.getQuestsForCurrentUser); const completedQuests = useQuery(api.userQuests.getCompletedQuestCount); @@ -133,16 +150,52 @@ function IndexRoute() { const totalQuests = myQuests.length; + const sortedQuests = myQuests.sort((a, b) => { + if (currentUser?.sortQuestsBy === "oldest") { + // Sort all quests by old to new _creationTime + return a._creationTime - b._creationTime; + } + if (currentUser?.sortQuestsBy === "newest") { + // Sort all quests by new to old _creationTime + return b._creationTime - a._creationTime; + } + return 0; + }); + return (
- +
+ + +
- {myQuests.map((quest) => { + {sortedQuests.map((quest) => { if (quest === null) return null; const Icon = ICONS[quest.icon]; @@ -156,10 +209,23 @@ function IndexRoute() { params: { questId: quest._id }, }} > -
- -

{quest.title}

- {quest.jurisdiction && {quest.jurisdiction}} +
+
+ +

{quest.title}

+ {quest.jurisdiction && {quest.jurisdiction}} +
+ {quest.completionTime ? ( + + ) : null}
); @@ -168,7 +234,7 @@ function IndexRoute() { textValue="Add quest" onAction={() => setIsNewQuestModalOpen(true)} > - Add quest + Add quest