From 371db1ce6509a758e82ca3fba3cd2fe1963931d5 Mon Sep 17 00:00:00 2001 From: Carl Barrdahl Date: Mon, 12 Feb 2024 10:47:30 +0100 Subject: [PATCH] Explorer: [2670] Application page indexer data (#2712) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Application page redesign * Update RoundEndedBanner * Clean up * Remove unused file * Remove console.log * Remove unused code * Fix tests * Fix tests * Add loading skeleton for about text * Add wrapping div to reduce diff in component * Refactor hooks and ProjectStats into seperate files * Revert "Refactor hooks and ProjectStats into seperate files" This reverts commit ce73c96624d6490a53ea2001f7f1b65024b66d87. * Fix icon color * Create useApplication hook to fetch data from the indexer * fix: use real indexer v2 endpointˆ * fix: finish application page indexer v2 data * feat: don't show v2 projects on v1, disallow adding v2 projects to cart. fix adding v1 projects to cart * feat: don't show v2 projects on v1, disallow adding v2 projects to cart. fix adding v1 projects to cart * feat: type-safe non-optional useParams * wip: tests * fix: tests for application pageˆ * feat: simplify tests for application page * fix: fix cart tests * fix: types * Update packages/grant-explorer/src/features/round/__tests__/ViewProjectDetails.test.tsx Co-authored-by: Mohamed Boudra --------- Co-authored-by: Atris Co-authored-by: Mohamed Boudra --- packages/common/src/index.ts | 10 +- .../hooks/__tests__/data/application.ts | 375 ++++++++++++++++++ .../hooks/__tests__/useApplication.test.tsx | 32 ++ .../features/projects/hooks/useApplication.ts | 154 +++++++ .../src/features/round/ViewProjectDetails.tsx | 108 +++-- .../__tests__/ViewProjectDetails.test.tsx | 289 +++++++------- 6 files changed, 769 insertions(+), 199 deletions(-) create mode 100644 packages/grant-explorer/src/features/projects/hooks/__tests__/data/application.ts create mode 100644 packages/grant-explorer/src/features/projects/hooks/__tests__/useApplication.test.tsx create mode 100644 packages/grant-explorer/src/features/projects/hooks/useApplication.ts diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index ec617a0132..294aeac4e3 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -6,11 +6,17 @@ import { Network, Web3Provider } from "@ethersproject/providers"; import { Signer } from "@ethersproject/abstract-signer"; import { graphql_fetch } from "./graphql_fetch"; import { ChainId } from "./chain-ids"; +import { useParams as useRouterParams } from "react-router"; + export * from "./icons"; export * from "./markdown"; export { ChainId }; +export function useParams = never>() { + return useRouterParams() as T; +} + export enum PassportState { NOT_CONNECTED, INVALID_PASSPORT, @@ -312,7 +318,7 @@ export { AlloV2 } from "./allo/backends/allo-v2"; export { createWaitForIndexerSyncTo, getCurrentSubgraphBlockNumber, - waitForSubgraphSyncTo + waitForSubgraphSyncTo, } from "./allo/indexer"; export type { WaitUntilIndexerSynced } from "./allo/indexer"; export { createPinataIpfsUploader } from "./allo/ipfs"; @@ -323,7 +329,7 @@ export { createViemTransactionSender, decodeEventFromReceipt, sendRawTransaction, - sendTransaction + sendTransaction, } from "./allo/transaction-sender"; export type AnyJson = diff --git a/packages/grant-explorer/src/features/projects/hooks/__tests__/data/application.ts b/packages/grant-explorer/src/features/projects/hooks/__tests__/data/application.ts new file mode 100644 index 0000000000..72c7c94e5b --- /dev/null +++ b/packages/grant-explorer/src/features/projects/hooks/__tests__/data/application.ts @@ -0,0 +1,375 @@ +export const applicationData = { + application: { + id: "18", + chainId: 424, + roundId: "0xd4cc0dd193c7dc1d665ae244ce12d7fab337a008", + status: "APPROVED", + totalAmountDonatedInUsd: 2515.015, + totalDonationsCount: 1410, + round: { + donationsStartTime: "2023-11-15T12:00:00", + donationsEndTime: "2023-11-29T23:59:00", + applicationsStartTime: "2023-10-27T04:20:00", + applicationsEndTime: "2023-11-29T23:59:00", + roundMetadata: { + name: "Web3 Open Source Software ", + support: { + info: "https://t.me/+glYGBHC5fVxjNGUx", + type: "Telegram Group Invite Link", + }, + roundType: "public", + eligibility: { + description: + "Grants for open-source projects primarily focused on developing on top of, or advancing the broader Ethereum and/or Web3 industry. Applications submitted by November 8th are guaranteed to be reviewed before the start of the round.", + requirements: [ + { + requirement: "Web3 Open Source Software Round Eligibility", + }, + { + requirement: + "To be eligible for this grant round, projects must meet the general program policy and the following specific requirements:", + }, + { + requirement: "Open Source: The project must be open-source.", + }, + { + requirement: + "Recent Activity: There must be meaningful GitHub activity within the past 3 months, demonstrating ongoing work aligned with the project's mission.", + }, + { + requirement: + "Web3 Focus: The project should primarily aim to develop or advance the broader Ethereum and/or Web3 ecosystem", + }, + ], + }, + feesAddress: "", + feesPercentage: 0, + programContractAddress: "0x8294ea30a691b47bb73e4b64225e52a080dc9ec7", + quadraticFundingConfig: { + matchingCap: true, + sybilDefense: true, + matchingCapAmount: 7.42, + minDonationThreshold: true, + matchingFundsAvailable: 200000, + minDonationThresholdAmount: 0.969, + }, + }, + }, + projectId: + "0xf85911aef3286fb038ed02b59f9aeba9711c1e12af61a8dd3e56d1cd7d66a197", + project: { + id: "0xf85911aef3286fb038ed02b59f9aeba9711c1e12af61a8dd3e56d1cd7d66a197", + metadata: { + title: "Mirror", + logoImg: "bafkreiescughfplki27nvckpjczsgn2qiwedveisupv32rkxggpjduo6ge", + website: "https://github.com/zaratanDotWorld/mirror", + bannerImg: + "bafkreiem47slmfhjvx3h6kugxewn7rjrupe4ftzl4kuxbllsf67mi7zy2a", + createdAt: 1698775918681, + userGithub: "kronosapiens", + credentials: { + github: { + type: ["VerifiableCredential"], + proof: { + jws: "eyJhbGciOiJFZERTQSIsImNyaXQiOlsiYjY0Il0sImI2NCI6ZmFsc2V9..4pptlgfAvYlX5F8lUFW1ZG6yosUAX2wNLz-i4KAAjm-_Adh6QfTduweSAW4GpwwGIBeEaVrF06sd01pusShTDA", + type: "Ed25519Signature2018", + created: "2023-10-31T18:08:50.405Z", + proofPurpose: "assertionMethod", + verificationMethod: + "did:key:z6MkghvGHLobLEdj1bgRLhS4LPGJAvbMA1tn2zcRyqmYU5LC#z6MkghvGHLobLEdj1bgRLhS4LPGJAvbMA1tn2zcRyqmYU5LC", + }, + issuer: "did:key:z6MkghvGHLobLEdj1bgRLhS4LPGJAvbMA1tn2zcRyqmYU5LC", + "@context": ["https://www.w3.org/2018/credentials/v1"], + issuanceDate: "2023-10-31T18:08:50.405Z", + expirationDate: "2024-01-29T18:08:50.405Z", + credentialSubject: { + id: "did:pkh:eip155:1:0xBDa44695a53DfEC8Fdb4b9c3087Ee1eDF91F5337", + hash: "v0.0.0:ObGjEGMMKOviip2BkGCUsiYK5yLNRleTxA85R1mFjy8=", + "@context": [ + { + hash: "https://schema.org/Text", + provider: "https://schema.org/Text", + }, + ], + provider: "ClearTextGithubOrg#zaratanDotWorld#1874062", + }, + }, + twitter: { + type: ["VerifiableCredential"], + proof: { + jws: "eyJhbGciOiJFZERTQSIsImNyaXQiOlsiYjY0Il0sImI2NCI6ZmFsc2V9..HGQwttSpGSl1HzR4aMxHzcQdfWybIJE66K6AOn4oQJ-7HSS_JnGNTXAuZApOoTyBH_e85HJvgaCPsNonfMKiDQ", + type: "Ed25519Signature2018", + created: "2023-10-31T18:08:39.803Z", + proofPurpose: "assertionMethod", + verificationMethod: + "did:key:z6MkghvGHLobLEdj1bgRLhS4LPGJAvbMA1tn2zcRyqmYU5LC#z6MkghvGHLobLEdj1bgRLhS4LPGJAvbMA1tn2zcRyqmYU5LC", + }, + issuer: "did:key:z6MkghvGHLobLEdj1bgRLhS4LPGJAvbMA1tn2zcRyqmYU5LC", + "@context": ["https://www.w3.org/2018/credentials/v1"], + issuanceDate: "2023-10-31T18:08:39.802Z", + expirationDate: "2024-01-29T18:08:39.802Z", + credentialSubject: { + id: "did:pkh:eip155:1:0xBDa44695a53DfEC8Fdb4b9c3087Ee1eDF91F5337", + hash: "v0.0.0:nK1n2UBTUdGYLTE+UqtY/7dLZUkJluU5TjEC8vyu/cQ=", + "@context": [ + { + hash: "https://schema.org/Text", + provider: "https://schema.org/Text", + }, + ], + provider: "ClearTextTwitter#ZaratanDotWorld", + }, + }, + }, + description: + 'Mirror by Zaratan is a DAO platform for residential communities, being used successfully in a 9-person house, providing mechanisms for managing labor contributions, behavioral norms, and shared funds. Funding will go towards development, marketing, and legal costs for developing and distributing the tools to other communities.\n\nDAO platforms are an important but challenging vertical in the Web3 space. Seen as a type of "holy grail" for crypto, many projects have attempted to innovate in the space, and each provides valuable insights and lessons for others to learn from.\n\nMirror is a DAO platform specifically designed for residential communities wishing to operate in a decentralized fashion. It uses a Slack-based chat-bot interface to allow residents to engage in community governance and manage shared resources. The underlying technology involves innovative time-based mechanics, which can serve as a case study for other projects to learn from. So far, the mechanisms have been very effective, allowing a non-technical userbase to meaningfully engage in community governance.\n\nMirror was developed by game designers, economists, and crypto-economic engineers, and has been successfully deployed to a 9-person house in Los Angeles. The prototype implementation runs on a Web2 stack, with plans to target a Web3 backend on the horizon. The project was open-sourced under AGPL-3, with subscriptions to a managed service being used to support long-term development. This is a similar approach that projects such as Loomio, Ghost, and Proton have taken in the past.\n\nYou can read the Whitepaper here: https://bit.ly/mirror-whitepaper\n\nYou can learn more about Zaratan here: https://zaratan.world/\n\nTeam Members:\n\nDaniel Kronovet, project lead. Founder of Zaratan and developed the initial Mirror mechanisms; research engineer at Colony who helped developed the BudgetBox algorithm\n\nSeth Frey, research advisor. PhD Cognitive Science under Elinor Ostrom, helped develop the economic mechanisms\n\nJoseph DeSimone, research advisor. Game designer, helped develop the economic mechanisms', + logoImgData: {}, + bannerImgData: {}, + projectGithub: "zaratanDotWorld", + projectTwitter: "zaratanDotworld", + }, + }, + metadata: { + signature: + "0x3195e94a52a84cdeae7559b5305d0a77f5618b928c22046fe66382de77ace65e0bf737863c47ef9e659621aaf9b57eafbac110c72908114c030dadf64628a2c51c", + application: { + round: "0xd4cc0dd193c7dc1d665ae244ce12d7fab337a008", + answers: [ + { + type: "email", + hidden: true, + question: "Email Address", + questionId: 0, + encryptedAnswer: { + ciphertext: + "K12ffuP3HdAn6I1JFT9AEHUkkC5pdjrImvs+JmeEzGUpP8gy0m6Cbrc9UMmjXyPT", + encryptedSymmetricKey: + "7680d6a2ed7553c99e36607fc0b390cd7aeffa06167e282c49dbede1231e69decebadb5b217f8bc8fc4c14c87b730819d35a23967c623c5ee33e546746064948b2c49cb393dc0b0c4a547a730632501e66d0741f4aab66268e26b00110b1b3a6cbdf320748e014cb008c66994a9c484e970498017aeaa5acb3890a5e24dd8a7d00000000000000203cd6fefb311b907d2dc2f1f091dc4cf3c90c9020fd3f1ed59cd7a7d00860b23962f51aa5511c0d318d2bd6bac61b9e90", + }, + }, + { + type: "short-answer", + answer: "kronosapiens", + hidden: false, + question: "Telegram Handle (you can answer N/A)", + questionId: 1, + }, + { + type: "short-answer", + hidden: false, + question: "Link to your Public Group Chat (Discord or Telegram )", + questionId: 2, + }, + { + type: "paragraph", + answer: + "DAOs are an important web3 vertical and the subject of active research. This project is proving out new mechanisms and contributing to the field of DAOs both in theory and practice.\n\nMirror has implemented and deployed novel mechanisms and demonstrated that they work under real-world conditions. The project has attracted academic interest and has been the subject of multiple talks and presentations.", + hidden: false, + question: + "How is the open-source software you're creating contributing to the evolution or enhancement of the wider Ethereum and Web3 ecosystem?", + questionId: 3, + }, + { + type: "paragraph", + answer: + "Since the GG18 round we have completed our closed beta and have begun our open beta. We have a waitlist of 50 communities waiting to try our tools and have already begun onboarding them.", + hidden: false, + question: + "If you've participated in past grant rounds, please share any new updates or milestones from the prior months (N/A if you haven’t participated previously). ", + questionId: 4, + }, + { + type: "paragraph", + answer: + "Currently we measure the impact of our project by our number of active users. Our sample size is still small, but so far none of our communities has chosen to leave the platform, which is encouraging.", + hidden: false, + question: + "How do you measure the impact of your project? Please provide examples and/or standard metrics. This might be used in future impact reports ", + questionId: 5, + }, + { + type: "number", + answer: "15000", + hidden: false, + question: "Total Prior Funding for the project in USD", + questionId: 6, + }, + { + type: "checkbox", + answer: ["Grants"], + hidden: false, + question: + "Which of the following funding sources have you received? Check all that apply", + questionId: 7, + }, + { + type: "number", + answer: "3", + hidden: false, + question: "Total Team Size", + questionId: 8, + }, + { + type: "number", + answer: "46", + hidden: false, + question: + "How old (in Months) is the project? (We will use twitter, domain registration, github, etc to verify)", + questionId: 9, + }, + { + type: "address", + answer: "0xB2800742F81ddce563998B66511d21C14bCEaBcc", + hidden: true, + question: + "If you participated in past grant rounds using a different project payout wallet address, please share it here:", + questionId: 10, + }, + { + type: "paragraph", + answer: "@kronosapiens\n@enfascination", + hidden: true, + question: + "Profiles or socials of other main team members publicly associated with the project (N/A for solo founders): ", + questionId: 11, + }, + { + type: "multiple-choice", + answer: "Yes", + hidden: true, + question: + "Have you read and confirmed your grant abides by the Program General Eligibility Policy? https://gitcoin.notion.site/GG19-Eligibility-50843c8b3ab44ad297731a00efa6e389?pvs=4", + questionId: 12, + }, + { + type: "paragraph", + answer: + "Gitcoin's support has been invaluable so far. We are grateful to be able to participate in this grants process.", + hidden: false, + question: + "Anything else you'd like to share about your project, previous work, or other project affiliations? Anything you'd like to add that may help in determining project eligibility?", + questionId: 13, + }, + { + type: "multiple-choice", + hidden: true, + question: + "Would you like to opt-in to future messages from Gitcoin for updates, information, and/or interviews? ", + questionId: 14, + encryptedAnswer: { + ciphertext: "An5HbER63Ec2IIHPGRkuuBjwcORcgApNYr4X9V1beoI=", + encryptedSymmetricKey: + "a0da970b25bf636651271c60a5f5d2a10b0b458c0c63d53f7fc12c2ae9924b2efd34e1ed4b3f5472196f8c174f98f41925f2eb22e0767d8fc50f68198ab4bdba613b9b879e0155cda95fa66a49934c42af0115a1557f856b053301b751d27585d77b2d9351a89d406063a38fd9cd6604efab622cb471e30822db825ead81493000000000000000202c543201f4e69de793c34a849946a6a6092670f3e7b0a1a63299d45fdc99971f08883416a42605dadc3c03777cd4ab90", + }, + }, + { + type: "multiple-choice", + hidden: true, + question: + "Would you like to opt-in to us sharing your information with select third-parties for investment opportunities? ", + questionId: 15, + encryptedAnswer: { + ciphertext: "6r0/bDrpIc2NEis3mwJdhbdO6qQdgzwwVjUuroc+6K0=", + encryptedSymmetricKey: + "7054552cc7709acdd353601b9b38124bd51867bf32edb2511909ec0adf66bb99b9d154a6bed9865226a19a91e4687c19c56acd9b5a31d60ea4d6d504150ffd63d471e953e2e9f2599054c24b845d6058270dfc6329eadc23abe5a6d3ea2ac5b62d7cdc5d12ec5fb5c274168f5c0eb528ca87ef9312d18002b2e250346f1adba400000000000000203b2201eba96db0b23fc386c5539343dc63e5a3fcfc83fc30e9f5eb473ca1d6c113f26fedb176ee90f3b850c3aa95a88c", + }, + }, + { + type: "multiple-choice", + answer: "North America", + hidden: false, + question: + "Location/Region where the project is based. Choose from: ", + questionId: 16, + }, + { + type: "short-answer", + answer: "Kronosapiens", + hidden: false, + question: "Gitcoin Gov Forum Handle (gov.gitcoin.co)", + questionId: 17, + }, + ], + project: { + id: "424:0xDF9BF58Aa1A1B73F0e214d79C652a7dd37a6074e:215", + title: "Mirror", + logoImg: + "bafkreiescughfplki27nvckpjczsgn2qiwedveisupv32rkxggpjduo6ge", + metaPtr: { + pointer: + "bafkreiaibp47et5nai2r67webezaf6uq4ua3rkljsdpmpvwrollzqb6v4e", + protocol: "1", + }, + website: "https://github.com/zaratanDotWorld/mirror", + bannerImg: + "bafkreiem47slmfhjvx3h6kugxewn7rjrupe4ftzl4kuxbllsf67mi7zy2a", + createdAt: 1698775918681, + userGithub: "kronosapiens", + credentials: { + github: { + type: ["VerifiableCredential"], + proof: { + jws: "eyJhbGciOiJFZERTQSIsImNyaXQiOlsiYjY0Il0sImI2NCI6ZmFsc2V9..4pptlgfAvYlX5F8lUFW1ZG6yosUAX2wNLz-i4KAAjm-_Adh6QfTduweSAW4GpwwGIBeEaVrF06sd01pusShTDA", + type: "Ed25519Signature2018", + created: "2023-10-31T18:08:50.405Z", + proofPurpose: "assertionMethod", + verificationMethod: + "did:key:z6MkghvGHLobLEdj1bgRLhS4LPGJAvbMA1tn2zcRyqmYU5LC#z6MkghvGHLobLEdj1bgRLhS4LPGJAvbMA1tn2zcRyqmYU5LC", + }, + issuer: + "did:key:z6MkghvGHLobLEdj1bgRLhS4LPGJAvbMA1tn2zcRyqmYU5LC", + "@context": ["https://www.w3.org/2018/credentials/v1"], + issuanceDate: "2023-10-31T18:08:50.405Z", + expirationDate: "2024-01-29T18:08:50.405Z", + credentialSubject: { + id: "did:pkh:eip155:1:0xBDa44695a53DfEC8Fdb4b9c3087Ee1eDF91F5337", + hash: "v0.0.0:ObGjEGMMKOviip2BkGCUsiYK5yLNRleTxA85R1mFjy8=", + "@context": [ + { + hash: "https://schema.org/Text", + provider: "https://schema.org/Text", + }, + ], + provider: "ClearTextGithubOrg#zaratanDotWorld#1874062", + }, + }, + twitter: { + type: ["VerifiableCredential"], + proof: { + jws: "eyJhbGciOiJFZERTQSIsImNyaXQiOlsiYjY0Il0sImI2NCI6ZmFsc2V9..HGQwttSpGSl1HzR4aMxHzcQdfWybIJE66K6AOn4oQJ-7HSS_JnGNTXAuZApOoTyBH_e85HJvgaCPsNonfMKiDQ", + type: "Ed25519Signature2018", + created: "2023-10-31T18:08:39.803Z", + proofPurpose: "assertionMethod", + verificationMethod: + "did:key:z6MkghvGHLobLEdj1bgRLhS4LPGJAvbMA1tn2zcRyqmYU5LC#z6MkghvGHLobLEdj1bgRLhS4LPGJAvbMA1tn2zcRyqmYU5LC", + }, + issuer: + "did:key:z6MkghvGHLobLEdj1bgRLhS4LPGJAvbMA1tn2zcRyqmYU5LC", + "@context": ["https://www.w3.org/2018/credentials/v1"], + issuanceDate: "2023-10-31T18:08:39.802Z", + expirationDate: "2024-01-29T18:08:39.802Z", + credentialSubject: { + id: "did:pkh:eip155:1:0xBDa44695a53DfEC8Fdb4b9c3087Ee1eDF91F5337", + hash: "v0.0.0:nK1n2UBTUdGYLTE+UqtY/7dLZUkJluU5TjEC8vyu/cQ=", + "@context": [ + { + hash: "https://schema.org/Text", + provider: "https://schema.org/Text", + }, + ], + provider: "ClearTextTwitter#ZaratanDotWorld", + }, + }, + }, + description: + 'Mirror by Zaratan is a DAO platform for residential communities, being used successfully in a 9-person house, providing mechanisms for managing labor contributions, behavioral norms, and shared funds. Funding will go towards development, marketing, and legal costs for developing and distributing the tools to other communities.\n\nDAO platforms are an important but challenging vertical in the Web3 space. Seen as a type of "holy grail" for crypto, many projects have attempted to innovate in the space, and each provides valuable insights and lessons for others to learn from.\n\nMirror is a DAO platform specifically designed for residential communities wishing to operate in a decentralized fashion. It uses a Slack-based chat-bot interface to allow residents to engage in community governance and manage shared resources. The underlying technology involves innovative time-based mechanics, which can serve as a case study for other projects to learn from. So far, the mechanisms have been very effective, allowing a non-technical userbase to meaningfully engage in community governance.\n\nMirror was developed by game designers, economists, and crypto-economic engineers, and has been successfully deployed to a 9-person house in Los Angeles. The prototype implementation runs on a Web2 stack, with plans to target a Web3 backend on the horizon. The project was open-sourced under AGPL-3, with subscriptions to a managed service being used to support long-term development. This is a similar approach that projects such as Loomio, Ghost, and Proton have taken in the past.\n\nYou can read the Whitepaper here: https://bit.ly/mirror-whitepaper\n\nYou can learn more about Zaratan here: https://zaratan.world/\n\nTeam Members:\n\nDaniel Kronovet, project lead. Founder of Zaratan and developed the initial Mirror mechanisms; research engineer at Colony who helped developed the BudgetBox algorithm\n\nSeth Frey, research advisor. PhD Cognitive Science under Elinor Ostrom, helped develop the economic mechanisms\n\nJoseph DeSimone, research advisor. Game designer, helped develop the economic mechanisms', + lastUpdated: 0, + projectGithub: "zaratanDotWorld", + projectTwitter: "zaratanDotworld", + }, + recipient: "0xa2e6ac0fc55c5e16d00cf302aa54ba4423253d21", + }, + }, + }, + round: null, +}; diff --git a/packages/grant-explorer/src/features/projects/hooks/__tests__/useApplication.test.tsx b/packages/grant-explorer/src/features/projects/hooks/__tests__/useApplication.test.tsx new file mode 100644 index 0000000000..93bd9cb872 --- /dev/null +++ b/packages/grant-explorer/src/features/projects/hooks/__tests__/useApplication.test.tsx @@ -0,0 +1,32 @@ +import { describe, Mock } from "vitest"; +import { useApplication } from "../useApplication"; +import { renderWithContext } from "../../../../test-utils"; +import { createElement } from "react"; +import { applicationData } from "./data/application"; + +describe("useApplication", () => { + beforeEach(() => vi.spyOn(global, "fetch")); + beforeEach(() => + (global.fetch as Mock).mockImplementation(async () => { + return { + ok: true, + status: 200, + json: async () => ({ data: applicationData }), + }; + }) + ); + + it("fetch application data", async () => { + const variables = { chainId: 1, roundId: "1", id: "1" }; + renderWithContext( + createElement(() => { + const { data } = useApplication(variables); + if (data) { + // Expect the hook to return the data + expect(data).toEqual(applicationData.application); + } + return null; + }) + ); + }); +}); diff --git a/packages/grant-explorer/src/features/projects/hooks/useApplication.ts b/packages/grant-explorer/src/features/projects/hooks/useApplication.ts new file mode 100644 index 0000000000..90ef00e322 --- /dev/null +++ b/packages/grant-explorer/src/features/projects/hooks/useApplication.ts @@ -0,0 +1,154 @@ +import useSWR from "swr"; +import { + ApplicationStatus, + GrantApplicationFormAnswer, + Project, + ProjectMetadata, + Round, + RoundMetadata, +} from "data-layer"; +import { getConfig } from "common/src/config"; + +type Params = { + chainId?: number; + roundId?: string; + applicationId?: string; +}; + +const { + dataLayer: { gsIndexerEndpoint }, + allo: { version }, +} = getConfig(); + +const allo_v2_url = gsIndexerEndpoint + "/graphql"; + +const APPLICATION_QUERY = ` +query Application($chainId: Int!, $applicationId: String!, $roundId: String!) { + application(chainId: $chainId, id: $applicationId, roundId: $roundId) { + id + chainId + roundId + projectId + status + totalAmountDonatedInUsd + uniqueDonorsCount + round { + donationsStartTime + donationsEndTime + applicationsStartTime + applicationsEndTime + matchTokenAddress + roundMetadata + } + metadata + project { + tags + id + metadata + } + } +} +`; + +export type Application = { + id: string; + chainId: string; + roundId: string; + projectId: string; + status: ApplicationStatus; + totalAmountDonatedInUsd: string; + totalDonationsCount: string; + round: { + donationsStartTime: string; + donationsEndTime: string; + applicationsStartTime: string; + applicationsEndTime: string; + roundMetadata: RoundMetadata; + matchTokenAddress: string; + }; + project: { + id: string; + metadata: ProjectMetadata; + }; + metadata: { + application: { + recipient: string; + answers: GrantApplicationFormAnswer[]; + }; + }; +}; + +export function useApplication(params: Params) { + const shouldFetch = Object.values(params).every(Boolean); + return useSWR(shouldFetch ? ["applications", params] : null, async () => { + return request(allo_v2_url, { + query: APPLICATION_QUERY, + variables: params, + }) + .then((r) => r.data?.application) + .then((application) => { + /* Don't fetch v2 rounds when allo version is set to v1 */ + if ( + version === "allo-v1" && + application.project.tags.includes("allo-v2") + ) { + return; + } + return application; + }); + }); +} + +// These functions map the application data to fit the shape of the view +// Changing the view would require significant changes to the markup + cart storage +export function mapApplicationToProject( + application?: Application +): Project | undefined { + if (!application) return; + return { + grantApplicationId: application.id, + applicationIndex: Number(application.id), + projectRegistryId: application.projectId, + recipient: application.metadata.application.recipient, + projectMetadata: application.project.metadata, + status: application.status, + grantApplicationFormAnswers: application.metadata.application.answers ?? [], + }; +} + +export function mapApplicationToRound( + application?: Application +): Round | undefined { + if (!application) return; + return { + roundEndTime: new Date(application.round.donationsEndTime), + roundStartTime: new Date(application.round.donationsStartTime), + applicationsStartTime: new Date(application.round.applicationsStartTime), + applicationsEndTime: new Date(application.round.applicationsEndTime), + roundMetadata: application.round.roundMetadata, + token: application.round.matchTokenAddress, + // This is missing from the indexer + payoutStrategy: { + id: "id", + strategyName: "MERKLE", + }, + // These might not be used anywhere in the app + votingStrategy: "", + ownedBy: "", + }; +} + +async function request(url: string, body: unknown) { + return fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }).then(async (res) => { + if (!res.ok) { + throw new Error(`${res.status} ${res.statusText}`); + } + return await res.json(); + }); +} diff --git a/packages/grant-explorer/src/features/round/ViewProjectDetails.tsx b/packages/grant-explorer/src/features/round/ViewProjectDetails.tsx index 400f42b883..4066e19144 100644 --- a/packages/grant-explorer/src/features/round/ViewProjectDetails.tsx +++ b/packages/grant-explorer/src/features/round/ViewProjectDetails.tsx @@ -1,22 +1,22 @@ import { datadogLogs } from "@datadog/browser-logs"; import { - VerifiableCredential, PROVIDER_ID, + VerifiableCredential, } from "@gitcoinco/passport-sdk-types"; import { ShieldCheckIcon } from "@heroicons/react/24/solid"; -import { Client } from "allo-indexer-client"; -import { formatDateWithOrdinal, renderToHTML } from "common"; +import { formatDateWithOrdinal, renderToHTML, useParams } from "common"; +import { getConfig } from "common/src/config"; + import { formatDistanceToNowStrict } from "date-fns"; import React, { ComponentProps, ComponentPropsWithRef, + createElement, FunctionComponent, PropsWithChildren, - createElement, useMemo, useState, } from "react"; -import { useParams } from "react-router-dom"; import useSWR from "swr"; import { useEnsName } from "wagmi"; import DefaultLogoImage from "../../assets/default_logo.png"; @@ -31,15 +31,19 @@ import RoundEndedBanner from "../common/RoundEndedBanner"; import Breadcrumb, { BreadcrumbItem } from "../common/Breadcrumb"; import { isDirectRound, isInfiniteDate } from "../api/utils"; import { useCartStorage } from "../../store"; -import { getAddress } from "viem"; import { Box, Skeleton, SkeletonText, Tab, Tabs } from "@chakra-ui/react"; import { GrantList } from "./KarmaGrant/GrantList"; import { useGap } from "../api/gap"; +import { ShoppingCartIcon } from "@heroicons/react/24/outline"; +import { DataLayer, useDataLayer } from "data-layer"; import { DefaultLayout } from "../common/DefaultLayout"; import { truncate } from "../common/utils/truncate"; import tw from "tailwind-styled-components"; -import { ShoppingCartIcon } from "@heroicons/react/24/outline"; -import { DataLayer, useDataLayer } from "data-layer"; +import { + mapApplicationToProject, + mapApplicationToRound, + useApplication, +} from "../projects/hooks/useApplication"; const CalendarIcon = (props: React.SVGProps) => { return ( @@ -67,11 +71,19 @@ enum VerifiedCredentialState { PENDING, } -const boundFetch = fetch.bind(window); - export const IAM_SERVER = "did:key:z6MkghvGHLobLEdj1bgRLhS4LPGJAvbMA1tn2zcRyqmYU5LC"; +const { + allo: { version }, +} = getConfig(); + +const useProjectDetailsParams = useParams<{ + chainId: string; + roundId: string; + applicationId: string; +}>; + export default function ViewProjectDetails() { const [selectedTab, setSelectedTab] = useState(0); @@ -79,14 +91,16 @@ export default function ViewProjectDetails() { "====> Route: /round/:chainId/:roundId/:applicationId" ); datadogLogs.logger.info(`====> URL: ${window.location.href}`); - const { chainId, roundId, applicationId } = useParams(); + const { chainId, roundId, applicationId } = useProjectDetailsParams(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { round } = useRoundById(Number(chainId), roundId!); + const { data: application } = useApplication({ + chainId: Number(chainId as string), + roundId, + applicationId: applicationId?.split("-")[1], + }); - const projectToRender = round?.approvedProjects?.find( - (project) => project.grantApplicationId === applicationId - ); + const projectToRender = mapApplicationToProject(application); + const round = mapApplicationToRound(application); const { grants } = useGap(projectToRender?.projectRegistryId as string); @@ -96,10 +110,8 @@ export default function ViewProjectDetails() { (isInfiniteDate(round.roundEndTime) ? false : round && round.roundEndTime <= currentTime); - const isBeforeRoundEndDate = - round && - (isInfiniteDate(round.roundEndTime) || round.roundEndTime > currentTime); + const disableAddToCartButton = version === "allo-v2" || isAfterRoundEndDate; const { projects, add, remove } = useCartStorage(); const isAlreadyInCart = projects.some( @@ -110,10 +122,9 @@ export default function ViewProjectDetails() { const cartProject = projectToRender as CartProject; if (cartProject !== undefined) { - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - cartProject.roundId = roundId!; - /* eslint-disable-next-line @typescript-eslint/no-non-null-assertion */ - cartProject.chainId = Number(chainId!); + cartProject.roundId = roundId; + cartProject.chainId = Number(chainId); + cartProject.grantApplicationId = applicationId; } const breadCrumbs = [ @@ -192,9 +203,9 @@ export default function ViewProjectDetails() { {round && !isDirectRound(round) && ( { - remove(cartProject.grantApplicationId); + remove(applicationId); }} addToCart={() => { add(cartProject); @@ -470,44 +481,15 @@ function Sidebar(props: { ); } -// NOTE: Consider moving this -export function useRoundApprovedApplication( - chainId: number, - roundId: string, - projectId: string -) { - // use chain id and project id from url params - const client = new Client( - boundFetch, - process.env.REACT_APP_ALLO_API_URL ?? "", - chainId - ); - - return useSWR([roundId, "/projects"], async ([roundId]) => { - const applications = await client.getRoundApplications( - getAddress(roundId.toLowerCase()) - ); - - return applications.find( - (app) => app.projectId === projectId && app.status === "APPROVED" - ); - }); -} - export function ProjectStats() { - const { chainId, roundId, applicationId } = useParams(); - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const { round } = useRoundById(Number(chainId), roundId!); + const { chainId, roundId, applicationId } = useProjectDetailsParams(); + const { round } = useRoundById(Number(chainId), roundId); - const projectToRender = round?.approvedProjects?.find( - (project) => project.grantApplicationId === applicationId - ); - - const { data: application } = useRoundApprovedApplication( - Number(chainId), - roundId as string, - projectToRender?.projectRegistryId as string - ); + const { data: application } = useApplication({ + chainId: Number(chainId as string), + roundId, + applicationId: applicationId.split("-")[1], + }); const timeRemaining = round?.roundEndTime && !isInfiniteDate(round?.roundEndTime) @@ -525,11 +507,11 @@ export function ProjectStats() { > funding received in current round - + contributors diff --git a/packages/grant-explorer/src/features/round/__tests__/ViewProjectDetails.test.tsx b/packages/grant-explorer/src/features/round/__tests__/ViewProjectDetails.test.tsx index 566050c709..7e5a17ac1c 100644 --- a/packages/grant-explorer/src/features/round/__tests__/ViewProjectDetails.test.tsx +++ b/packages/grant-explorer/src/features/round/__tests__/ViewProjectDetails.test.tsx @@ -2,8 +2,6 @@ import { faker } from "@faker-js/faker"; import { fireEvent, screen, waitFor } from "@testing-library/react"; import { act } from "react-dom/test-utils"; import { - makeApprovedProjectData, - makeRoundData, renderComponentsBasedOnDeviceSize, renderWithContext, setWindowDimensions, @@ -11,10 +9,11 @@ import { import ViewProjectDetails from "../ViewProjectDetails"; import { truncate } from "../../common/utils/truncate"; import { formatDateWithOrdinal } from "common"; - -const chainId = faker.datatype.number(); -const roundId = faker.finance.ethereumAddress(); -const grantApplicationId = "0xdeadbeef-0xdeadbeef"; +import { + Application, + useApplication, +} from "../../projects/hooks/useApplication"; +import { beforeEach, expect, Mock } from "vitest"; vi.mock("../../common/Navbar"); vi.mock("../../common/Auth"); @@ -22,6 +21,18 @@ vi.mock("@rainbow-me/rainbowkit", () => ({ ConnectButton: vi.fn(), })); +vi.mock("common", async () => { + const actual = await vi.importActual("common"); + return { + ...actual, + useParams: vi.fn().mockImplementation(() => ({ + chainId: 1, + roundId: "0x0", + applicationId: "0xdeadbeef-0xdeadbeef", + })), + }; +}); + vi.mock("wagmi", async () => { const actual = await vi.importActual("wagmi"); return { @@ -33,6 +44,18 @@ vi.mock("wagmi", async () => { useAccount: vi.fn().mockReturnValue({ data: "mockedAccount" }), }; }); + +vi.mock("../../projects/hooks/useApplication", async () => { + const actual = await vi.importActual< + typeof import("../../projects/hooks/useApplication") + >("../../projects/hooks/useApplication"); + + return { + ...actual, + useApplication: vi.fn().mockReturnValue({ data: "" }), + }; +}); + vi.mock("react-router-dom", async () => { const actual = await vi.importActual( @@ -41,92 +64,156 @@ vi.mock("react-router-dom", async () => { return { ...actual, - useNavigate: () => vi.fn(), - useParams: () => ({ - chainId, - roundId, - applicationId: grantApplicationId, - }), + useNavigate: vi.fn(), + useParams: vi.fn().mockImplementation(() => ({ + chainId: 1, + roundId: "0x0", + applicationId: "0xdeadbeef-0xdeadbeef", + })), }; }); +const expectedProject: Application = { + chainId: "1", + id: faker.finance.ethereumAddress(), + metadata: { + application: { + answers: [ + { + answer: "never gonna give you up", + hidden: false, + question: "never gonna let you down", + questionId: 0, + type: "string", + }, + { + questionId: 1, + question: "this is a hidden question", + answer: "this will not show up", + hidden: true, + }, + { + questionId: 2, + question: "array of strings", + answer: ["first option", "second option"], + hidden: false, + }, + ], + recipient: faker.finance.ethereumAddress(), + }, + }, + project: { + id: faker.finance.ethereumAddress(), + metadata: { + createdAt: Date.now(), + title: "Project test", + description: "Best project in the world", + website: "test.com", + owners: [], + bannerImg: "banner!", + logoImg: "logo!", + projectTwitter: "twitter.com/project", + projectGithub: "github.com/project", + userGithub: "github.com/user", + }, + }, + projectId: faker.finance.ethereumAddress(), + round: { + applicationsEndTime: new Date().valueOf().toString(), + applicationsStartTime: new Date().valueOf().toString(), + donationsEndTime: new Date().valueOf().toString(), + donationsStartTime: new Date().valueOf().toString(), + matchTokenAddress: faker.finance.ethereumAddress(), + roundMetadata: { + name: "", + roundType: "public", + eligibility: { + description: "", + }, + programContractAddress: "", + }, + }, + roundId: faker.finance.ethereumAddress(), + status: "APPROVED", + // @ts-expect-error tests + totalAmountDonatedInUsd: 0, + // @ts-expect-error tests + totalDonationsCount: 0, +}; describe("", () => { - it("shows project name", async () => { - const expectedProject = makeApprovedProjectData({ grantApplicationId }); - const expectedProjectName = expectedProject.projectMetadata.title; - - const roundWithProjects = makeRoundData({ - id: roundId, - approvedProjects: [expectedProject], + beforeEach(() => { + (useApplication as Mock).mockReturnValue({ + data: expectedProject, }); + }); + + it("shows project name", async () => { renderWithContext(, { roundState: { - rounds: [roundWithProjects], + rounds: [], isLoading: false, }, }); - - expect(await screen.findByText(expectedProjectName)).toBeInTheDocument(); + expect( + await screen.findByText(expectedProject.project.metadata.title) + ).toBeInTheDocument(); }); describe("Show project details", () => { - const expectedProject = makeApprovedProjectData({ grantApplicationId }); - const expectedProjectWebsite = expectedProject.projectMetadata.website; - - const roundWithProjects = makeRoundData({ - id: roundId, - approvedProjects: [expectedProject], - }); - beforeEach(() => { vi.clearAllMocks(); renderWithContext(, { roundState: { - rounds: [roundWithProjects], + rounds: [], isLoading: false, }, }); }); it("shows project recipient", async () => { - const [{ recipient }] = roundWithProjects.approvedProjects ?? []; - expect(screen.getByText(truncate(recipient))).toBeInTheDocument(); + expect( + screen.getByText( + truncate(expectedProject.metadata.application.recipient) + ) + ).toBeInTheDocument(); }); it("shows project website", async () => { expect( - await screen.findByText(expectedProjectWebsite) + await screen.findByText(expectedProject.project.metadata.website) ).toBeInTheDocument(); }); it("shows project twitter", async () => { - const [{ projectMetadata }] = roundWithProjects.approvedProjects ?? []; expect( - screen.getByText(projectMetadata?.projectTwitter as string) + screen.getByText( + expectedProject.project.metadata.projectTwitter as string + ) ).toBeInTheDocument(); }); it("shows created at date", async () => { - const [{ projectMetadata }] = roundWithProjects.approvedProjects ?? []; expect( screen.getByText( - formatDateWithOrdinal(new Date(projectMetadata?.createdAt ?? 0)), + formatDateWithOrdinal( + new Date(expectedProject.project.metadata.createdAt as number) + ), { exact: false } ) ).toBeInTheDocument(); }); it("shows project user github", async () => { - const [{ projectMetadata }] = roundWithProjects.approvedProjects ?? []; expect( - screen.getByText(projectMetadata?.userGithub as string) + screen.getByText(expectedProject.project.metadata.userGithub as string) ).toBeInTheDocument(); }); it("shows project github", async () => { - const [{ projectMetadata }] = roundWithProjects.approvedProjects ?? []; expect( - screen.getByText(projectMetadata?.projectGithub as string) + screen.getByText( + expectedProject.project.metadata.projectGithub as string + ) ).toBeInTheDocument(); }); @@ -136,40 +223,22 @@ describe("", () => { }); it("shows project description", async () => { - const expectedProject = makeApprovedProjectData({ grantApplicationId }); - const expectedProjectDescription = - expectedProject.projectMetadata.description; - - const roundWithProjects = makeRoundData({ - id: roundId, - approvedProjects: [expectedProject], - }); renderWithContext(, { roundState: { - rounds: [roundWithProjects], + rounds: [], isLoading: false, }, }); expect( - await screen.findByText(expectedProjectDescription) + await screen.findByText(expectedProject.project.metadata.description) ).toBeInTheDocument(); }); it("shows project banner", async () => { - const expectedProjectBannerImg = "bannersrc"; - const expectedProject = makeApprovedProjectData( - { grantApplicationId }, - { bannerImg: expectedProjectBannerImg } - ); - - const roundWithProjects = makeRoundData({ - id: roundId, - approvedProjects: [expectedProject], - }); renderWithContext(, { roundState: { - rounds: [roundWithProjects], + rounds: [], isLoading: false, }, }); @@ -178,23 +247,13 @@ describe("", () => { name: /project banner/i, }) as HTMLImageElement; - expect(bannerImg.src).toContain(expectedProjectBannerImg); + expect(bannerImg.src).toContain(expectedProject.project.metadata.bannerImg); }); it("shows project logo", async () => { - const expectedProjectLogoImg = "logosrc"; - const expectedProject = makeApprovedProjectData( - { grantApplicationId }, - { logoImg: expectedProjectLogoImg } - ); - - const roundWithProjects = makeRoundData({ - id: roundId, - approvedProjects: [expectedProject], - }); renderWithContext(, { roundState: { - rounds: [roundWithProjects], + rounds: [], isLoading: false, }, }); @@ -203,81 +262,44 @@ describe("", () => { name: /project logo/i, }) as HTMLImageElement; - expect(logoImg.src).toContain(expectedProjectLogoImg); + expect(logoImg.src).toContain(expectedProject.project.metadata.logoImg); }); it("shows project application form answers", async () => { - const expectedProject = makeApprovedProjectData({ - grantApplicationId, - grantApplicationFormAnswers: [ - { - questionId: 0, - question: "What is love?", - answer: "baby don't hurt me", - hidden: false, - }, - { - questionId: 1, - question: "this is a hidden question", - answer: "this will not show up", - hidden: true, - }, - { - questionId: 2, - question: "array of strings", - answer: ["first option", "second option"], - hidden: false, - }, - ], - }); - - const roundWithProjects = makeRoundData({ - id: roundId, - approvedProjects: [expectedProject], - }); - renderWithContext(, { roundState: { - rounds: [roundWithProjects], + rounds: [], isLoading: false, }, }); expect(screen.getByText("Additional Information")).toBeInTheDocument(); - expect(screen.getByText("What is love?")).toBeInTheDocument(); - expect(screen.getByText("baby don't hurt me")).toBeInTheDocument(); + expect(screen.getByText("never gonna give you up")).toBeInTheDocument(); + expect(screen.getByText("never gonna let you down")).toBeInTheDocument(); expect( screen.queryByText("this is a hidden question") ).not.toBeInTheDocument(); - expect(screen.queryByText("this will not show up")).not.toBeInTheDocument(); expect(screen.getByText("array of strings")).toBeInTheDocument(); expect(screen.getByText("first option, second option")).toBeInTheDocument(); }); it("hides project application form answers when they're empty", async () => { - const expectedProject = makeApprovedProjectData({ - grantApplicationId, - grantApplicationFormAnswers: [ - { - questionId: 1, - question: "this is a hidden question", - answer: "this will not show up", - hidden: true, + (useApplication as Mock).mockImplementation(() => ({ + data: { + ...expectedProject, + metadata: { + application: { + answers: [], + }, }, - ], - }); - - const roundWithProjects = makeRoundData({ - id: roundId, - approvedProjects: [expectedProject], - }); - + }, + })); renderWithContext(, { roundState: { - rounds: [roundWithProjects], + rounds: [], isLoading: false, }, }); @@ -294,16 +316,15 @@ describe("", () => { }); describe("voting cart", () => { - const expectedProject = makeApprovedProjectData({ grantApplicationId }); - const roundWithProjects = makeRoundData({ - id: roundId, - approvedProjects: [expectedProject], + beforeEach(() => { + (useApplication as Mock).mockReturnValue({ + data: expectedProject, + }); }); - it("shows an add-to-cart button", async () => { renderWithContext(, { roundState: { - rounds: [roundWithProjects], + rounds: [], isLoading: false, }, }); @@ -348,7 +369,7 @@ describe("voting cart", () => { it("shows a remove-from-cart button replacing add-to-cart when add-to-cart is clicked", () => { renderWithContext(, { roundState: { - rounds: [roundWithProjects], + rounds: [], isLoading: false, }, }); @@ -364,7 +385,7 @@ describe("voting cart", () => { it("shows a add-to-cart button replacing a remove-from-cart button when remove-from-cart is clicked", async () => { renderWithContext(, { roundState: { - rounds: [roundWithProjects], + rounds: [], isLoading: false, }, });