From 7147db1e54f1cda2f1f77021ea5d7445eba1dc0c Mon Sep 17 00:00:00 2001
From: Ed Castro <ed.sdr@outlook.com>
Date: Tue, 15 Oct 2024 17:41:53 -0300
Subject: [PATCH] feat: subnet filters

Co-authored-by: Kelvin Steiner <me@steinerkelvin.dev>
---
 .../src/app/(pages)/modules/page.tsx          |  6 +-
 .../src/app/(pages)/subnets/page.tsx          | 54 ++++++++++----
 .../app/components/subnet-view-controls.tsx   | 70 +++++++++++++++++++
 packages/api/src/router/subnet.ts             | 45 ++++++++++++
 packages/subspace/queries/index.ts            | 47 ++++++++-----
 5 files changed, 189 insertions(+), 33 deletions(-)
 create mode 100644 apps/commune-validator/src/app/components/subnet-view-controls.tsx

diff --git a/apps/commune-validator/src/app/(pages)/modules/page.tsx b/apps/commune-validator/src/app/(pages)/modules/page.tsx
index 631a834e..3f8b6e4e 100644
--- a/apps/commune-validator/src/app/(pages)/modules/page.tsx
+++ b/apps/commune-validator/src/app/(pages)/modules/page.tsx
@@ -1,11 +1,12 @@
 import { Suspense } from "react";
 
+import type { Module } from "~/utils/types";
 import { ModuleCard } from "~/app/components/module-card";
 import { PaginationControls } from "~/app/components/pagination-controls";
 import { ViewControls } from "~/app/components/view-controls";
 import { api } from "~/trpc/server";
 
-export default async function Page({
+export default async function ModulesPage({
   searchParams,
 }: {
   searchParams: { page?: string; sortBy?: string; order?: string };
@@ -17,7 +18,6 @@ export default async function Page({
   const { modules, metadata } = await api.module.paginatedAll({
     page: currentPage,
     limit: 24,
-    // @ts-expect-error - TS doesn't know about sortBy for some reason
     sortBy: sortBy,
     order: order,
   });
@@ -29,7 +29,7 @@ export default async function Page({
       </Suspense>
       <div className="mb-16 grid w-full animate-fade-up grid-cols-1 gap-4 backdrop-blur-md animate-delay-700 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
         {modules.length ? (
-          modules.map((module) => (
+          modules.map((module: Module) => (
             <ModuleCard
               id={module.id}
               key={module.id}
diff --git a/apps/commune-validator/src/app/(pages)/subnets/page.tsx b/apps/commune-validator/src/app/(pages)/subnets/page.tsx
index e23552d6..a5c55dcb 100644
--- a/apps/commune-validator/src/app/(pages)/subnets/page.tsx
+++ b/apps/commune-validator/src/app/(pages)/subnets/page.tsx
@@ -1,19 +1,49 @@
+import { Suspense } from "react";
+
+import type { Subnet } from "~/utils/types";
+import { PaginationControls } from "~/app/components/pagination-controls";
 import SubnetCard from "~/app/components/subnet-card";
+import { SubnetViewControls } from "~/app/components/subnet-view-controls";
 import { api } from "~/trpc/server";
 
-export default async function SubnetsPage() {
-  const data = await api.subnet.all();
+export default async function SubnetsPage({
+  searchParams,
+}: {
+  searchParams: { page?: string; sortBy?: string; order?: string };
+}) {
+  const currentPage = Number(searchParams.page) || 1;
+  const sortBy = searchParams.sortBy ?? "id";
+  const order = searchParams.order === "desc" ? "desc" : "asc";
+
+  const { subnets, metadata } = await api.subnet.paginatedAll({
+    page: currentPage,
+    limit: 24,
+    sortBy: sortBy,
+    order: order,
+  });
 
   return (
-    <div className="mb-4 flex w-full flex-col gap-4">
-      {data.map((subnet) => (
-        <SubnetCard
-          key={subnet.id}
-          founderAddress={subnet.founder}
-          id={subnet.netuid}
-          name={subnet.name}
-        />
-      ))}
-    </div>
+    <>
+      <Suspense fallback={<div>Loading view controls...</div>}>
+        <SubnetViewControls />
+      </Suspense>
+      <div className="mb-4 flex w-full flex-col gap-4">
+        {subnets.length ? (
+          subnets.map((subnet: Subnet) => (
+            <SubnetCard
+              key={subnet.id}
+              founderAddress={subnet.founder}
+              id={subnet.netuid}
+              name={subnet.name}
+            />
+          ))
+        ) : (
+          <p>No subnets found</p>
+        )}
+      </div>
+      <Suspense fallback={<div>Loading...</div>}>
+        <PaginationControls totalPages={metadata.totalPages} />
+      </Suspense>
+    </>
   );
 }
diff --git a/apps/commune-validator/src/app/components/subnet-view-controls.tsx b/apps/commune-validator/src/app/components/subnet-view-controls.tsx
new file mode 100644
index 00000000..eb3a2327
--- /dev/null
+++ b/apps/commune-validator/src/app/components/subnet-view-controls.tsx
@@ -0,0 +1,70 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { useRouter, useSearchParams } from "next/navigation";
+
+type SortField =
+  | "id"
+  | "founderShare"
+  | "incentiveRatio"
+  | "proposalRewardTreasuryAllocation"
+  | "minValidatorStake"
+  | "createdAt";
+
+type SortOrder = "asc" | "desc";
+
+const sortFieldLabels: Record<SortField, string> = {
+  id: "ID",
+  founderShare: "Founder Share",
+  incentiveRatio: "Incentive Ratio",
+  proposalRewardTreasuryAllocation: "Proposal Reward Alloc.",
+  minValidatorStake: "Min. Vali. Stake",
+  createdAt: "Creation Date",
+};
+
+export function SubnetViewControls() {
+  const router = useRouter();
+  const searchParams = useSearchParams();
+  const [sortField, setSortField] = useState<SortField>(
+    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+    (searchParams.get("sortBy") as SortField) ?? "id",
+  );
+  const [sortOrder, setSortOrder] = useState<SortOrder>(
+    // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
+    (searchParams.get("order") as SortOrder) ?? "asc",
+  );
+
+  useEffect(() => {
+    const newSearchParams = new URLSearchParams(searchParams);
+    newSearchParams.set("sortBy", sortField);
+    newSearchParams.set("order", sortOrder);
+    router.push(`?${newSearchParams.toString()}`, { scroll: false });
+  }, [sortField, sortOrder, router, searchParams]);
+
+  const handleSortChange = (field: SortField) => {
+    const newOrder =
+      field === sortField && sortOrder === "asc" ? "desc" : "asc";
+    setSortField(field);
+    setSortOrder(newOrder);
+  };
+
+  return (
+    <div className="mb-4 flex w-full animate-fade-down flex-col items-center justify-between gap-2 border-b border-white/20 pb-4 animate-delay-200 md:flex-row">
+      <span className="w-full text-white">Sort by:</span>
+      {(Object.keys(sortFieldLabels) as SortField[]).map((field) => (
+        <button
+          key={field}
+          onClick={() => handleSortChange(field)}
+          className={`w-full py-1 text-sm ${
+            sortField === field
+              ? "border border-cyan-500 bg-cyan-500/20 text-white"
+              : "border border-white/20 bg-[#898989]/5 text-gray-300 hover:bg-gray-600/50"
+          }`}
+        >
+          {sortFieldLabels[field]}{" "}
+          {sortField === field && (sortOrder === "asc" ? "↑" : "↓")}
+        </button>
+      ))}
+    </div>
+  );
+}
diff --git a/packages/api/src/router/subnet.ts b/packages/api/src/router/subnet.ts
index ba7845c8..1015430b 100644
--- a/packages/api/src/router/subnet.ts
+++ b/packages/api/src/router/subnet.ts
@@ -23,6 +23,51 @@ export const subnetRouter = {
         where: eq(subnetDataSchema.id, input.id),
       });
     }),
+  paginatedAll: publicProcedure
+    .input(
+      z.object({
+        page: z.number().int().positive().default(1),
+        limit: z.number().int().positive().max(100).default(50),
+        sortBy: z
+          .enum([
+            "id",
+            "founderShare",
+            "incentiveRatio",
+            "proposalRewardTreasuryAllocation",
+            "minValidatorStake",
+            "createdAt",
+          ])
+          .default("id"),
+        order: z.enum(["asc", "desc"]).default("asc"),
+      }),
+    )
+    .query(async ({ ctx, input }) => {
+      const { page, limit, sortBy, order } = input;
+      const offset = (page - 1) * limit;
+
+      const subnets = await ctx.db.query.subnetDataSchema.findMany({
+        limit: limit,
+        offset: offset,
+        orderBy: (subnetData, { asc, desc }) => [
+          order === "asc" ? asc(subnetData[sortBy]) : desc(subnetData[sortBy]),
+        ],
+      });
+
+      const totalCount = await ctx.db
+        .select({ count: sql`count(*)` })
+        .from(subnetDataSchema)
+        .then((result) => Number(result[0]?.count));
+
+      return {
+        subnets,
+        metadata: {
+          currentPage: page,
+          pageSize: limit,
+          totalCount,
+          totalPages: Math.ceil(totalCount / limit),
+        },
+      };
+    }),
   byUserSubnetData: publicProcedure
     .input(z.object({ userKey: z.string() }))
     .query(async ({ ctx, input }) => {
diff --git a/packages/subspace/queries/index.ts b/packages/subspace/queries/index.ts
index 0ea61821..b740342d 100644
--- a/packages/subspace/queries/index.ts
+++ b/packages/subspace/queries/index.ts
@@ -21,12 +21,12 @@ import type {
 } from "@commune-ts/utils";
 import {
   checkSS58,
-  STAKE_OUT_DATA_SCHEMA,
   GOVERNANCE_CONFIG_SCHEMA,
   isSS58,
-  STAKE_FROM_SCHEMA,
   MODULE_BURN_CONFIG_SCHEMA,
   NetworkSubnetConfigSchema,
+  STAKE_FROM_SCHEMA,
+  STAKE_OUT_DATA_SCHEMA,
   SUBSPACE_MODULE_SCHEMA,
 } from "@commune-ts/types";
 import {
@@ -36,7 +36,6 @@ import {
   standardizeUidToSS58address,
 } from "@commune-ts/utils";
 
-
 export { ApiPromise };
 
 // == chain ==
@@ -348,7 +347,7 @@ export async function queryStakeOutCORRECT(
   if (!response.ok) {
     throw new Error("Failed to fetch data");
   }
-  const stakeOutData = STAKE_OUT_DATA_SCHEMA.parse(await response.json())
+  const stakeOutData = STAKE_OUT_DATA_SCHEMA.parse(await response.json());
   return stakeOutData;
 }
 
@@ -521,8 +520,9 @@ export async function queryUserTotalStaked(
  * @param netuidWhitelist if empty, modules from all subnets are returned
  */
 
-
-export async function querySubnetParams(api: Api): Promise<NetworkSubnetConfig[]> {
+export async function querySubnetParams(
+  api: Api,
+): Promise<NetworkSubnetConfig[]> {
   const subnetProps: SubspaceStorageName[] = [
     "subnetNames",
     "immunityPeriod",
@@ -550,7 +550,6 @@ export async function querySubnetParams(api: Api): Promise<NetworkSubnetConfig[]
   const subnetInfo = await queryChain(api, props);
   const subnetNames = subnetInfo.subnetNames;
 
-
   const subnets: NetworkSubnetConfig[] = [];
   for (const [netuid, _] of Object.entries(subnetNames)) {
     const subnet: NetworkSubnetConfig = NetworkSubnetConfigSchema.parse({
@@ -566,13 +565,18 @@ export async function querySubnetParams(api: Api): Promise<NetworkSubnetConfig[]
       trustRatio: subnetInfo.trustRatio[netuid]!,
       maxWeightAge: subnetInfo.maxWeightAge[netuid]!,
       bondsMovingAverage: subnetInfo.bondsMovingAverage[netuid],
-      maximumSetWeightCallsPerEpoch: subnetInfo.maximumSetWeightCallsPerEpoch[netuid],
+      maximumSetWeightCallsPerEpoch:
+        subnetInfo.maximumSetWeightCallsPerEpoch[netuid],
       minValidatorStake: subnetInfo.minValidatorStake[netuid]!,
       maxAllowedValidators: subnetInfo.maxAllowedValidators[netuid],
-      moduleBurnConfig: MODULE_BURN_CONFIG_SCHEMA.parse(subnetInfo.moduleBurnConfig[netuid]),
+      moduleBurnConfig: MODULE_BURN_CONFIG_SCHEMA.parse(
+        subnetInfo.moduleBurnConfig[netuid],
+      ),
       subnetMetadata: subnetInfo.subnetMetadata[netuid],
       netuid: netuid,
-      subnetGovernanceConfig: GOVERNANCE_CONFIG_SCHEMA.parse(subnetInfo.subnetGovernanceConfig[netuid]),
+      subnetGovernanceConfig: GOVERNANCE_CONFIG_SCHEMA.parse(
+        subnetInfo.subnetGovernanceConfig[netuid],
+      ),
       subnetEmission: subnetInfo.subnetEmission[netuid],
     });
     subnets.push(subnet);
@@ -582,7 +586,7 @@ export async function querySubnetParams(api: Api): Promise<NetworkSubnetConfig[]
 
 export function keyStakeFrom(
   targetKey: SS58Address,
-  stakeFromStorage: Map<SS58Address, Map<SS58Address, bigint>>
+  stakeFromStorage: Map<SS58Address, Map<SS58Address, bigint>>,
 ) {
   const stakerMap = stakeFromStorage.get(targetKey);
   let totalStake = 0n;
@@ -618,13 +622,17 @@ export async function queryRegisteredModulesInfo(
     "dividends",
     "delegationFee",
     "stakeFrom",
-  ]
+  ];
 
-  const extraPropsQuery: { subspaceModule: SubspaceStorageName[] } = { subspaceModule: moduleProps }
+  const extraPropsQuery: { subspaceModule: SubspaceStorageName[] } = {
+    subspaceModule: moduleProps,
+  };
   const modulesInfo = await queryChain(api, extraPropsQuery, netuid);
   const processedModules = standardizeUidToSS58address(modulesInfo, uidToSS58);
   const moduleMap: SubspaceModule[] = [];
-  const parsedStakeFromStorage = STAKE_FROM_SCHEMA.parse({ stakeFromStorage: processedModules.stakeFrom });
+  const parsedStakeFromStorage = STAKE_FROM_SCHEMA.parse({
+    stakeFromStorage: processedModules.stakeFrom,
+  });
 
   for (const uid of Object.keys(uidToSS58)) {
     const moduleKey = uidToSS58[uid];
@@ -646,10 +654,13 @@ export async function queryRegisteredModulesInfo(
       incentive: processedModules.incentive[moduleKey],
       dividends: processedModules.dividends[moduleKey],
       delegationFee: processedModules.delegationFee[moduleKey],
-      totalStaked: keyStakeFrom(moduleKey, parsedStakeFromStorage.stakeFromStorage),
-      totalStakers: parsedStakeFromStorage.stakeFromStorage.get(moduleKey)?.size ?? 0,
-
-    })
+      totalStaked: keyStakeFrom(
+        moduleKey,
+        parsedStakeFromStorage.stakeFromStorage,
+      ),
+      totalStakers:
+        parsedStakeFromStorage.stakeFromStorage.get(moduleKey)?.size ?? 0,
+    });
     moduleMap.push(module);
   }
   return moduleMap;