From a9e523229229b6fa32d5545ae72043d8e066da27 Mon Sep 17 00:00:00 2001 From: NicoSerranoP Date: Tue, 26 Nov 2024 19:27:58 -0500 Subject: [PATCH] feat(sort-filter): code restructure to get search term feat(sort-filter): get projects with metadata from server feat(sort-filter): search by name on the server fix(frontend): fix small react bugs fix(frontend): fix favicon.svg call in html meta fix(main): view projects button as router and not link fix(form): prevent undefined url call fix(frontend): activate ssr to prevent hydration errors fix(search): useDeferredValue to lag behind on search fix(image): prevent console.log error fix(image): pr comments fix(search): single if --- packages/interface/.env.example | 2 +- .../interface/src/components/ImageUpload.tsx | 143 ++++++++++-------- .../src/components/SingleRoundHome.tsx | 10 +- .../interface/src/components/SortFilter.tsx | 31 ++-- .../features/rounds/components/Projects.tsx | 13 +- packages/interface/src/layouts/BaseLayout.tsx | 14 +- packages/interface/src/providers/index.tsx | 2 +- .../src/server/api/routers/projects.ts | 4 +- packages/interface/src/utils/fetchProjects.ts | 44 ++++++ packages/interface/src/utils/types.ts | 6 + 10 files changed, 183 insertions(+), 86 deletions(-) diff --git a/packages/interface/.env.example b/packages/interface/.env.example index 26764182..8382fd88 100644 --- a/packages/interface/.env.example +++ b/packages/interface/.env.example @@ -72,7 +72,7 @@ NEXT_PUBLIC_MACI_START_BLOCK= NEXT_PUBLIC_MACI_SUBGRAPH_URL= -NEXT_PUBLIC_ROUND_LOGO="round-logo.png" +NEXT_PUBLIC_ROUND_LOGO="round-logo.svg" NEXT_PUBLIC_START_DATE=2024-01-01T00:00:00.000Z NEXT_PUBLIC_REGISTRATION_END_DATE=2024-01-01T00:00:00.000Z diff --git a/packages/interface/src/components/ImageUpload.tsx b/packages/interface/src/components/ImageUpload.tsx index f1855d33..4d10eb39 100644 --- a/packages/interface/src/components/ImageUpload.tsx +++ b/packages/interface/src/components/ImageUpload.tsx @@ -1,7 +1,9 @@ +/* eslint-disable react/require-default-props */ + import { useMutation } from "@tanstack/react-query"; import clsx from "clsx"; import { ImageIcon } from "lucide-react"; -import { type ComponentProps, useRef, useCallback } from "react"; +import { type ComponentProps, forwardRef, useRef, useCallback, ChangeEvent } from "react"; import { Controller, useFormContext } from "react-hook-form"; import { toast } from "sonner"; @@ -12,71 +14,86 @@ export interface IImageUploadProps extends ComponentProps<"img"> { maxSize?: number; } -export const ImageUpload = ({ - name = "", - maxSize = 1024 * 1024, // 1 MB - className, -}: IImageUploadProps): JSX.Element => { - const ref = useRef(null); - const { control } = useFormContext(); +export const ImageUpload = forwardRef( + ( + { + name = "", + maxSize = 1024 * 1024, // 1 MB + className, + }: IImageUploadProps, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _, + ): JSX.Element => { + const internalRef = useRef(null); + const { control } = useFormContext(); + + const select = useMutation({ + mutationFn: async (file: File) => { + if (file.size >= maxSize) { + toast.error("Image too large", { + description: `The image to selected is: ${(file.size / 1024).toFixed(2)} / ${(maxSize / 1024).toFixed(2)} kb`, + }); + throw new Error("IMAGE_TOO_LARGE"); + } + + return Promise.resolve(URL.createObjectURL(file)); + }, + }); + + const onClickIconButton = useCallback((e: React.MouseEvent) => { + e.preventDefault(); + }, []); - const select = useMutation({ - mutationFn: async (file: File) => { - if (file.size >= maxSize) { - toast.error("Image too large", { - description: `The image to selected is: ${(file.size / 1024).toFixed(2)} / ${(maxSize / 1024).toFixed(2)} kb`, - }); - throw new Error("IMAGE_TOO_LARGE"); - } + const onClickContainer = useCallback(() => { + internalRef.current?.click(); + }, [internalRef]); - return Promise.resolve(URL.createObjectURL(file)); - }, - }); + const onChangeInputImage = useCallback( + (event: ChangeEvent, onChange: (event: string) => void) => { + const [file] = event.target.files ?? []; + if (file) { + select.mutate(file, { + onSuccess: (objectUrl) => { + onChange(objectUrl); + }, + }); + } + }, + [select], + ); - const onClickIconButton = useCallback((e: React.MouseEvent) => { - e.preventDefault(); - }, []); + return ( + ( + // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events +
+ - return ( - ( - // eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -
ref.current?.click()} - > - +
-
+ { + onChangeInputImage(event, onChange); + }} + /> +
+ )} + rules={{ required: "Recipe picture is required" }} + /> + ); + }, +); - { - const [file] = event.target.files ?? []; - if (file) { - select.mutate(file, { - onSuccess: (objectUrl) => { - onChange(objectUrl); - }, - }); - } - }} - /> -
- )} - rules={{ required: "Recipe picture is required" }} - /> - ); -}; +ImageUpload.displayName = "ImageUpload"; diff --git a/packages/interface/src/components/SingleRoundHome.tsx b/packages/interface/src/components/SingleRoundHome.tsx index f312d2cc..26334ffa 100644 --- a/packages/interface/src/components/SingleRoundHome.tsx +++ b/packages/interface/src/components/SingleRoundHome.tsx @@ -1,3 +1,4 @@ +import Link from "next/link"; import { useAccount } from "wagmi"; import ConnectButton from "~/components/ConnectButton"; @@ -28,8 +29,13 @@ export const SingleRoundHome = ({ round }: ISingleRoundHomeProps): JSX.Element =

{round.description}

- {roundState === ERoundState.RESULTS && ( diff --git a/packages/interface/src/components/SortFilter.tsx b/packages/interface/src/components/SortFilter.tsx index d871be18..720a3f4d 100644 --- a/packages/interface/src/components/SortFilter.tsx +++ b/packages/interface/src/components/SortFilter.tsx @@ -8,31 +8,42 @@ import type { OrderBy, SortOrder } from "~/features/filter/types"; import { SortByDropdown } from "./SortByDropdown"; import { SearchInput } from "./ui/Form"; -export const SortFilter = (): JSX.Element => { +export interface ISortFilterProps { + onSearchChange: (search: string) => void; +} + +export const SortFilter = ({ onSearchChange }: ISortFilterProps): JSX.Element => { const { orderBy, sortOrder, setFilter } = useFilter(); const [search, setSearch] = useState(""); useDebounce(() => setFilter({ search }), 500, [search]); - const onChange = useCallback( + const onChangeSearchInput = useCallback( (e: ChangeEvent) => { - setSearch(e.target.value); + const term = e.target.value; + setSearch(term); + onSearchChange(term); + }, + [setSearch, onSearchChange], + ); + + const onChangeSortByDropdown = useCallback( + async (sort: string) => { + const [order, sorting] = sort.split("_") as [OrderBy, SortOrder]; + + await setFilter({ orderBy: order, sortOrder: sorting }).catch(); }, - [setSearch], + [setFilter], ); return (
- + { - const [order, sorting] = sort.split("_") as [OrderBy, SortOrder]; - - await setFilter({ orderBy: order, sortOrder: sorting }).catch(); - }} + onChange={onChangeSortByDropdown} />
); diff --git a/packages/interface/src/features/rounds/components/Projects.tsx b/packages/interface/src/features/rounds/components/Projects.tsx index 606e902f..aa528a62 100644 --- a/packages/interface/src/features/rounds/components/Projects.tsx +++ b/packages/interface/src/features/rounds/components/Projects.tsx @@ -1,6 +1,6 @@ import clsx from "clsx"; import Link from "next/link"; -import { useCallback, useMemo } from "react"; +import { useCallback, useDeferredValue, useMemo, useState } from "react"; import { FiAlertCircle } from "react-icons/fi"; import { Hex, zeroAddress } from "viem"; @@ -25,12 +25,19 @@ export interface IProjectsProps { } export const Projects = ({ pollId = "" }: IProjectsProps): JSX.Element => { + const [searchTerm, setSearchTerm] = useState(""); + const deferredSearchTerm = useDeferredValue(searchTerm); + const roundState = useRoundState({ pollId }); const { getRoundByPollId } = useRound(); const round = useMemo(() => getRoundByPollId(pollId), [pollId, getRoundByPollId]); - const projects = useSearchProjects({ pollId, search: "", registryAddress: round?.registryAddress ?? zeroAddress }); + const projects = useSearchProjects({ + pollId, + search: deferredSearchTerm, + registryAddress: round?.registryAddress ?? zeroAddress, + }); const { isRegistered } = useMaci(); const { addToBallot, removeFromBallot, ballotContains, getBallot } = useBallot(); @@ -114,7 +121,7 @@ export const Projects = ({ pollId = "" }: IProjectsProps): JSX.Element => {
- +
diff --git a/packages/interface/src/layouts/BaseLayout.tsx b/packages/interface/src/layouts/BaseLayout.tsx index 7aa327f5..d8bcfbd5 100644 --- a/packages/interface/src/layouts/BaseLayout.tsx +++ b/packages/interface/src/layouts/BaseLayout.tsx @@ -2,7 +2,7 @@ import clsx from "clsx"; import Head from "next/head"; import { useRouter } from "next/router"; import { useTheme } from "next-themes"; -import { type PropsWithChildren, createContext, useContext, useEffect, useCallback, useMemo } from "react"; +import { type PropsWithChildren, createContext, useContext, useEffect, useCallback, useMemo, useState } from "react"; import { tv } from "tailwind-variants"; import { useAccount } from "wagmi"; @@ -54,7 +54,8 @@ export const BaseLayout = ({ type = undefined, children = null, }: IBaseLayoutProps): JSX.Element => { - const { theme } = useTheme(); + const { theme, resolvedTheme } = useTheme(); + const [clientTheme, setClientTheme] = useState(""); const router = useRouter(); const { address, isConnecting } = useAccount(); @@ -68,6 +69,11 @@ export const BaseLayout = ({ manageDisplay(); }, [manageDisplay]); + useEffect(() => { + // Ensure the theme is consistent on the client side + setClientTheme(theme || resolvedTheme || ""); + }, [theme, resolvedTheme]); + const wrappedSidebar = {sidebarComponent}; const contextValue = useMemo(() => ({ eligibilityCheck, showBallot }), [eligibilityCheck, showBallot]); @@ -79,7 +85,7 @@ export const BaseLayout = ({ - + @@ -104,7 +110,7 @@ export const BaseLayout = ({ -
+
{header} diff --git a/packages/interface/src/providers/index.tsx b/packages/interface/src/providers/index.tsx index fdf56b2e..c947673f 100644 --- a/packages/interface/src/providers/index.tsx +++ b/packages/interface/src/providers/index.tsx @@ -63,7 +63,7 @@ function createWagmiConfig() { const config = getDefaultConfig({ appName, projectId, - ssr: false, + ssr: true, chains: activeChains as unknown as readonly [Chain, ...Chain[]], transports: { [appConfig.config.network.id]: http(appConfig.getRPCURL()), diff --git a/packages/interface/src/server/api/routers/projects.ts b/packages/interface/src/server/api/routers/projects.ts index a1dfad3f..94344c42 100644 --- a/packages/interface/src/server/api/routers/projects.ts +++ b/packages/interface/src/server/api/routers/projects.ts @@ -3,7 +3,7 @@ import { z } from "zod"; import { FilterSchema } from "~/features/filter/types"; import { createTRPCRouter, publicProcedure } from "~/server/api/trpc"; -import { fetchApprovedProjects, fetchProjects } from "~/utils/fetchProjects"; +import { fetchApprovedProjectsWithMetadata, fetchProjects } from "~/utils/fetchProjects"; import { getProjectCount } from "~/utils/registry"; import type { Chain, Hex } from "viem"; @@ -32,7 +32,7 @@ export const projectsRouter = createTRPCRouter({ .input(FilterSchema.extend({ search: z.string(), registryAddress: z.string() })) .query(async ({ input }) => // get the projects that are approved - fetchApprovedProjects(input.registryAddress), + fetchApprovedProjectsWithMetadata(input.search, input.registryAddress), ), projects: publicProcedure diff --git a/packages/interface/src/utils/fetchProjects.ts b/packages/interface/src/utils/fetchProjects.ts index c881048c..243a900b 100644 --- a/packages/interface/src/utils/fetchProjects.ts +++ b/packages/interface/src/utils/fetchProjects.ts @@ -1,6 +1,8 @@ import { config } from "~/config"; +import { Application } from "~/features/applications/types"; import { createCachedFetch } from "./fetch"; +import { fetchMetadata } from "./fetchMetadata"; import { IRecipient } from "./types"; const cachedFetch = createCachedFetch({ ttl: 1000 }); @@ -89,3 +91,45 @@ export async function fetchApprovedProjects(registryAddress: string): Promise { + const response = await cachedFetch<{ recipients: IRecipient[] }>(config.maciSubgraphUrl, { + method: "POST", + body: JSON.stringify({ + query: ApprovedProjects, + variables: { registryAddress }, + }), + }).then((resp: GraphQLResponse) => resp.data?.recipients); + + if (!response) { + return []; + } + + const recipients = await Promise.all( + response.map(async (request) => { + const metadata = (await fetchMetadata(request.metadataUrl)) as unknown as Application; + const name = metadata.name.toLowerCase(); + if (search !== "" && !name.includes(search.trim().toLowerCase())) { + return null; + } + return { + id: request.id, + metadataUrl: request.metadataUrl, + metadata, + payout: request.payout, + initialized: request.initialized, + index: request.index, + }; + }), + ); + + return recipients.filter((r) => r !== null); +} diff --git a/packages/interface/src/utils/types.ts b/packages/interface/src/utils/types.ts index ab9a9a36..d051f734 100644 --- a/packages/interface/src/utils/types.ts +++ b/packages/interface/src/utils/types.ts @@ -1,3 +1,5 @@ +import { Application } from "~/features/applications/types"; + import type { IGetPollData } from "maci-cli/sdk"; import type { Address, Hex } from "viem"; @@ -229,6 +231,10 @@ export interface IRecipient { * The recipient metadata url */ metadataUrl: string; + /** + * The recipient metadata values + */ + metadata?: Application; /** * The recipient address */