Skip to content

Commit

Permalink
add ipfs collections (gitcoinco#3315)
Browse files Browse the repository at this point in the history
* add ipfs collections

* return empty collection instead of selecting the first 100 if collection file is empty

* fix collection tests

* use unknown before parsing collection
  • Loading branch information
gravityblast authored Apr 17, 2024
1 parent 238b428 commit f414231
Show file tree
Hide file tree
Showing 10 changed files with 374 additions and 35 deletions.
99 changes: 96 additions & 3 deletions packages/data-layer/src/data-layer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,10 @@ import { Address } from "viem";
import * as categories from "./backends/categories";
import * as collections from "./backends/collections";
import { AlloVersion, PaginationInfo } from "./data-layer.types";
import { gql } from "graphql-request";
import {
Application,
ApplicationStatus,
Collection,
GrantApplicationFormAnswer,
OrderByRounds,
Program,
Project,
Expand All @@ -20,7 +19,7 @@ import {
Round,
RoundGetRound,
RoundsQueryVariables,
RoundWithApplications,
ExpandedApplicationRef,
SearchBasedProjectCategory,
V2RoundWithProject,
v2Project,
Expand Down Expand Up @@ -379,6 +378,96 @@ export class DataLayer {
return response.application ?? null;
}

/**
* Returns a list of applications identified by their chainId, roundId, and id.
* @param expandedRefs
*/
async getApplicationsByExpandedRefs(
expandedRefs: Array<ExpandedApplicationRef>,
): Promise<ApplicationSummary[]> {
if (expandedRefs.length === 0) {
return [];
}

const applicationToFilter = (r: ExpandedApplicationRef) => {
return `{
and: {
chainId: { equalTo: ${r.chainId} }
roundId: {
equalTo: "${r.roundId}"
}
id: { equalTo: "${r.id}" }
}
}`;
};

const filters = expandedRefs.map(applicationToFilter).join("\n");

const query = gql`
query Application {
applications(
first: 100
filter: {
or: [
${filters}
]
}
) {
id
chainId
roundId
projectId
status
totalAmountDonatedInUsd
uniqueDonorsCount
round {
strategyName
donationsStartTime
donationsEndTime
applicationsStartTime
applicationsEndTime
matchTokenAddress
roundMetadata
tags
}
metadata
project: canonicalProject {
tags
id
metadata
anchorAddress
}
}
}
`;

const response: { applications: Application[] } = await request(
this.gsIndexerEndpoint,
query,
);

return response.applications.map((a: Application) => {
return {
applicationRef: `${a.chainId}:${a.roundId}:${a.id}`,
chainId: parseInt(a.chainId),
roundApplicationId: a.id,
roundId: a.roundId,
roundName: a.round.roundMetadata?.name,
projectId: a.project.id,
name: a.project?.metadata?.title,
websiteUrl: a.project?.metadata?.website,
logoImageCid: a.project?.metadata?.logoImg!,
bannerImageCid: a.project?.metadata?.bannerImg!,
summaryText: a.project?.metadata?.description,
payoutWalletAddress: a.metadata?.application?.recipient,
createdAtBlock: 123,
contributorCount: a.uniqueDonorsCount,
contributionsTotalUsd: a.totalAmountDonatedInUsd,
tags: a.round.tags,
};
});
}

/**
* Returns a single application as identified by its id, round name and chain name
* @param projectId
Expand Down Expand Up @@ -626,6 +715,10 @@ export class DataLayer {
| {
type: "refs";
refs: string[];
}
| {
type: "expanded-refs";
refs: ExpandedApplicationRef[];
};
}): Promise<{
applications: ApplicationSummary[];
Expand Down
6 changes: 6 additions & 0 deletions packages/data-layer/src/data.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,12 @@ export type SearchBasedProjectCategory = {
searchQuery: string;
};

export type ExpandedApplicationRef = {
chainId: number;
roundId: string;
id: string;
};

export type Collection = {
id: string;
author: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,14 @@ export interface ApplicationSummary {
* @memberof ApplicationSummary
*/
contributionsTotalUsd: number;
/**
* Tags are present if applications are fetched from the indexer.
* They are not present if applications are fetched from the search backend.
* @type {array}
* @memberof ApplicationSummary
* @optional
*/
tags?: Array<string>;
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import { Collection } from "data-layer";
import tw from "tailwind-styled-components";
import { CheckIcon, LinkIcon } from "@heroicons/react/20/solid";
import { ShoppingCartIcon } from "@heroicons/react/24/outline";
import { useState } from "react";
import { CollectionV1 } from "./collections";

type Props = {
collection: Collection;
collection: CollectionV1;
projectsInView: number;
onAddAllApplicationsToCart: () => void;
};
Expand All @@ -16,12 +16,7 @@ export function CollectionDetails({
}: Props) {
return (
<div className="mt-16">
<h3 className="text-4xl font-medium mb-2">{`${collection.name} (${collection.applicationRefs.length})`}</h3>
<div className="text-lg flex gap-2 mb-12">
by:
<span className="text-white">{collection.author}</span>
</div>

<h3 className="text-4xl font-medium mb-2">{`${collection.name} (${collection.applications.length})`}</h3>
<div className="flex">
<div className="text-lg flex-1 whitespace-pre-wrap">
{collection.description}
Expand All @@ -31,7 +26,7 @@ export function CollectionDetails({
<ShareButton url={location.href} />
<AddToCartButton
current={projectsInView}
total={collection.applicationRefs.length}
total={collection.applications.length}
onAdd={onAddAllApplicationsToCart}
/>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import { parseCollection } from "../collections";
import { ZodError } from "zod";

describe("parseCollection", () => {
beforeEach(() => {
vi.clearAllMocks();
});

it("should parse a valid collection", async () => {
const data = {
version: "1.0.0",
applications: [
{
chainId: 1,
roundId: "0x05df76bc446cee7ad536e2d23c128c9c8909cf7b",
id: "0",
},
{
chainId: 10,
roundId: "8",
id: "7",
},
],
};

const response = parseCollection(data);
expect(response).toEqual(data);
});

it("requires a valid version", async () => {
const data = {
version: "xyz",
applications: [],
};

const expectedError = new ZodError([
{
received: "xyz",
code: "invalid_enum_value",
options: ["1.0.0"],
path: ["version"],
message: "Invalid enum value. Expected '1.0.0', received 'xyz'",
},
]);

expect(() => parseCollection(data)).toThrowError(expectedError);
});

it("requires applications", async () => {
const data = {
version: "1.0.0",
};

const expectedError = new ZodError([
{
code: "invalid_type",
expected: "array",
received: "undefined",
path: ["applications"],
message: "Required",
},
]);

expect(() => parseCollection(data)).toThrowError(expectedError);
});

it("requires chainId in applications", async () => {
const data = {
version: "1.0.0",
applications: [
{
roundId: "1",
id: "0",
},
],
};

const expectedError = new ZodError([
{
code: "invalid_type",
expected: "number",
received: "undefined",
path: ["applications", 0, "chainId"],
message: "Required",
},
]);

expect(() => parseCollection(data)).toThrowError(expectedError);
});

it("requires roundId in applications", async () => {
const data = {
version: "1.0.0",
applications: [
{
chainId: 1,
id: "0",
},
],
};

const expectedError = new ZodError([
{
code: "invalid_type",
expected: "string",
received: "undefined",
path: ["applications", 0, "roundId"],
message: "Required",
},
]);

expect(() => parseCollection(data)).toThrowError(expectedError);
});

it("requires id in applications", async () => {
const data = {
version: "1.0.0",
applications: [
{
chainId: 1,
roundId: "0",
},
],
};

const expectedError = new ZodError([
{
code: "invalid_type",
expected: "string",
received: "undefined",
path: ["applications", 0, "id"],
message: "Required",
},
]);

expect(() => parseCollection(data)).toThrowError(expectedError);
});
});
21 changes: 21 additions & 0 deletions packages/grant-explorer/src/features/collections/collections.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { z } from "zod";

const collectionSchemaV1 = z.object({
version: z.enum(["1.0.0"]),
name: z.string().optional(),
description: z.string().optional(),
author: z.string().optional(),
applications: z.array(
z.object({
chainId: z.number(),
roundId: z.string(),
id: z.string(),
})
),
});

export type CollectionV1 = z.infer<typeof collectionSchemaV1>;

export function parseCollection(json: unknown) {
return collectionSchemaV1.parse(json);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import useSWR, { SWRResponse } from "swr";
import { useDataLayer, Collection } from "data-layer";
import { CollectionV1, parseCollection } from "../collections";
import { getConfig } from "common/src/config";
import { ApplicationSummary } from "data-layer";

const config = getConfig();

export const useCollections = (): SWRResponse<Collection[]> => {
const dataLayer = useDataLayer();
Expand All @@ -14,7 +19,6 @@ export const useCollection = (
id: string | null
): SWRResponse<Collection | undefined> => {
const dataLayer = useDataLayer();

return useSWR(id === null ? null : ["collections", id], async () => {
if (id === null) {
// The first argument to useSRW will ensure that this function never gets
Expand All @@ -27,3 +31,17 @@ export const useCollection = (
return collection === null ? undefined : collection;
});
};

export const useIpfsCollection = (
cid: string | undefined
): SWRResponse<CollectionV1> => {
return useSWR(
cid === undefined ? null : ["collections/ipfs", cid],
async () => {
const url = `${config.ipfs.baseUrl}/ipfs/${cid}`;
return fetch(url)
.then((res) => res.json())
.then(parseCollection);
}
);
};
Loading

0 comments on commit f414231

Please sign in to comment.