Skip to content

Commit

Permalink
Andrei/agora 1572 proposal filtering on index page (#128)
Browse files Browse the repository at this point in the history
* Misc constant ref

* WIP implemeting sort logic

* implemented ui

* Implementing ui

* Misc

* Refreshing filter list

* Sort refresh and query update

* Const reference

* misc types updates

* Minor component optimization

* Type defs and cleanup

* Proposal filter debug + optimization

* Misc

* Pagination fix

* Remove unnused page var

* JS nested els warning
  • Loading branch information
andreitr committed Feb 19, 2024
1 parent 7b81fb6 commit 510df82
Show file tree
Hide file tree
Showing 10 changed files with 172 additions and 68 deletions.
45 changes: 30 additions & 15 deletions src/app/api/common/proposals/getProposals.ts
Expand Up @@ -8,31 +8,46 @@ import { getVotableSupplyForNamespace } from "../votableSupply/getVotableSupply"
import { getQuorumForProposalForNamespace } from "../quorum/getQuorum";

export async function getProposalsForNamespace({
page = 1,
filter,
namespace,
page = 1,
}: {
page: number;
filter: string;
namespace: "optimism";
page: number;
}) {
const pageSize = 10;

const prodDataOnly = process.env.NEXT_PUBLIC_AGORA_ENV === "prod" && {
contract: contracts(namespace).governor.address.toLowerCase(),
};

const { meta, data: proposals } = await paginatePrismaResult(
(skip: number, take: number) =>
prisma[`${namespace}Proposals`].findMany({
take,
skip,
orderBy: {
ordinal: "desc",
},
where: {
...(prodDataOnly || {}),
cancelled_block: null,
},
}),
(skip: number, take: number) => {
if (filter === "relevant") {
return prisma[`${namespace}Proposals`].findMany({
take,
skip,
orderBy: {
ordinal: "desc",
},
where: {
...(prodDataOnly || {}),
cancelled_block: null,
},
});
} else {
return prisma[`${namespace}Proposals`].findMany({
take,
skip,
orderBy: {
ordinal: "desc",
},
where: {
...(prodDataOnly || {}),
},
});
}
},
page,
pageSize
);
Expand Down
13 changes: 8 additions & 5 deletions src/app/api/proposals/getProposals.ts
@@ -1,14 +1,17 @@
import {
getProposalsForNamespace,
getProposalForNamespace,
getProposalTypesForNamespace,
getProposalsForNamespace,
} from "../common/proposals/getProposals";
import { ProposalFilter } from "@/app/api/proposals/proposal";

export const getProposals = ({ page = 1 }: { page: number }) =>
getProposalsForNamespace({ page, namespace: "optimism" });
export const getProposals = (params: {
page: number;
filter: ProposalFilter;
}) => getProposalsForNamespace({ ...params, namespace: "optimism" });

export const getProposal = ({ proposal_id }: { proposal_id: string }) =>
getProposalForNamespace({ proposal_id, namespace: "optimism" });
export const getProposal = (params: { proposal_id: string }) =>
getProposalForNamespace({ ...params, namespace: "optimism" });

export const getProposalTypes = () =>
getProposalTypesForNamespace({ namespace: "optimism" });
1 change: 1 addition & 0 deletions src/app/api/proposals/proposal.d.ts
@@ -0,0 +1 @@
export type ProposalFilter = "relevant" | "everything";
5 changes: 2 additions & 3 deletions src/app/delegates/page.jsx
Expand Up @@ -56,10 +56,9 @@ export async function generateMetadata({}, parent) {
}

export default async function Page({ searchParams }) {
const sort =
delegatesFilterOptions[searchParams.orderBy]?.sort || "weighted_random";
const sort = delegatesFilterOptions[searchParams.orderBy]?.sort || delegatesFilterOptions.weightedRandom.sort;
const citizensSort =
citizensFilterOptions[searchParams.citizensOrderBy]?.value || "shuffle";
citizensFilterOptions[searchParams.citizensOrderBy]?.value || citizensFilterOptions.shuffle.sort;
const seed = Math.random();
const delegates = await fetchDelegates(sort);
const citizens = await fetchCitizens(citizensSort);
Expand Down
31 changes: 19 additions & 12 deletions src/app/page.jsx
@@ -1,22 +1,22 @@
import ProposalsList from "@/components/Proposals/ProposalsList/ProposalsList";
import NeedsMyVoteProposalsList from "@/components/Proposals/NeedsMyVoteProposalsList/NeedsMyVoteProposalsList";
import DAOMetricsHeader from "@/components/Metrics/DAOMetricsHeader";
import styles from "@/styles/homepage.module.scss";
import Hero from "@/components/Hero/Hero";
import { PageDivider } from "@/components/Layout/PageDivider";
import { VStack } from "@/components/Layout/Stack";
import { getProposals } from "./api/proposals/getProposals";
import { getMetrics } from "./api/metrics/getMetrics";
import DAOMetricsHeader from "@/components/Metrics/DAOMetricsHeader";
import NeedsMyVoteProposalsList from "@/components/Proposals/NeedsMyVoteProposalsList/NeedsMyVoteProposalsList";
import ProposalsList from "@/components/Proposals/ProposalsList/ProposalsList";
import { proposalsFilterOptions } from "@/lib/constants";
import styles from "@/styles/homepage.module.scss";
import { getVotableSupply } from "src/app/api/votableSupply/getVotableSupply";
import { getMetrics } from "./api/metrics/getMetrics";
import { getNeedsMyVoteProposals } from "./api/proposals/getNeedsMyVoteProposals";
import { getProposals } from "./api/proposals/getProposals";

// Revalidate cache every 60 seconds
export const revalidate = 60;

async function fetchProposals(page = 1) {
async function fetchProposals(filter, page = 1) {
"use server";

return getProposals({ page });
return getProposals({ filter, page });
}

async function fetchNeedsMyVoteProposals(address) {
Expand Down Expand Up @@ -57,8 +57,12 @@ export async function generateMetadata({}, parent) {
};
}

export default async function Home() {
const proposals = await fetchProposals();
export default async function Home({ searchParams }) {
const filter = searchParams?.filter
? proposalsFilterOptions.everything.filter
: proposalsFilterOptions.relevant.filter;
const proposals = await fetchProposals(filter);

const metrics = await fetchDaoMetrics();
const votableSupply = await fetchVotableSupply();

Expand All @@ -73,7 +77,10 @@ export default async function Home() {
/>
<ProposalsList
initialProposals={proposals}
fetchProposals={fetchProposals}
fetchProposals={async (page) => {
"use server";
return getProposals({ filter, page });
}}
votableSupply={votableSupply}
/>
</VStack>
Expand Down
Expand Up @@ -64,7 +64,7 @@ export default function DelegateCardList({
(d) => !existingIds.has(d.address)
);
setPages((prev) => [...prev, { ...data, delegates: uniqueDelegates }]);
setMeta(data.meta);
setMeta(data.meta) ;
fetching.current = false;
}
};
Expand Down
7 changes: 4 additions & 3 deletions src/components/Delegates/DelegatesFilter/DelegatesFilter.jsx
Expand Up @@ -11,27 +11,28 @@ export default function DelegatesFilter() {
const router = useRouter();
const searchParams = useSearchParams();
const orderByParam = searchParams.get("orderBy");
const [selected, setSelected] = useState(orderByParam || "weightedRandom");
const [selected, setSelected] = useState(orderByParam || delegatesFilterOptions.weightedRandom.value);

// TODO: -> this router.push is super slow but window.history.pushState does not revalidate the query and the
// problem using revalidatePath is that it erases searchParams, another idea to optimize this filter is to prefetch
// the data, also use hooks useAddSearchParam and useDeleteSearchParam
useEffect(() => {
const handleChanges = (value) => {
value === "weightedRandom"
value === delegatesFilterOptions.weightedRandom.value
? router.push("/delegates")
: router.push(`/delegates?orderBy=${value}`);
};

handleChanges(selected);
}, [router, selected]);


return (
<Listbox as="div" value={selected} onChange={setSelected}>
{() => (
<>
<Listbox.Button className="w-full md:w-fit bg-[#F7F7F7] text-base font-medium border-none rounded-full py-2 px-4 flex items-center">
{delegatesFilterOptions[selected]?.value || "Weighted Random"}
{delegatesFilterOptions[selected]?.value || delegatesFilterOptions.weightedRandom.value}
<ChevronDown className="h-4 w-4 ml-[2px] opacity-30 hover:opacity-100" />
</Listbox.Button>
<Listbox.Options className="mt-3 absolute bg-[#F7F7F7] border border-[#ebebeb] p-2 rounded-2xl flex flex-col gap-1">
Expand Down
56 changes: 56 additions & 0 deletions src/components/Proposals/ProposalsFilter/ProposalsFilter.tsx
@@ -0,0 +1,56 @@
"use client";

import { useRouter, useSearchParams } from "next/navigation";
import { proposalsFilterOptions } from "@/lib/constants";
import { Listbox } from "@headlessui/react";
import { Fragment, useEffect, useState } from "react";
import { ChevronDown } from "lucide-react";

export default function ProposalsFilter() {
const router = useRouter();
const searchParams = useSearchParams();
const filterParam = searchParams?.get("filter");
const [selected, setSelected] = useState(
filterParam || proposalsFilterOptions.relevant.filter
);

const isRecentFilter = selected === proposalsFilterOptions.relevant.filter;

useEffect(() => {
const handleChanges = (value: string) => {
isRecentFilter ? router.push("/") : router.push(`/?filter=${value}`);
};

handleChanges(selected);
}, [router, selected]);

return (
<div className="relative">
<Listbox value={selected} onChange={setSelected}>
<Listbox.Button className="w-full md:w-fit bg-[#F7F7F7] text-base font-medium border-none rounded-full py-2 px-4 flex items-center">
{selected === proposalsFilterOptions.relevant.filter
? proposalsFilterOptions.relevant.value
: proposalsFilterOptions.everything.value}
<ChevronDown className="h-4 w-4 ml-[2px] opacity-30 hover:opacity-100" />
</Listbox.Button>
<Listbox.Options className="mt-3 absolute bg-[#F7F7F7] border border-[#ebebeb] p-2 rounded-2xl flex flex-col gap-1">
{Object.values(proposalsFilterOptions).map((option) => (
<Listbox.Option key={option.filter} value={option.filter}>
{({ selected }) => (
<div
className={`cursor-pointer text-base py-2 px-3 border rounded-xl font-medium ${
selected
? "text-black bg-white border-[#ebebeb]"
: "text-[#66676b] border-transparent"
}`}
>
{option.value}
</div>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Listbox>
</div>
);
}
56 changes: 33 additions & 23 deletions src/components/Proposals/ProposalsList/ProposalsList.jsx
@@ -1,45 +1,55 @@
"use client";

import { useEffect, useRef, useState } from "react";

import PageHeader from "@/components/Layout/PageHeader/PageHeader";
import { HStack, VStack } from "@/components/Layout/Stack";
import ProposalsFilter from "@/components/Proposals/ProposalsFilter/ProposalsFilter";
import * as React from "react";
import { useRouter } from "next/navigation";
import InfiniteScroll from "react-infinite-scroller";
import styles from "./proposalLists.module.scss";
import { HStack, VStack } from "@/components/Layout/Stack";
import PageHeader from "@/components/Layout/PageHeader/PageHeader";
import Proposal from "../Proposal/Proposal";
import Loader from "@/components/Layout/Loader";
import styles from "./proposalLists.module.scss";

export default function ProposalsList({
initialProposals,
fetchProposals,
votableSupply,
}) {
const router = useRouter();
const fetching = React.useRef(false);
const [pages, setPages] = React.useState([initialProposals] || []);
const [meta, setMeta] = React.useState(initialProposals.meta);
const fetching = useRef(false);
const [pages, setPages] = useState([initialProposals] || []);
const [meta, setMeta] = useState(initialProposals.meta);

useEffect(() => {
setPages([initialProposals]);
setMeta(initialProposals.meta);
}, [initialProposals]);

const loadMore = async () => {
if (fetching.current || !meta.hasNextPage) return;

const loadMore = async (page) => {
if (!fetching.current && meta.hasNextPage) {
fetching.current = true;
const data = await fetchProposals(page);
const existingIds = new Set(proposals.map((p) => p.id));
const uniqueProposals = data.proposals.filter(
(p) => !existingIds.has(p.id)
);
setPages((prev) => [...prev, { ...data, proposals: uniqueProposals }]);
setMeta(data.meta);
fetching.current = false;
}
fetching.current = true;

const data = await fetchProposals(meta.currentPage + 1);
const uniqueProposals = data.proposals.filter(
(p) => !proposals.some((existing) => existing.id === p.id)
);
setPages((prev) => [...prev, { ...data, proposals: uniqueProposals }]);
setMeta(data.meta);
fetching.current = false;
};

const proposals = pages.reduce((all, page) => all.concat(page.proposals), []);
const proposals = pages.flatMap((page) => page.proposals);

return (
<VStack className={styles.proposals_list_container}>
{/* {address && <NonVotedProposalsList address={address} />} */}

<PageHeader headerText="All Proposals" />
<div className="flex flex-col md:flex-row justify-between items-baseline gap-2">
<PageHeader headerText="All Proposals" />
<div className="flex flex-col md:flex-row justify-between gap-4 w-full md:w-fit">
<ProposalsFilter />
</div>
</div>

<VStack className={styles.proposals_table_container}>
<div className={styles.proposals_table}>
Expand Down
24 changes: 18 additions & 6 deletions src/lib/constants.ts
@@ -1,3 +1,13 @@
export const proposalsFilterOptions = {
relevant: {
value: "Relevant",
filter: "relevant",
},
everything: {
value: "Everything",
filter: "everything",
},
};
export const delegatesFilterOptions = {
weightedRandom: {
sort: "weighted_random",
Expand All @@ -15,8 +25,10 @@ export const delegatesFilterOptions = {
export const citizensFilterOptions = {
mostVotingPower: {
value: "Most voting power",
sort: "most_voting_power",
},
shuffle: {
sort: "shuffle",
value: "Shuffle",
},
};
Expand All @@ -33,24 +45,24 @@ export const delegatesVotesSortOptions = {

export const retroPGFCategories = {
ALL: {
filter: "All projects"
filter: "All projects",
},
COLLECTIVE_GOVERNANCE: {
text: "Collective Governance",
filter: "Collective Governance (104)"
filter: "Collective Governance (104)",
},
DEVELOPER_ECOSYSTEM: {
text: "Developer Ecosystem",
filter: "Developer Ecosystem (304)"
filter: "Developer Ecosystem (304)",
},
END_USER_EXPERIENCE_AND_ADOPTION: {
text: "End UX & Adoption",
filter: "End User Experience & Adoption (472)"
filter: "End User Experience & Adoption (472)",
},
OP_STACK: {
text: "OP Stack",
filter: "OP Stack (165)"
}
filter: "OP Stack (165)",
},
};

export const retroPGFSort = {
Expand Down

0 comments on commit 510df82

Please sign in to comment.