diff --git a/.gitignore b/.gitignore index 4b1eea7b13..5f9918fdaa 100644 --- a/.gitignore +++ b/.gitignore @@ -9,4 +9,5 @@ node_modules dist/ coverage/ .eslintcache - +bun.lockb +tmp diff --git a/docs/DEV.md b/docs/DEV.md index 674a6c0425..b815248c9d 100644 --- a/docs/DEV.md +++ b/docs/DEV.md @@ -148,6 +148,20 @@ component route Find more information about routing [here](https://reactrouter.com/docs/en/v6). +## Running E2E tests using Synpress + +Synpress is an E2E testing framework for testing dApps. It works by setting up metamask before every run. + +### Running Synpress + +1. Put `TEST_PRIVATE_KEY` in `.env.local` in the respective directory (e.g. `packages/round-manager`) +2. Start the dev server `pnpm start` +3. Download playwright with `pnpm exec playwright install` +4. Run tests with `pnpm synpress:test` + +NOTE: some tests require you to be part of a testing program and have some gas in your wallet. Please use a private key that has some gas on Fantom Testnet and Optimism Mainnet, and is part of the "GS Optimism Program 10 Round" Program on Optimism Mainnet. + + ## Submitting a PR Please always submit draft PRs at first, and make sure they pass the following conditions before you mark them as Ready for review. diff --git a/package.json b/package.json index 587ab98845..680cd6a428 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "devDependencies": { "@commitlint/cli": "^17.7.1", "@commitlint/config-conventional": "^17.7.0", - "turbo": "^1.11.0" + "turbo": "^1.12.2" }, "dependencies": { "prettier": "^3.0.3" @@ -44,5 +44,6 @@ "overrides": { "webpack": "^5" } - } + }, + "workspaces": ["packages/*"] } diff --git a/packages/builder/.eslintrc.js b/packages/builder/.eslintrc.js index cb3b8bbf5b..29811168e0 100644 --- a/packages/builder/.eslintrc.js +++ b/packages/builder/.eslintrc.js @@ -2,6 +2,7 @@ module.exports = { env: { browser: true, es2021: true, + jest: true, }, parser: "@typescript-eslint/parser", extends: ["airbnb", "airbnb-typescript", "prettier"], @@ -35,5 +36,5 @@ module.exports = { "eol-last": ["error", "always"], "react/require-default-props": "off", }, - ignorePatterns: ["node_modules/"], + ignorePatterns: ["node_modules/", "./fixtures.ts"], }; diff --git a/packages/builder/craco.config.js b/packages/builder/craco.config.js index 7406ac9dec..a930a7aa0a 100644 --- a/packages/builder/craco.config.js +++ b/packages/builder/craco.config.js @@ -29,6 +29,7 @@ module.exports = { require.resolve("jest-transform-stub"), }, setupFilesAfterEnv: ["./src/setupTests.ts"], + testPathIgnorePatterns: ["/e2e/"], }), }, webpack: { diff --git a/packages/builder/e2e/homepage.test.ts b/packages/builder/e2e/homepage.test.ts new file mode 100644 index 0000000000..036dd1bdbd --- /dev/null +++ b/packages/builder/e2e/homepage.test.ts @@ -0,0 +1,14 @@ +import * as metamask from "@synthetixio/synpress/commands/metamask"; +import { test } from "../fixtures"; + +test.beforeEach(async ({ page }) => { + // baseUrl is set in playwright.config.ts + await page.goto("/"); +}); + +test("main page loads and wallet connects", async ({ page }) => { + await page.getByRole("navigation").getByTestId("rk-connect-button").click(); + await page.getByText("Metamask").click(); + await metamask.acceptAccess(); + await page.getByText("My Projects").waitFor(); +}); diff --git a/packages/builder/fixtures.ts b/packages/builder/fixtures.ts new file mode 100644 index 0000000000..21382c0e3e --- /dev/null +++ b/packages/builder/fixtures.ts @@ -0,0 +1,69 @@ +import { type BrowserContext, chromium, test as base } from "@playwright/test"; +import { initialSetup } from "@synthetixio/synpress/commands/metamask"; +import { setExpectInstance } from "@synthetixio/synpress/commands/playwright"; +import { resetState } from "@synthetixio/synpress/commands/synpress"; +import { prepareMetamask } from "@synthetixio/synpress/helpers"; +import { config } from "dotenv"; + +export const test = base.extend<{ + context: BrowserContext; +}>({ + // eslint-disable-next-line no-empty-pattern + context: async ({}, use) => { + config({ + path: ["./.env.local", "./.env", "./.env.test"], + }); + + // required for synpress as it shares same expect instance as playwright + // eslint-disable-next-line @typescript-eslint/no-use-before-define + await setExpectInstance(expect); + + // download metamask + const metamaskPath = await prepareMetamask( + process.env.METAMASK_VERSION || "10.25.0" + ); + + // prepare browser args + const browserArgs = [ + `--disable-extensions-except=${metamaskPath}`, + `--load-extension=${metamaskPath}`, + "--remote-debugging-port=9222", + ]; + + if (process.env.CI) { + browserArgs.push("--disable-gpu"); + } + + if (process.env.HEADLESS_MODE) { + browserArgs.push("--headless=new"); + } + + // launch browser + const context = await chromium.launchPersistentContext("", { + headless: false, + args: browserArgs, + }); + + // wait for metamask + await context.pages()[0].waitForTimeout(3000); + + // setup metamask + await initialSetup(chromium, { + secretWordsOrPrivateKey: + process.env.TEST_PRIVATE_KEY ?? + "test test test test test test test test test test test junk", + network: "optimism", + password: "Tester@1234", + enableAdvancedSettings: true, + }); + + await use(context); + + await context.close(); + + await resetState(); + }, +}); + +// eslint-disable-next-line prefer-destructuring +export const expect = test.expect; diff --git a/packages/builder/package.json b/packages/builder/package.json index 3cf7a7353e..5739594614 100644 --- a/packages/builder/package.json +++ b/packages/builder/package.json @@ -19,8 +19,14 @@ "@heroicons/react": "^2.0.11", "@lagunovsky/redux-react-router": "^2.2.0", "@pinata/sdk": "^1.1.26", + "@playwright/test": "^1.41.1", "@rainbow-me/rainbowkit": "^0.12.18", "@redux-devtools/extension": "^3.2.3", + "@rsbuild/core": "^0.4.1", + "@rsbuild/plugin-react": "^0.3.11", + "@rsbuild/plugin-svgr": "^0.3.11", + "@rsdoctor/rspack-plugin": "^0.1.1", + "@synthetixio/synpress": "3.7.2-beta.10", "@tailwindcss/line-clamp": "^0.4.2", "@tailwindcss/typography": "^0.5.9", "@tanstack/query-core": "4.22.0", @@ -54,6 +60,8 @@ "history": "^5.3.0", "https-browserify": "^1.0.0", "jest": "^27.0", + "jszip": "^3.10.1", + "localforage": "^1.10.0", "os-browserify": "^0.3.0", "pnpm": "7", "postcss": "^8.4.14", @@ -93,7 +101,8 @@ "lint": "eslint --ext .js,.jsx,.ts,.tsx ./src --cache", "lint:ci": "CI=false pnpm lint", "lint:fix": "eslint ./src --fix --cache", - "typecheck": "tsc --noEmit" + "typecheck": "tsc --noEmit", + "synpress:test": "playwright test --project=chromium" }, "eslintConfig": { "extends": [ @@ -125,6 +134,7 @@ "@types/react-gtm-module": "^2.0.1", "@typescript-eslint/eslint-plugin": "^5.23.0", "@typescript-eslint/parser": "^5.23.0", + "dotenv": "^16.4.1", "eslint": "^8.2.0", "eslint-config-airbnb": "^19.0.4", "eslint-config-airbnb-typescript": "^17.0.0", diff --git a/packages/builder/playwright.config.js b/packages/builder/playwright.config.js new file mode 100644 index 0000000000..b876896c4b --- /dev/null +++ b/packages/builder/playwright.config.js @@ -0,0 +1,36 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 60 * 1000, + expect: { + timeout: 5000, + }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: "html", + use: { + actionTimeout: 0, + baseURL: "http://localhost:3000", + trace: "on-first-retry", + headless: false, + }, + // start local web server before tests + webServer: [ + { + command: "pnpm start", + url: "http://localhost:3000", + timeout: 5000, + reuseExistingServer: true, + }, + ], + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + outputDir: "test-results", +}); diff --git a/packages/builder/rsbuild.config.ts b/packages/builder/rsbuild.config.ts new file mode 100644 index 0000000000..3871060251 --- /dev/null +++ b/packages/builder/rsbuild.config.ts @@ -0,0 +1,41 @@ +import { defineConfig, loadEnv } from "@rsbuild/core"; +import { pluginReact } from "@rsbuild/plugin-react"; +import { pluginSvgr } from "@rsbuild/plugin-svgr"; +import { RsdoctorRspackPlugin } from "@rsdoctor/rspack-plugin"; +const path = require("path"); + +const { publicVars } = loadEnv({ prefixes: ["REACT_APP_"] }); + +export default defineConfig({ + plugins: [pluginReact(), pluginSvgr()], + html: { + template: "./public/index.html", + }, + server: { + port: Number(process.env.PORT) || 3000, + }, + source: { + define: publicVars, + alias: { + localforage: path.resolve( + __dirname, + "./node_modules/localforage/src/localforage.js" + ), + jszip: path.resolve(__dirname, "./node_modules/jszip/lib/index.js"), + "readable-stream": require.resolve("readable-stream"), + "csv-stringify": "csv-stringify/browser/esm", + }, + }, + tools: { + rspack(config, { appendPlugins }) { + // Only register the plugin when RSDOCTOR is true, as the plugin will increase the build time. + if (process.env.RSDOCTOR) { + appendPlugins( + new RsdoctorRspackPlugin({ + // plugin options + }) + ); + } + }, + }, +}); \ No newline at end of file diff --git a/packages/builder/src/__tests__/components/application/Form.test.tsx b/packages/builder/src/__tests__/components/application/Form.test.tsx index 1bb80eb5cb..36a4eeca49 100644 --- a/packages/builder/src/__tests__/components/application/Form.test.tsx +++ b/packages/builder/src/__tests__/components/application/Form.test.tsx @@ -1,11 +1,12 @@ import "@testing-library/jest-dom"; import { act, fireEvent, screen, waitFor } from "@testing-library/react"; import { Store } from "redux"; +import { RoundApplicationMetadata } from "data-layer"; import * as projects from "../../../actions/projects"; import { web3ChainIDLoaded } from "../../../actions/web3"; import Form from "../../../components/application/Form"; import setupStore from "../../../store"; -import { Metadata, Round, RoundApplicationMetadata } from "../../../types"; +import { Metadata, Round } from "../../../types"; import { addressFrom, renderWrapped } from "../../../utils/test_utils"; import * as utils from "../../../utils/utils"; diff --git a/packages/builder/src/__tests__/components/grants/List.test.tsx b/packages/builder/src/__tests__/components/grants/List.test.tsx index 1fdcfe415b..4db4cf6b6c 100644 --- a/packages/builder/src/__tests__/components/grants/List.test.tsx +++ b/packages/builder/src/__tests__/components/grants/List.test.tsx @@ -342,7 +342,7 @@ describe("", () => { renderWrapped(, store); - expect(screen.getByText("Apply")).toBeInTheDocument(); + expect(screen.getByText("Apply to Grant Round")).toBeInTheDocument(); }); test("should not be visible if user already applied", async () => { diff --git a/packages/builder/src/__tests__/components/providers/Github.test.tsx b/packages/builder/src/__tests__/components/providers/Github.test.tsx index 062bcdd4ac..d9e5beec6a 100644 --- a/packages/builder/src/__tests__/components/providers/Github.test.tsx +++ b/packages/builder/src/__tests__/components/providers/Github.test.tsx @@ -56,11 +56,6 @@ describe("", () => { store ); }); - - // console.log(screen.debug()); - // TO BE ENABLE - // should not be a problem with REACT_APP_PASSPORT_IAM_URL properly set - // expect(screen.queryByText("Verified")).toBeInTheDocument(); }); test("should not show the badge if the verified account is different from the current one in the form", async () => { diff --git a/packages/builder/src/__tests__/components/rounds/Show.test.tsx b/packages/builder/src/__tests__/components/rounds/Show.test.tsx index c6a1c788db..c0a5d3e967 100644 --- a/packages/builder/src/__tests__/components/rounds/Show.test.tsx +++ b/packages/builder/src/__tests__/components/rounds/Show.test.tsx @@ -11,6 +11,7 @@ import { addressFrom, buildProjectMetadata, buildRound, + now, renderWrapped, } from "../../../utils/test_utils"; @@ -42,19 +43,19 @@ describe("", () => { }); const pastRound = buildRound({ - address: addressFrom(1), - applicationsStartTime: 0, - applicationsEndTime: 0, - roundStartTime: 0, - roundEndTime: 0, + address: addressFrom(2), + applicationsStartTime: now - 7200, + applicationsEndTime: now - 3600, + roundStartTime: now - 3600, + roundEndTime: now - 600, }); const futureRound = buildRound({ - address: addressFrom(1), - applicationsStartTime: Date.now() / 1000 + 60 * 30, - applicationsEndTime: Date.now() / 1000 + 60 * 60, - roundStartTime: Date.now() / 1000 + 60 * 60, - roundEndTime: Date.now() / 1000 + 60 * 120, + address: addressFrom(3), + applicationsStartTime: now + 3600, + applicationsEndTime: now + 7200, + roundStartTime: now + 7200, + roundEndTime: now + 12000, }); store.dispatch(web3ChainIDLoaded(5)); diff --git a/packages/builder/src/__tests__/reducers/projects.test.ts b/packages/builder/src/__tests__/reducers/projects.test.ts index 8698d6960f..2f1a1a6239 100644 --- a/packages/builder/src/__tests__/reducers/projects.test.ts +++ b/packages/builder/src/__tests__/reducers/projects.test.ts @@ -1,10 +1,10 @@ import "@testing-library/jest-dom"; +import { ApplicationStatus, RoundVisibilityType } from "data-layer"; import { - AppStatus, - projectsReducer, ProjectsState, Status, initialState as initialProjectsState, + projectsReducer, } from "../../reducers/projects"; import { addressFrom } from "../../utils/test_utils"; @@ -21,18 +21,64 @@ describe("projects reducer", () => { applications: { "1": [ { - roundID: addressFrom(1), - status: "PENDING" as AppStatus, - inReview: false, + id: "1", chainId: 1, + roundId: addressFrom(1), + status: "PENDING" as ApplicationStatus, + metadataCid: "0x1", + metadata: {}, + inReview: false, + round: { + applicationsStartTime: "0", + applicationsEndTime: "0", + donationsStartTime: "0", + donationsEndTime: "0", + roundMetadata: { + name: "Round 1", + roundType: "public" as RoundVisibilityType, + eligibility: { + description: "Eligibility description", + requirements: [{ requirement: "Requirement 1" }], + }, + programContractAddress: "0x1", + support: { + info: "https://support.com", + type: "WEBSITE", + }, + }, + name: "Round 1", + }, }, ], "2": [ { - roundID: addressFrom(2), - status: "PENDING" as AppStatus, - inReview: false, + id: "1", chainId: 1, + roundId: addressFrom(2), + status: "PENDING" as ApplicationStatus, + metadataCid: "0x1", + metadata: {}, + inReview: false, + round: { + applicationsStartTime: "0", + applicationsEndTime: "0", + donationsStartTime: "0", + donationsEndTime: "0", + roundMetadata: { + name: "Round 2", + roundType: "public" as RoundVisibilityType, + eligibility: { + description: "Eligibility description", + requirements: [{ requirement: "Requirement 1" }], + }, + programContractAddress: "0x1", + support: { + info: "https://support.com", + type: "WEBSITE", + }, + }, + name: "Round 2", + }, }, ], }, @@ -46,10 +92,33 @@ describe("projects reducer", () => { expect(newState.applications).toEqual({ "1": [ { - roundID: addressFrom(1), - status: "PENDING", + roundId: addressFrom(1), + status: "PENDING" as ApplicationStatus, inReview: false, chainId: 1, + id: "1", + metadataCid: "0x1", + metadata: {}, + round: { + applicationsStartTime: "0", + applicationsEndTime: "0", + donationsStartTime: "0", + donationsEndTime: "0", + roundMetadata: { + name: "Round 1", + roundType: "public" as RoundVisibilityType, + eligibility: { + description: "Eligibility description", + requirements: [{ requirement: "Requirement 1" }], + }, + programContractAddress: "0x1", + support: { + info: "https://support.com", + type: "WEBSITE", + }, + }, + name: "Round 1", + }, }, ], }); @@ -61,10 +130,33 @@ describe("projects reducer", () => { applications: { "1": [ { - roundID: addressFrom(1), - status: "PENDING" as AppStatus, + roundId: addressFrom(1), + status: "PENDING" as ApplicationStatus, inReview: false, chainId: 1, + id: "1", + metadataCid: "0x1", + metadata: {}, + round: { + applicationsStartTime: "0", + applicationsEndTime: "0", + donationsStartTime: "0", + donationsEndTime: "0", + roundMetadata: { + name: "Round 1", + roundType: "public" as RoundVisibilityType, + eligibility: { + description: "Eligibility description", + requirements: [{ requirement: "Requirement 1" }], + }, + programContractAddress: "0x1", + support: { + info: "https://support.com", + type: "WEBSITE", + }, + }, + name: "Round 1", + }, }, ], }, @@ -75,10 +167,33 @@ describe("projects reducer", () => { projectID: "2", applications: [ { - roundID: addressFrom(2), - status: "APPROVED", + roundId: addressFrom(2), + status: "APPROVED" as ApplicationStatus, inReview: false, chainId: 1, + id: "2", + metadataCid: "0x2", + metadata: {}, + round: { + applicationsStartTime: "0", + applicationsEndTime: "0", + donationsStartTime: "0", + donationsEndTime: "0", + roundMetadata: { + name: "Round 2", + roundType: "public" as RoundVisibilityType, + eligibility: { + description: "Eligibility description", + requirements: [{ requirement: "Requirement 1" }], + }, + programContractAddress: "0x1", + support: { + info: "https://support.com", + type: "WEBSITE", + }, + }, + name: "Round 2", + }, }, ], }); @@ -86,18 +201,64 @@ describe("projects reducer", () => { expect(newState.applications).toEqual({ "1": [ { - roundID: addressFrom(1), - status: "PENDING", + roundId: addressFrom(1), + status: "PENDING" as ApplicationStatus, inReview: false, chainId: 1, + id: "1", + metadataCid: "0x1", + metadata: {}, + round: { + applicationsStartTime: "0", + applicationsEndTime: "0", + donationsStartTime: "0", + donationsEndTime: "0", + roundMetadata: { + name: "Round 1", + roundType: "public" as RoundVisibilityType, + eligibility: { + description: "Eligibility description", + requirements: [{ requirement: "Requirement 1" }], + }, + programContractAddress: "0x1", + support: { + info: "https://support.com", + type: "WEBSITE", + }, + }, + name: "Round 1", + }, }, ], "2": [ { - roundID: addressFrom(2), - status: "APPROVED", + roundId: addressFrom(2), + status: "APPROVED" as ApplicationStatus, inReview: false, chainId: 1, + id: "2", + metadataCid: "0x2", + metadata: {}, + round: { + applicationsStartTime: "0", + applicationsEndTime: "0", + donationsStartTime: "0", + donationsEndTime: "0", + roundMetadata: { + name: "Round 2", + roundType: "public" as RoundVisibilityType, + eligibility: { + description: "Eligibility description", + requirements: [{ requirement: "Requirement 1" }], + }, + programContractAddress: "0x1", + support: { + info: "https://support.com", + type: "WEBSITE", + }, + }, + name: "Round 2", + }, }, ], }); @@ -119,44 +280,182 @@ describe("projects reducer", () => { applications: { "1": [ { - roundID: "0x1", - status: "PENDING" as AppStatus, + roundId: "0x1", + status: "PENDING" as ApplicationStatus, inReview: false, chainId: 1, + id: "1", + metadataCid: "0x1", + metadata: {}, + round: { + applicationsStartTime: "0", + applicationsEndTime: "0", + donationsStartTime: "0", + donationsEndTime: "0", + roundMetadata: { + name: "Round 1", + roundType: "public" as RoundVisibilityType, + eligibility: { + description: "Eligibility description", + requirements: [{ requirement: "Requirement 1" }], + }, + programContractAddress: "0x1", + support: { + info: "https://support.com", + type: "WEBSITE", + }, + }, + name: "Round 1", + }, }, ], "2": [ { - roundID: "0x1", - status: "PENDING" as AppStatus, + roundId: "0x1", + status: "PENDING" as ApplicationStatus, inReview: false, chainId: 1, + id: "1", + metadataCid: "0x1", + metadata: {}, + round: { + applicationsStartTime: "0", + applicationsEndTime: "0", + donationsStartTime: "0", + donationsEndTime: "0", + roundMetadata: { + name: "Round 1", + roundType: "public" as RoundVisibilityType, + eligibility: { + description: "Eligibility description", + requirements: [{ requirement: "Requirement 1" }], + }, + programContractAddress: "0x1", + support: { + info: "https://support.com", + type: "WEBSITE", + }, + }, + name: "Round 1", + }, }, { - roundID: "0x2", - status: "PENDING" as AppStatus, + roundId: "0x2", + status: "PENDING" as ApplicationStatus, inReview: false, chainId: 1, + id: "2", + metadataCid: "0x2", + metadata: {}, + round: { + applicationsStartTime: "0", + applicationsEndTime: "0", + donationsStartTime: "0", + donationsEndTime: "0", + roundMetadata: { + name: "Round 2", + roundType: "public" as RoundVisibilityType, + eligibility: { + description: "Eligibility description", + requirements: [{ requirement: "Requirement 1" }], + }, + programContractAddress: "0x1", + support: { + info: "https://support.com", + type: "WEBSITE", + }, + }, + name: "Round 2", + }, }, { - roundID: "0x3", - status: "PENDING" as AppStatus, + roundId: "0x3", + status: "PENDING" as ApplicationStatus, inReview: false, chainId: 1, + id: "3", + metadataCid: "0x3", + metadata: {}, + round: { + applicationsStartTime: "0", + applicationsEndTime: "0", + donationsStartTime: "0", + donationsEndTime: "0", + roundMetadata: { + name: "Round 3", + roundType: "public" as RoundVisibilityType, + eligibility: { + description: "Eligibility description", + requirements: [{ requirement: "Requirement 1" }], + }, + programContractAddress: "0x1", + support: { + info: "https://support.com", + type: "WEBSITE", + }, + }, + name: "Round 3", + }, }, { - roundID: "0x4", - status: "PENDING" as AppStatus, + roundId: "0x4", + status: "PENDING" as ApplicationStatus, inReview: false, chainId: 1, + id: "4", + metadataCid: "0x4", + metadata: {}, + round: { + applicationsStartTime: "0", + applicationsEndTime: "0", + donationsStartTime: "0", + donationsEndTime: "0", + roundMetadata: { + name: "Round 4", + roundType: "public" as RoundVisibilityType, + eligibility: { + description: "Eligibility description", + requirements: [{ requirement: "Requirement 1" }], + }, + programContractAddress: "0x1", + support: { + info: "https://support.com", + type: "WEBSITE", + }, + }, + name: "Round 4", + }, }, ], "3": [ { - roundID: "0x3", - status: "PENDING" as AppStatus, + roundId: "0x3", + status: "PENDING" as ApplicationStatus, inReview: false, chainId: 1, + id: "1", + metadataCid: "0x1", + metadata: {}, + round: { + applicationsStartTime: "0", + applicationsEndTime: "0", + donationsStartTime: "0", + donationsEndTime: "0", + roundMetadata: { + name: "Round 3", + roundType: "public" as RoundVisibilityType, + eligibility: { + description: "Eligibility description", + requirements: [{ requirement: "Requirement 1" }], + }, + programContractAddress: "0x1", + support: { + info: "https://support.com", + type: "WEBSITE", + }, + }, + name: "Round 3", + }, }, ], }, @@ -169,49 +468,34 @@ describe("projects reducer", () => { status: "APPROVED", }); - expect(newState.applications).toEqual({ - "1": [ - { - roundID: "0x1", - status: "PENDING" as AppStatus, - inReview: false, - chainId: 1, - }, - ], - "2": [ - { - roundID: "0x1", - status: "PENDING" as AppStatus, - inReview: false, - chainId: 1, - }, - { - roundID: "0x2", - status: "PENDING" as AppStatus, - inReview: false, - chainId: 1, - }, - { - roundID: "0x3", - status: "APPROVED" as AppStatus, - inReview: false, - chainId: 1, - }, - { - roundID: "0x4", - status: "PENDING" as AppStatus, - inReview: false, - chainId: 1, - }, - ], - "3": [ - { - roundID: "0x3", - status: "PENDING" as AppStatus, - inReview: false, - chainId: 1, + expect(newState.applications!["2"][2]).toEqual({ + roundId: "0x3", + status: "APPROVED" as ApplicationStatus, + inReview: false, + chainId: 1, + id: "3", + metadataCid: "0x3", + metadata: {}, + round: { + applicationsStartTime: "0", + applicationsEndTime: "0", + donationsStartTime: "0", + donationsEndTime: "0", + roundMetadata: { + name: "Round 3", + roundType: "public" as RoundVisibilityType, + eligibility: { + description: "Eligibility description", + requirements: [{ requirement: "Requirement 1" }], + }, + programContractAddress: "0x1", + support: { + info: "https://support.com", + type: "WEBSITE", + }, }, - ], + name: "Round 3", + }, }); }); diff --git a/packages/builder/src/__tests__/utils/RoundApplicationBuilder.test.ts b/packages/builder/src/__tests__/utils/RoundApplicationBuilder.test.ts index d660cb301d..44d1a854bb 100644 --- a/packages/builder/src/__tests__/utils/RoundApplicationBuilder.test.ts +++ b/packages/builder/src/__tests__/utils/RoundApplicationBuilder.test.ts @@ -1,5 +1,6 @@ +import { RoundApplicationMetadata } from "data-layer"; import RoundApplicationBuilder from "../../utils/RoundApplicationBuilder"; -import { RoundApplicationMetadata, Project } from "../../types"; +import { Project } from "../../types"; import Lit from "../../services/lit"; jest.mock("../../services/lit"); diff --git a/packages/builder/src/actions/projects.ts b/packages/builder/src/actions/projects.ts index 828c8ac0fa..376f4ebd60 100644 --- a/packages/builder/src/actions/projects.ts +++ b/packages/builder/src/actions/projects.ts @@ -1,22 +1,23 @@ import { datadogRum } from "@datadog/browser-rum"; import { Client as AlloClient } from "allo-indexer-client"; -import { - ChainId, - ROUND_PAYOUT_MERKLE, - RoundPayoutType, - convertStatusToText, -} from "common"; +import { ChainId, ROUND_PAYOUT_MERKLE, RoundPayoutType } from "common"; import { getConfig } from "common/src/config"; -import { DataLayer, ProjectEventsMap } from "data-layer"; +import { + ApplicationStatus, + DataLayer, + ProjectApplication, + ProjectEventsMap, +} from "data-layer"; import { utils } from "ethers"; import { Dispatch } from "redux"; import { addressesByChainID } from "../contracts/deployments"; import { global } from "../global"; import { RootState } from "../reducers"; -import { AppStatus, Application, ProjectStats } from "../reducers/projects"; +import { ProjectStats } from "../reducers/projects"; import { getEnabledChainsAndProviders } from "../utils/chains"; import { graphqlFetch } from "../utils/graphql"; import generateUniqueRoundApplicationID from "../utils/roundApplication"; +import { getV1HashedProjectId } from "../utils/utils"; import { fetchGrantData } from "./grantsMetadata"; import { addAlert } from "./ui"; @@ -25,7 +26,7 @@ export const PROJECTS_LOADING = "PROJECTS_LOADING"; export type SubgraphApplication = { round: { id: string }; - status: AppStatus; + status: ApplicationStatus; inReview: boolean; chainId: ChainId; metaPtr?: { @@ -74,7 +75,7 @@ export const PROJECT_APPLICATIONS_LOADED = "PROJECT_APPLICATIONS_LOADED"; interface ProjectApplicationsLoadedAction { type: typeof PROJECT_APPLICATIONS_LOADED; projectID: string; - applications: Application[]; + applications: ProjectApplication[]; } export const PROJECT_APPLICATION_UPDATED = "PROJECT_APPLICATION_UPDATED"; @@ -83,7 +84,7 @@ interface ProjectApplicationUpdatedAction { type: typeof PROJECT_APPLICATION_UPDATED; projectID: string; roundID: string; - status: AppStatus; + status: ApplicationStatus; } export const PROJECT_APPLICATIONS_ERROR = "PROJECT_APPLICATIONS_ERROR"; @@ -333,102 +334,64 @@ export const fetchProjectApplicationInRound = async ( * This loads project applications for a given project and network and dispatches the * appropriate actions to the store. * - * @param projectID - * @param projectChainId + * @param projectId + * @param dataLayer * * @returns All applications for a given project */ export const fetchProjectApplications = - (projectID: string, projectChainId: ChainId) => + (projectId: string, chainId: ChainId, dataLayer: DataLayer) => async (dispatch: Dispatch) => { + const config = getConfig(); + dispatch({ type: PROJECT_APPLICATIONS_LOADING, - projectID, + projectID: projectId, }); - const { web3Provider } = global; - - if (!web3Provider?.chains) { - return; - } + try { + const { web3Provider } = global; + if (!web3Provider?.chains) { + return; + } - const apps = await Promise.all( - web3Provider.chains.map(async (chain: { id: number }) => { - try { - const addresses = addressesByChainID(projectChainId); - const projectApplicationID = generateUniqueRoundApplicationID( - projectChainId, - projectID, - addresses.projectRegistry! - ); - - const response: any = await graphqlFetch( - `query roundApplications($projectApplicationID: String) { - roundApplications(where: { project: $projectApplicationID }) { - status - round { - id - } - inReview - metaPtr { - pointer - protocol - } - } - } - `, - chain.id, - { - projectApplicationID, - } - ); + const id = + config.allo.version === "allo-v1" + ? getV1HashedProjectId( + `${chainId}:${ + addressesByChainID(chainId).projectRegistry + }:${projectId}` + ) + : projectId; + + const chainIds = web3Provider.chains.map((chain) => chain.id); + const applications = await dataLayer.getApplicationsByProjectId({ + projectId: id, + chainIds, + }); - if (response.errors) { - throw response.errors; - } + applications?.forEach(async (application, index) => { + const programName = await dataLayer.getProgramName({ + projectId: application.round.roundMetadata.programContractAddress, + }); - const applications: Application[] = - response.data.roundApplications.map( - (application: SubgraphApplication) => ({ - status: convertStatusToText(application.status), - roundID: application.round.id, - inReview: application.inReview, - chainId: chain.id, - metaPtr: application.metaPtr, - }) - ); - - if (applications.length === 0) { - return []; - } + applications[index].round.name = programName || ""; + }); - dispatch({ - type: PROJECT_APPLICATIONS_LOADED, - projectID, - applications, - }); - - return applications; - } catch (error: any) { - console.error( - "failed fetchProjectApplications for", - "Project Id", - projectID, - "in Chain Id", - chain.id, - error - ); - datadogRum.addError(error, { projectID }); - - return []; - } - }) - ); - dispatch({ - type: PROJECT_APPLICATIONS_LOADED, - projectID, - applications: apps.flat(), - }); + dispatch({ + type: PROJECT_APPLICATIONS_LOADED, + projectID: projectId, + applications, + }); + } catch (error: any) { + console.error( + "failed fetchProjectApplications for", + "Project Id", + projectId, + error + ); + datadogRum.addError(error, { projectId }); + } }; /** diff --git a/packages/builder/src/actions/roundApplication.ts b/packages/builder/src/actions/roundApplication.ts index a36b37b926..9523a308e2 100644 --- a/packages/builder/src/actions/roundApplication.ts +++ b/packages/builder/src/actions/roundApplication.ts @@ -1,21 +1,21 @@ import { datadogLogs } from "@datadog/browser-logs"; import { datadogRum } from "@datadog/browser-rum"; -import { ChainId, isJestRunning } from "common"; +import { Allo, AnyJson, ChainId, isJestRunning } from "common"; import { ethers } from "ethers"; import { Dispatch } from "redux"; import { getConfig } from "common/src/config"; +import { RoundApplicationAnswers } from "data-layer/dist/roundApplication.types"; +import { Hex } from "viem"; import RoundABI from "../contracts/abis/RoundImplementation.json"; import { global } from "../global"; import { RootState } from "../reducers"; import { Status } from "../reducers/roundApplication"; import PinataClient from "../services/pinata"; import { Project, RoundApplication, SignedRoundApplication } from "../types"; -import { RoundApplicationAnswers } from "../types/roundApplication"; import { objectToDeterministicJSON } from "../utils/deterministicJSON"; import generateUniqueRoundApplicationID from "../utils/roundApplication"; import RoundApplicationBuilder from "../utils/RoundApplicationBuilder"; import { getProjectURIComponents, metadataToProject } from "../utils/utils"; -import { fetchProjectApplications } from "./projects"; import { graphqlFetch } from "../utils/graphql"; const LitJsSdk = isJestRunning() ? null : require("gitcoin-lit-js-sdk"); @@ -145,7 +145,7 @@ export function chainIdToChainName(chainId: number): string { } export const submitApplication = - (roundAddress: string, formInputs: RoundApplicationAnswers) => + (roundAddress: string, formInputs: RoundApplicationAnswers, allo: Allo) => async (dispatch: Dispatch, getState: () => RootState) => { const state = getState(); const roundState = state.rounds[roundAddress]; @@ -179,7 +179,7 @@ export const submitApplication = const projectQuestion = roundApplicationMetadata.applicationSchema.questions.find( - (q) => q.type === "project" + (q: { type: string }) => q.type === "project" ); if (!projectQuestion) { @@ -287,63 +287,73 @@ export const submitApplication = application, }; - const pinataClient = new PinataClient(getConfig()); + const projectUniqueID = generateUniqueRoundApplicationID( + Number(projectChainId), + projectNumber, + projectRegistryAddress + ) as Hex; + dispatch({ type: ROUND_APPLICATION_LOADING, roundAddress, status: Status.UploadingMetadata, }); - let resp; - try { - resp = await pinataClient.pinJSON(signedApplication); - } catch (e) { - dispatchAndLogApplicationError( - dispatch, - roundAddress, - "error uploading round application metadata", - Status.UploadingMetadata - ); - return; - } - const metaPtr = { - protocol: "1", - pointer: resp.IpfsHash, - }; - dispatch({ - type: ROUND_APPLICATION_LOADING, - roundAddress, - status: Status.SendingTx, + const result = allo.applyToRound({ + projectId: projectUniqueID, + roundId: roundAddress as Hex, + metadata: signedApplication as unknown as AnyJson, }); - const contract = new ethers.Contract(roundAddress, RoundABI, signer); - - const projectUniqueID = generateUniqueRoundApplicationID( - Number(projectChainId), - projectNumber, - projectRegistryAddress - ); - - try { - const tx = await contract.applyToRound(projectUniqueID, metaPtr); - // FIXME: check return value of tx.wait() ?? - await tx.wait(); - dispatch({ - type: ROUND_APPLICATION_LOADED, - roundAddress, - projectId: projectID, - }); - dispatch( - fetchProjectApplications(projectID, Number(projectChainId)) - ); - } catch (e: any) { - dispatchAndLogApplicationError( - dispatch, - roundAddress, - "error calling applyToRound", - Status.SendingTx - ); - } + await result + .on("ipfs", (res) => { + if (res.type === "success") { + console.log("IPFS CID", res.value); + dispatch({ + type: ROUND_APPLICATION_LOADING, + roundAddress, + status: Status.SendingTx, + }); + } else { + console.error("IPFS Error", res.error); + datadogRum.addError(res.error); + datadogLogs.logger.error("ipfs: error uploading metadata"); + dispatchAndLogApplicationError( + dispatch, + roundAddress, + "error uploading round application metadata", + Status.UploadingMetadata + ); + } + }) + .on("transaction", (res) => { + // Note: Not handled by UI + if (res.type === "success") { + console.log("Transaction", res.value); + } else { + console.error("Transaction Error", res.error); + datadogRum.addError(res.error); + datadogLogs.logger.warn("transaction error"); + } + }) + .on("transactionStatus", async (res) => { + if (res.type === "success") { + dispatch({ + type: ROUND_APPLICATION_LOADED, + roundAddress, + projectId: projectID, + }); + } else { + dispatchAndLogApplicationError( + dispatch, + roundAddress, + "error calling applyToRound", + Status.SendingTx + ); + console.log("Transaction Status Error", res.error); + } + }) + .execute(); }; export const checkRoundApplications = diff --git a/packages/builder/src/actions/rounds.ts b/packages/builder/src/actions/rounds.ts index ccb5a93cef..11c28436f4 100644 --- a/packages/builder/src/actions/rounds.ts +++ b/packages/builder/src/actions/rounds.ts @@ -1,19 +1,12 @@ -import { Dispatch } from "redux"; -// import { RootState } from "../reducers"; import { datadogLogs } from "@datadog/browser-logs"; import { datadogRum } from "@datadog/browser-rum"; -import { getConfig } from "common/src/config"; -import { BigNumber, ethers } from "ethers"; -import ProgramABI from "../contracts/abis/ProgramImplementation.json"; -import RoundABI from "../contracts/abis/RoundImplementation.json"; -import { RootState } from "../reducers"; +import { DataLayer } from "data-layer"; +import { ethers } from "ethers"; +import { Dispatch } from "redux"; import { PayoutStrategy, Status } from "../reducers/rounds"; -import PinataClient from "../services/pinata"; -import { MetaPtr, ProgramMetadata, Round, RoundMetadata } from "../types"; -import { RoundApplicationMetadata } from "../types/roundApplication"; +import { Round } from "../types"; import { graphqlFetch } from "../utils/graphql"; import { parseRoundApplicationMetadata } from "../utils/roundApplication"; -import { getProviderByChainId } from "../utils/utils"; export const ROUNDS_LOADING_ROUND = "ROUNDS_LOADING_ROUND"; interface RoundsLoadingRoundAction { @@ -65,265 +58,40 @@ const loadingError = (address: string, error: string): RoundsActions => ({ export const unloadRounds = () => roundsUnloaded(); -const isTooBig = (bigNumber: BigNumber) => - bigNumber.eq(ethers.constants.MaxUint256); - export const loadRound = - (address: string, roundChainId?: number) => - async (dispatch: Dispatch, getState: () => RootState) => { + (roundId: string, dataLayer: DataLayer, chainId: number) => + async (dispatch: Dispatch) => { try { // address validation - ethers.utils.getAddress(address); + ethers.utils.getAddress(roundId); } catch (e) { datadogRum.addError(e); - datadogLogs.logger.warn(`invalid address or address checksum ${address}`); - dispatch(loadingError(address, "invalid address or address checksum")); + datadogLogs.logger.warn(`invalid address or address checksum ${roundId}`); + dispatch(loadingError(roundId, "invalid address or address checksum")); console.error(e); return; } - const state = getState(); - const { chainID: stateChainID } = state.web3; - const chainId = roundChainId || stateChainID; - const appProvider = getProviderByChainId(chainId!); - if (!appProvider) return; - - const contract = new ethers.Contract(address, RoundABI, appProvider); - const pinataClient = new PinataClient(getConfig()); - - dispatch({ - type: ROUNDS_LOADING_ROUND, - address, - status: Status.LoadingApplicationsStartTime, + const v2Round = await dataLayer.getRoundByIdAndChainId({ + roundId, + chainId, }); - let applicationsStartTime; - try { - const ast: BigNumber = await contract.applicationsStartTime(); - applicationsStartTime = ast.toNumber(); - } catch (e) { - datadogRum.addError(e); - datadogLogs.logger.error( - `contract: error loading application start time ${contract.address}` - ); - dispatch(loadingError(address, "error loading application start time")); - console.error(e); + if (!v2Round || !v2Round.roundMetadata || !v2Round.applicationMetadata) { + dispatch(loadingError(roundId, "round not found")); return; } - dispatch({ - type: ROUNDS_LOADING_ROUND, - address, - status: Status.LoadingApplicationsEndTime, - }); - - let applicationsEndTime; - try { - const aet: BigNumber = await contract.applicationsEndTime(); - // fixes infinite applicationsEndTime representation - applicationsEndTime = isTooBig(aet) - ? Number.MAX_SAFE_INTEGER - : aet.toNumber(); - } catch (e) { - datadogRum.addError(e); - datadogLogs.logger.error( - `contract: error loading application end time ${contract.address}` - ); - dispatch(loadingError(address, "error loading application end time")); - console.error(e); - return; - } - - dispatch({ - type: ROUNDS_LOADING_ROUND, - address, - status: Status.LoadingRoundStartTime, - }); - - let roundStartTime; - try { - const rst: BigNumber = await contract.roundStartTime(); - roundStartTime = rst.toNumber(); - } catch (e) { - datadogRum.addError(e); - datadogLogs.logger.error( - `contract: error loading round start time ${contract.address}` - ); - dispatch(loadingError(address, "error loading round start time")); - console.error(e); - return; - } - - dispatch({ - type: ROUNDS_LOADING_ROUND, - address, - status: Status.LoadingRoundEndTime, - }); - - let roundEndTime; - try { - const ret: BigNumber = await contract.roundEndTime(); - // fixes infinite roundEndTime representation - roundEndTime = isTooBig(ret) ? Number.MAX_SAFE_INTEGER : ret.toNumber(); - } catch (e) { - datadogRum.addError(e); - datadogLogs.logger.error( - `contract: error loading round end time ${contract.address}` - ); - dispatch(loadingError(address, "error loading round end time")); - console.error(e); - return; - } - - dispatch({ - type: ROUNDS_LOADING_ROUND, - address, - status: Status.LoadingToken, - }); - - let token; - try { - token = await contract.token(); - } catch (e) { - datadogRum.addError(e); - datadogLogs.logger.error( - `contract: error loading round token ${contract.address}` - ); - dispatch(loadingError(address, "error loading round token")); - console.error(e); - return; - } + const applicationMetadata = parseRoundApplicationMetadata( + v2Round.applicationMetadata + ); - dispatch({ - type: ROUNDS_LOADING_ROUND, - address, - status: Status.LoadingRoundMetaPtr, - }); - - let roundMetaPtr: MetaPtr; - try { - roundMetaPtr = await contract.roundMetaPtr(); - } catch (e) { - datadogRum.addError(e); - datadogLogs.logger.error( - `contract: error loading round metaPtr ${contract.address}` - ); - dispatch(loadingError(address, "error loading round metaPtr")); - console.error(e); - return; - } - - dispatch({ - type: ROUNDS_LOADING_ROUND, - address, - status: Status.LoadingRoundMetadata, - }); - - let roundMetadata: RoundMetadata; - try { - const resp = await pinataClient.fetchText(roundMetaPtr.pointer); - roundMetadata = JSON.parse(resp); - } catch (e) { - datadogRum.addError(e); - datadogLogs.logger.error( - `contract: error loading round metadata ${contract.address}` - ); - dispatch(loadingError(address, "error loading round metadata")); - console.error(e); - return; - } - - dispatch({ - type: ROUNDS_LOADING_ROUND, - address, - status: Status.LoadingApplicationMetaPtr, - }); - - let applicationMetaPtr: MetaPtr; - try { - applicationMetaPtr = await contract.applicationMetaPtr(); - } catch (e) { - datadogRum.addError(e); - datadogLogs.logger.error( - `contract: error loading application metaPtr ${contract.address}` - ); - dispatch(loadingError(address, "error loading application metaPtr")); - console.error(e); - return; - } - - dispatch({ - type: ROUNDS_LOADING_ROUND, - address, - status: Status.LoadingApplicationMetadata, - }); - - let applicationMetadata: RoundApplicationMetadata; - try { - const resp = await pinataClient.fetchText(applicationMetaPtr.pointer); - applicationMetadata = parseRoundApplicationMetadata(JSON.parse(resp)); - } catch (e) { - datadogRum.addError(e); - datadogLogs.logger.error("ipfs: error loading application metadata"); - dispatch(loadingError(address, "error loading application metadata")); - console.error(e); - return; - } - - let programName = ""; - - if (roundMetadata.programContractAddress !== undefined) { - const programContract = new ethers.Contract( - roundMetadata.programContractAddress, - ProgramABI, - appProvider - ); - - dispatch({ - type: ROUNDS_LOADING_ROUND, - address, - status: Status.LoadingProgramMetaPtr, - }); - - let programMetaPtr: MetaPtr; - try { - programMetaPtr = await programContract.metaPtr(); - } catch (e) { - datadogRum.addError(e); - datadogLogs.logger.error( - `contract: error loading program metaPtr ${programContract.address}` - ); - dispatch(loadingError(address, "error loading program metaPtr")); - console.error(e); - return; - } - - dispatch({ - type: ROUNDS_LOADING_ROUND, - address, - status: Status.LoadingProgramMetadata, - }); - - let programMetadata: ProgramMetadata; - try { - const resp = await pinataClient.fetchText(programMetaPtr.pointer); - programMetadata = JSON.parse(resp); - programName = programMetadata.name; - } catch (e) { - datadogRum.addError(e); - datadogLogs.logger.error("ipfs: error loading program metadata"); - dispatch(loadingError(address, "error loading program metadata")); - console.error(e); - return; - } - } - - dispatch({ - type: ROUNDS_LOADING_ROUND, - address, - status: Status.LoadingRoundPayoutStrategy, - }); + const programName = + (await dataLayer.getProgramName({ + projectId: v2Round.roundMetadata.programContractAddress, + })) || ""; + // TODO: FETCH FROM INDEXER let roundPayoutStrategy: PayoutStrategy; try { const resp = await graphqlFetch( @@ -341,7 +109,7 @@ export const loadRound = } `, chainId!, - { roundId: address.toLowerCase() } + { roundId: roundId.toLowerCase() } ); roundPayoutStrategy = resp.data.rounds[0].payoutStrategy ? resp.data.rounds[0].payoutStrategy.strategyName @@ -349,31 +117,32 @@ export const loadRound = } catch (e) { datadogRum.addError(e); datadogLogs.logger.error("sg: error loading round payoutStrategy"); - dispatch(loadingError(address, "error loading round payoutStrategy")); + dispatch(loadingError(roundId, "error loading round payoutStrategy")); console.error(e); return; } const round = { - address, - applicationsStartTime, - applicationsEndTime, - roundStartTime, - roundEndTime, - token, + address: roundId, + applicationsStartTime: + Date.parse(`${v2Round.applicationsStartTime}Z`) / 1000, + applicationsEndTime: Date.parse(`${v2Round.applicationsEndTime}Z`) / 1000, + roundStartTime: Date.parse(`${v2Round.donationsStartTime}Z`) / 1000, + roundEndTime: Date.parse(`${v2Round.donationsEndTime}Z`) / 1000, + token: v2Round.matchTokenAddress, roundMetaPtr: { - protocol: BigNumber.from(roundMetaPtr.protocol).toString(), - pointer: roundMetaPtr.pointer, + protocol: "1", + pointer: v2Round.roundMetadataCid, }, - roundMetadata, + roundMetadata: v2Round.roundMetadata, applicationMetaPtr: { - protocol: BigNumber.from(applicationMetaPtr.protocol).toString(), - pointer: applicationMetaPtr.pointer, + protocol: "1", + pointer: v2Round.applicationMetadataCid, }, applicationMetadata, programName, payoutStrategy: roundPayoutStrategy, }; - dispatch(roundLoaded(address, round)); + dispatch(roundLoaded(roundId, round)); }; diff --git a/packages/builder/src/components/application/AboutProject.tsx b/packages/builder/src/components/application/AboutProject.tsx index 9afdb241de..177c59f320 100644 --- a/packages/builder/src/components/application/AboutProject.tsx +++ b/packages/builder/src/components/application/AboutProject.tsx @@ -1,14 +1,17 @@ -import { useEnsName } from "wagmi"; import { GlobeAltIcon } from "@heroicons/react/24/solid"; import { ChainId } from "common"; -import { Metadata, RoundApplicationQuestion } from "../../types"; -import { RoundApplicationAnswers } from "../../types/roundApplication"; +import { + RoundApplicationAnswers, + RoundApplicationQuestion, +} from "data-layer/dist/roundApplication.types"; +import { useEnsName } from "wagmi"; +import { GithubLogo, TwitterLogo } from "../../assets"; import useValidateCredential from "../../hooks/useValidateCredential"; -import { getPayoutIcon } from "../../utils/wallet"; -import Calendar from "../icons/Calendar"; import colors from "../../styles/colors"; -import { GithubLogo, TwitterLogo } from "../../assets"; +import { Metadata } from "../../types"; +import { getPayoutIcon } from "../../utils/wallet"; import GreenVerifiedBadge from "../badges/GreenVerifiedBadge"; +import Calendar from "../icons/Calendar"; import { DetailSummary } from "./DetailSummary"; export function AboutProject(props: { diff --git a/packages/builder/src/components/application/Form.tsx b/packages/builder/src/components/application/Form.tsx index ecb8ed2f7e..3c1e11b16d 100644 --- a/packages/builder/src/components/application/Form.tsx +++ b/packages/builder/src/components/application/Form.tsx @@ -5,6 +5,10 @@ import { ExclamationTriangleIcon, InformationCircleIcon, } from "@heroicons/react/24/solid"; +import { + RoundApplicationAnswers, + RoundApplicationMetadata, +} from "data-layer/dist/roundApplication.types"; import { Fragment, useEffect, useState } from "react"; import { shallowEqual, useDispatch, useSelector } from "react-redux"; import { Link } from "react-router-dom"; @@ -22,10 +26,6 @@ import { ProjectOption, Round, } from "../../types"; -import { - RoundApplicationAnswers, - RoundApplicationMetadata, -} from "../../types/roundApplication"; import { ROUND_PAYOUT_DIRECT, getProjectURIComponents, @@ -359,7 +359,7 @@ export default function Form({ }`} >
e.preventDefault()}> - {schema.questions.map((input) => { + {schema.questions.map((input: any) => { if ( needsProject && input.type !== "project" && @@ -619,7 +619,7 @@ export default function Form({ } name={`${input.id}`} value={answers[input.id] as string} - options={input.options.map((o) => ({ + options={input.options.map((o: any) => ({ id: o, title: o, }))} diff --git a/packages/builder/src/components/application/FullPreview.tsx b/packages/builder/src/components/application/FullPreview.tsx index 5ab2a6b904..b2329d1e15 100644 --- a/packages/builder/src/components/application/FullPreview.tsx +++ b/packages/builder/src/components/application/FullPreview.tsx @@ -1,9 +1,12 @@ -import { useEffect } from "react"; import { EyeIcon } from "@heroicons/react/24/solid"; import { ChainId, renderToHTML } from "common"; -import { Metadata, RoundApplicationQuestion } from "../../types"; -import { RoundApplicationAnswers } from "../../types/roundApplication"; +import { + RoundApplicationAnswers, + RoundApplicationQuestion, +} from "data-layer/dist/roundApplication.types"; +import { useEffect } from "react"; import { DefaultProjectBanner, DefaultProjectLogo } from "../../assets"; +import { Metadata } from "../../types"; import Button, { ButtonVariants } from "../base/Button"; import { AboutProject } from "./AboutProject"; import { ProjectTitle } from "./ProjectTitle"; diff --git a/packages/builder/src/components/base/__tests__/formValidation.test.ts b/packages/builder/src/components/base/__tests__/formValidation.test.ts index 0e36f487c9..d0a045ce1b 100644 --- a/packages/builder/src/components/base/__tests__/formValidation.test.ts +++ b/packages/builder/src/components/base/__tests__/formValidation.test.ts @@ -1,5 +1,5 @@ import { ValidationError } from "yup"; -import { RoundApplicationQuestion } from "../../../types"; +import { RoundApplicationQuestion } from "data-layer"; import { validateApplication, validateProjectForm } from "../formValidation"; const validInputs = { diff --git a/packages/builder/src/components/base/formValidation.ts b/packages/builder/src/components/base/formValidation.ts index c9cf3b464d..95c62291fb 100644 --- a/packages/builder/src/components/base/formValidation.ts +++ b/packages/builder/src/components/base/formValidation.ts @@ -1,9 +1,9 @@ -import { array, object, string, number } from "yup"; -import { FormInputs } from "../../types"; import { RoundApplicationAnswers, RoundApplicationQuestion, -} from "../../types/roundApplication"; +} from "data-layer/dist/roundApplication.types"; +import { array, number, object, string } from "yup"; +import { FormInputs } from "../../types"; const urlRegex = /^(?:https?:\/\/)?(?:www\.)?[A-Za-z0-9]+\.[A-Za-z]{2,}(?:\/.*)?$/; diff --git a/packages/builder/src/components/grants/About.tsx b/packages/builder/src/components/grants/About.tsx index 0558399e88..fddc7dd71a 100644 --- a/packages/builder/src/components/grants/About.tsx +++ b/packages/builder/src/components/grants/About.tsx @@ -1,10 +1,8 @@ import { Box } from "@chakra-ui/react"; import { renderToHTML } from "common"; -import { useSelector } from "react-redux"; -import { useParams } from "react-router-dom"; +import { ProjectApplication } from "data-layer"; import { GithubLogo, TwitterLogo } from "../../assets"; import useValidateCredential from "../../hooks/useValidateCredential"; -import { RootState } from "../../reducers"; import colors from "../../styles/colors"; import { ApplicationCardType, @@ -13,37 +11,25 @@ import { Project, } from "../../types"; import { formatDateFromMs } from "../../utils/components"; +import GreenVerifiedBadge from "../badges/GreenVerifiedBadge"; import Calendar from "../icons/Calendar"; import LinkIcon from "../icons/LinkIcon"; import ApplicationCard from "./ApplicationCard"; -import GreenVerifiedBadge from "../badges/GreenVerifiedBadge"; export default function About({ project, + applications, showApplications, createdAt, updatedAt, }: { project?: Metadata | FormInputs | Project; + applications: ProjectApplication[]; showApplications: boolean; createdAt: number; updatedAt: number; }) { - const params = useParams(); - const props = useSelector((state: RootState) => { - const { chainId } = params; - - const applications = state.projects.applications[params.id!] || []; - - return { - chainId, - projectID: params.id!, - applications, - }; - }); - - const canShowApplications = - props.applications.length !== 0 && showApplications; + const canShowApplications = applications.length !== 0 && showApplications; const { isValid: validTwitterCredential } = useValidateCredential( project?.credentials?.twitter, @@ -61,8 +47,8 @@ export default function About({ My Applications - {props.applications.map((application, index) => { - const roundID = application?.roundID; + {applications.map((application, index) => { + const roundID = application?.roundId; const cardData: ApplicationCardType = { application, roundID, diff --git a/packages/builder/src/components/grants/ApplicationCard.tsx b/packages/builder/src/components/grants/ApplicationCard.tsx index dbf2ab5a92..9b5dac4633 100644 --- a/packages/builder/src/components/grants/ApplicationCard.tsx +++ b/packages/builder/src/components/grants/ApplicationCard.tsx @@ -1,29 +1,34 @@ import { Badge, Box, Button, Image, Spinner } from "@chakra-ui/react"; -import { useEffect, useState } from "react"; +import { ApplicationStatus, useDataLayer } from "data-layer"; +import { useEffect } from "react"; import { useDispatch, useSelector } from "react-redux"; import { Link } from "react-router-dom"; import { loadRound } from "../../actions/rounds"; import { RootState } from "../../reducers"; -import { AppStatus } from "../../reducers/projects"; +import { PayoutStrategy } from "../../reducers/rounds"; import { roundApplicationViewPath } from "../../routes"; -import { Round, RoundSupport, ApplicationCardType } from "../../types"; +import { ApplicationCardType, RoundSupport } from "../../types"; import { formatDateFromSecs, isInfinite } from "../../utils/components"; -import { getNetworkIcon, networkPrettyName } from "../../utils/wallet"; -import { PayoutStrategy } from "../../reducers/rounds"; import { ROUND_PAYOUT_DIRECT } from "../../utils/utils"; +import { getNetworkIcon, networkPrettyName } from "../../utils/wallet"; export default function ApplicationCard({ applicationData, }: { applicationData: ApplicationCardType; }) { - const [roundData, setRoundData] = useState(); + // const [roundData, setRoundData] = useState(); + const dataLayer = useDataLayer(); const dispatch = useDispatch(); + const props = useSelector((state: RootState) => { const roundState = state.rounds[applicationData.roundID]; const round = roundState ? roundState.round : undefined; + const support: RoundSupport | undefined = round?.roundMetadata?.support; + const payoutStrategy = round?.payoutStrategy; + const applicationChainName = networkPrettyName( Number(applicationData.chainId) ); @@ -31,38 +36,36 @@ export default function ApplicationCard({ Number(applicationData.chainId) ); - const isDirectRound = round && round.payoutStrategy === ROUND_PAYOUT_DIRECT; + const isDirectRound = payoutStrategy === ROUND_PAYOUT_DIRECT; return { round, isDirectRound, support, + payoutStrategy, applicationChainName, applicationChainIconUri, }; }); - const renderApplicationDate = () => - roundData && ( - <> - {formatDateFromSecs(roundData.applicationsStartTime)} -{" "} - {!isInfinite(roundData.applicationsEndTime) - ? formatDateFromSecs(roundData.applicationsEndTime) - : "No End Date"} - - ); - useEffect(() => { if (applicationData.roundID !== undefined) { - dispatch(loadRound(applicationData.roundID, applicationData.chainId)); + dispatch( + loadRound(applicationData.roundID, dataLayer, applicationData.chainId) + ); } }, [dispatch, applicationData.roundID]); - useEffect(() => { - if (props.round) { - setRoundData(props.round); - } - }, [props.round]); + const renderApplicationDate = () => + props.round && ( + <> + {formatDateFromSecs(props.round?.applicationsStartTime!)} -{" "} + {!isInfinite(Number(props.round?.applicationsEndTime!)) && + props.round?.applicationsEndTime + ? formatDateFromSecs(props.round?.applicationsEndTime!) + : "No End Date"} + + ); const renderRoundBadge = () => { let colorScheme: @@ -72,7 +75,7 @@ export default function ApplicationCard({ } | undefined; - switch (roundData?.payoutStrategy as PayoutStrategy) { + switch (props.payoutStrategy as PayoutStrategy) { case "MERKLE": colorScheme = { bg: "#E6FFF9", @@ -90,7 +93,7 @@ export default function ApplicationCard({ break; } - const roundPayoutStrategy = roundData?.payoutStrategy; + const roundPayoutStrategy = props.payoutStrategy; return ( @@ -123,7 +126,7 @@ export default function ApplicationCard({ text: string; } | undefined; - switch (applicationData.application.status as AppStatus) { + switch (applicationData.application.status as ApplicationStatus) { case "APPROVED": colorScheme = { bg: "#E6FFF9", @@ -154,7 +157,6 @@ export default function ApplicationCard({ } const applicationStatus = applicationData.application.status; - const isDirectRound = props.round?.payoutStrategy === ROUND_PAYOUT_DIRECT; const applicationInReview = applicationData.application.inReview; return ( @@ -166,12 +168,12 @@ export default function ApplicationCard({ textTransform="inherit" > {applicationStatus === "PENDING" && - isDirectRound && + props.isDirectRound && !applicationInReview ? ( Received ) : null} - {(applicationStatus === "PENDING" && !isDirectRound) || - (isDirectRound && applicationInReview) ? ( + {(applicationStatus === "PENDING" && !props.isDirectRound) || + (props.isDirectRound && applicationInReview) ? ( In Review ) : null} {applicationStatus === "REJECTED" ? ( @@ -188,6 +190,10 @@ export default function ApplicationCard({ applicationData.application.inReview || applicationData.application.status === "APPROVED"; + if (!props.round?.roundMetadata) { + return null; + } + return ( - {props.round?.programName} + {props.round?.roundMetadata.name}
@@ -211,7 +217,7 @@ export default function ApplicationCard({
{props.round?.roundMetadata.name}
- {roundData ? {renderApplicationDate()} : } + {props.round ? {renderApplicationDate()} : }
{renderRoundBadge()} {renderApplicationBadge()} @@ -229,7 +235,7 @@ export default function ApplicationCard({ }`} rel="noreferrer" > - Contact the {props.round?.programName} support team. + Contact the {props.round?.roundMetadata.name} support team.

)} @@ -237,7 +243,7 @@ export default function ApplicationCard({ to={roundApplicationViewPath( applicationData.chainId.toString(), applicationData.roundID, - applicationData.application.metaPtr?.pointer || "" + applicationData.application.metadataCid || "" )} > + )}
diff --git a/packages/common/src/config.ts b/packages/common/src/config.ts index 424ef3339d..e34f48bbab 100644 --- a/packages/common/src/config.ts +++ b/packages/common/src/config.ts @@ -40,8 +40,50 @@ export type Config = { }; }; +type LocalStorageConfigOverrides = Record; + let config: Config | null = null; +function getLocalStorageConfigOverrides(): LocalStorageConfigOverrides { + if (typeof window === "undefined") { + return {}; + } + + const configOverrides = + window.localStorage.getItem("configOverrides") || "{}"; + return JSON.parse(configOverrides); +} + +export function setLocalStorageConfigOverride(key: string, value: string) { + if (typeof window === "undefined") { + throw new Error("window is not defined"); + } + + const configOverrides = getLocalStorageConfigOverrides(); + configOverrides[key] = value; + window.localStorage.setItem( + "configOverrides", + JSON.stringify(configOverrides) + ); +} + +function overrideConfigFromLocalStorage(config: Config): Config { + const configOverrides = getLocalStorageConfigOverrides(); + + const alloVersion = z + .enum(["allo-v1", "allo-v2"]) + .catch(() => config.allo.version) + .parse(configOverrides["allo-version"]); + + return { + ...config, + allo: { + ...config.allo, + version: alloVersion, + }, + }; +} + export function getConfig(): Config { if (config !== null) { return config; @@ -158,5 +200,9 @@ export function getConfig(): Config { }, }; + if (config.appEnv === "development") { + config = overrideConfigFromLocalStorage(config); + } + return config; } 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/common/src/payoutTokens.ts b/packages/common/src/payoutTokens.ts index a2b6836c1d..15767c2e55 100644 --- a/packages/common/src/payoutTokens.ts +++ b/packages/common/src/payoutTokens.ts @@ -29,6 +29,7 @@ export const TokenNamesAndLogos = { LUSD: "/logos/lusd-logo.svg", MUTE: "/logos/mute-logo.svg", mkUSD: "/logos/mkusd-logo.svg", // Prisma mkUSD + USDGLO: "/logos/usdglo-logo.svg", } as const; const MAINNET_TOKENS: PayoutToken[] = [ { @@ -81,6 +82,14 @@ const OPTIMISM_MAINNET_TOKENS: PayoutToken[] = [ logo: TokenNamesAndLogos["ETH"], redstoneTokenId: RedstoneTokenIds["ETH"], }, + { + name: "USDGLO", + chainId: ChainId.OPTIMISM_MAINNET_CHAIN_ID, + address: "0x4f604735c1cf31399c6e711d5962b2b3e0225ad3", + decimal: 18, + logo: TokenNamesAndLogos["USDGLO"], + redstoneTokenId: RedstoneTokenIds["USDGLO"], + }, ]; const FANTOM_MAINNET_TOKENS: PayoutToken[] = [ { @@ -302,6 +311,14 @@ const ARBITRUM_TOKENS: PayoutToken[] = [ logo: TokenNamesAndLogos["ARB"], redstoneTokenId: RedstoneTokenIds["ARB"], }, + { + name: "USDGLO", + chainId: ChainId.ARBITRUM, + address: "0x4f604735c1cf31399c6e711d5962b2b3e0225ad3", + decimal: 18, + logo: TokenNamesAndLogos["USDGLO"], + redstoneTokenId: RedstoneTokenIds["USDGLO"], + }, ]; const AVALANCHE_TOKENS: PayoutToken[] = [ { diff --git a/packages/common/tsconfig.json b/packages/common/tsconfig.json index 988ab479d1..e0f9805635 100644 --- a/packages/common/tsconfig.json +++ b/packages/common/tsconfig.json @@ -17,6 +17,7 @@ "isolatedModules": true, "jsx": "react-jsx", "sourceMap": true, - "declaration": true + "declaration": true, + "incremental": true }, } diff --git a/packages/data-layer/package.json b/packages/data-layer/package.json index 67bddd6aaa..4d8be5d2ec 100644 --- a/packages/data-layer/package.json +++ b/packages/data-layer/package.json @@ -5,6 +5,7 @@ "types": "dist/index.d.ts", "scripts": { "dev": "tsc --watch", + "start": "node -r dotenv-flow/config dist/index.js", "lint": "eslint --cache --max-warnings=0", "lint:fix": "eslint --cache --max-warnings=0 --fix", "lint:all:fix": "eslint --fix --cache --max-warnings=0 src", diff --git a/packages/data-layer/src/backends/legacy.ts b/packages/data-layer/src/backends/legacy.ts index c90ad0ebe3..51b1ef3f80 100644 --- a/packages/data-layer/src/backends/legacy.ts +++ b/packages/data-layer/src/backends/legacy.ts @@ -215,7 +215,7 @@ function convertStatus(status: string | number): ApplicationStatus { case 2: return "REJECTED"; case 3: - return "CANCELLED"; + return "APPEAL"; default: // XXX should this not throw an error? return "PENDING"; diff --git a/packages/data-layer/src/data-layer.ts b/packages/data-layer/src/data-layer.ts index a436245305..342a727156 100644 --- a/packages/data-layer/src/data-layer.ts +++ b/packages/data-layer/src/data-layer.ts @@ -8,16 +8,20 @@ import * as collections from "./backends/collections"; import * as legacy from "./backends/legacy"; import { AlloVersion, PaginationInfo } from "./data-layer.types"; import { + Application, Collection, OrderByRounds, Program, + ProjectApplication, ProjectEventsMap, Round, RoundGetRound, RoundOverview, SearchBasedProjectCategory, TimestampVariables, + V2RoundWithRoles, v2Project, + V2Round, } from "./data.types"; import { ApplicationSummary, @@ -26,12 +30,18 @@ import { SearchResult, } from "./openapi-search-client/index"; import { + getApplication, + getApplicationsByProjectId, + getProgramName, getProgramsByUser, getProjectById, getProjects, getProjectsAndRolesByAddress, getRoundsQuery, + getRoundByIdAndChainId, + getRoundsByProgramIdAndUserAddress, } from "./queries"; +import { Address } from "viem"; /** * DataLayer is a class that provides a unified interface to the various data sources. @@ -297,6 +307,114 @@ export class DataLayer { return projectEventsMap; } + /** + * getApplicationsByProjectId() returns a list of projects by address. + * @param projectId + * @param chainIds + */ + async getApplicationsByProjectId({ + projectId, + chainIds, + }: { + projectId: string; + chainIds: number[]; + }): Promise { + const requestVariables = { + projectId: projectId, + chainIds: chainIds, + }; + + const response: { applications: ProjectApplication[] } = await request( + this.gsIndexerEndpoint, + getApplicationsByProjectId, + requestVariables, + ); + + return response.applications ?? []; + } + + /** + * Returns a single application as identified by its id, round name and chain name + * @param projectId + */ + async getApplication({ + roundId, + chainId, + applicationId, + }: { + roundId: Lowercase
; + chainId: number; + applicationId: string; + }): Promise { + const requestVariables = { + roundId, + chainId, + applicationId, + }; + + const response: { application: Application } = await request( + this.gsIndexerEndpoint, + getApplication, + requestVariables, + ); + + return response.application ?? []; + } + + async getProgramName({ + projectId, + }: { + projectId: string; + }): Promise { + const requestVariables = { + projectId, + }; + + const response: { projects: { metadata: { name: string } }[] } = + await request(this.gsIndexerEndpoint, getProgramName, requestVariables); + + if (response.projects.length === 0) return null; + + const project = response.projects[0]; + + return project.metadata.name; + } + + async getRoundByIdAndChainId({ + roundId, + chainId, + }: { + roundId: string; + chainId: number; + }): Promise { + const requestVariables = { + roundId, + chainId, + }; + + const response: { rounds: V2Round[] } = await request( + this.gsIndexerEndpoint, + getRoundByIdAndChainId, + requestVariables, + ); + + return response.rounds[0] ?? []; + } + + async getRoundsByProgramIdAndUserAddress(args: { + chainId: number; + programId: string; + userAddress: Address; + }): Promise { + const response: { rounds: V2RoundWithRoles[] } = await request( + this.gsIndexerEndpoint, + getRoundsByProgramIdAndUserAddress, + { ...args, userAddress: args.userAddress.toLowerCase() }, + ); + + return response.rounds; + } + /** * Legacy - Allo v1 queries */ diff --git a/packages/data-layer/src/data.types.ts b/packages/data-layer/src/data.types.ts index 191f27d2b4..bdbf5f598e 100644 --- a/packages/data-layer/src/data.types.ts +++ b/packages/data-layer/src/data.types.ts @@ -1,5 +1,5 @@ import { VerifiableCredential } from "@gitcoinco/passport-sdk-types"; - +import { RoundApplicationMetadata } from "./roundApplication.types"; // TODO `RoundPayoutType` and `RoundVisibilityType` are duplicated from `common` to // avoid further spaghetti dependencies. They should probably be relocated here. export type RoundPayoutType = "MERKLE" | "DIRECT"; @@ -9,7 +9,9 @@ export type ApplicationStatus = | "PENDING" | "APPROVED" | "REJECTED" - | "CANCELLED"; + | "APPEAL" + | "FRAUD" + | "RECEIVED"; export type GrantApplicationFormAnswer = { questionId: number; @@ -191,6 +193,53 @@ export type v2Project = { roles: AddressAndRole[]; }; +/** + * The project application type for v2 + * + */ +export type ProjectApplication = { + id: string; + chainId: number; + roundId: string; + status: ApplicationStatus; + metadataCid: string; + metadata: any; // TODO: fix + inReview: boolean; + round: { + applicationsStartTime: string; + applicationsEndTime: string; + donationsStartTime: string; + donationsEndTime: string; + roundMetadata: RoundMetadata; + name: string; + }; +}; + +/** + * The round type for v2 + * + */ +export type V2Round = { + id: string; + chainId: number; + applicationsStartTime: string; + applicationsEndTime: string; + donationsStartTime: string; + donationsEndTime: string; + matchTokenAddress: string; + roundMetadata: RoundMetadata | null; + roundMetadataCid: string; + applicationMetadata: RoundApplicationMetadata | null; + applicationMetadataCid: string; + projectId: string; + strategyAddress: string; + strategyName: string; +}; + +export type V2RoundWithRoles = V2Round & { + roles: AddressAndRole[]; +}; + export type ProjectEvents = { createdAtBlock: number | undefined; updatedAtBlock: number | undefined; @@ -351,6 +400,10 @@ export type RoundMetadata = { roundType: RoundVisibilityType; eligibility: Eligibility; programContractAddress: string; + support?: { + info: string; + type: string; + }; }; export type SearchBasedProjectCategory = { @@ -384,7 +437,7 @@ export type RoundGetRound = { strategyId: string; strategyName: RoundPayoutType; strategyAddress: string; - applications: Application[]; + applications: ApplicationWithId[]; }; export interface RoundMetadataGetRound { @@ -417,7 +470,7 @@ export interface QuadraticFundingConfig { minDonationThresholdAmount?: number; } -export interface Application { +export interface ApplicationWithId { id: string; } @@ -475,3 +528,33 @@ export type OrderByRounds = | "UNIQUE_DONORS_COUNT_DESC" | "PRIMARY_KEY_ASC" | "PRIMARY_KEY_DESC"; + +export type Application = { + id: string; + chainId: string; + roundId: string; + projectId: string; + status: ApplicationStatus; + totalAmountDonatedInUsd: number; + totalDonationsCount: string; + uniqueDonorsCount: number; + round: { + donationsStartTime: string; + donationsEndTime: string; + applicationsStartTime: string; + applicationsEndTime: string; + roundMetadata: RoundMetadata; + matchTokenAddress: string; + tags: string[]; + }; + project: { + id: string; + metadata: ProjectMetadata; + }; + metadata: { + application: { + recipient: string; + answers: GrantApplicationFormAnswer[]; + }; + }; +}; diff --git a/packages/data-layer/src/index.ts b/packages/data-layer/src/index.ts index fa86d77515..ce49471e6d 100644 --- a/packages/data-layer/src/index.ts +++ b/packages/data-layer/src/index.ts @@ -2,3 +2,4 @@ export { DataLayer } from "./data-layer"; export { useDataLayer, DataLayerContext, DataLayerProvider } from "./react"; export * from "./openapi-search-client/models/index"; export * from "./data.types"; +export * from "./roundApplication.types"; \ No newline at end of file diff --git a/packages/data-layer/src/queries.ts b/packages/data-layer/src/queries.ts index 69ff014c60..f0729988f4 100644 --- a/packages/data-layer/src/queries.ts +++ b/packages/data-layer/src/queries.ts @@ -134,6 +134,70 @@ export const getProjects = gql` } `; +export const getApplicationsByProjectId = gql` + query getApplicationsByProjectId($projectId: String!, $chainIds: [Int!]!) { + applications( + filter: { + project: { id: { equalTo: $projectId }, chainId: { in: $chainIds } } + } + ) { + id + chainId + roundId + status + metadataCid + metadata + round { + applicationsStartTime + applicationsEndTime + donationsStartTime + donationsEndTime + roundMetadata + } + } + } +`; + +export const getApplication = gql` + 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 const getProgramName = gql` + query getProgramNameQuery($projectId: String!) { + projects(filter: { id: { equalTo: $projectId } }) { + metadata + } + } +`; + /** * Get projects by their address * @param $address - The address of the project @@ -241,3 +305,58 @@ export const getRoundsQuery = gql` } } `; + +export const getRoundByIdAndChainId = gql` + query getRoundByIdAndChainId($roundId: String!, $chainId: Int!) { + rounds( + filter: { id: { equalTo: $roundId }, chainId: { equalTo: $chainId } } + ) { + id + chainId + applicationsStartTime + applicationsEndTime + donationsStartTime + donationsEndTime + matchTokenAddress + roundMetadata + roundMetadataCid + applicationMetadata + applicationMetadataCid + } + } +`; + +export const getRoundsByProgramIdAndUserAddress = gql` + query getRoundsByProgramIdAndMemberAddress( + $chainId: Int! + $programId: String! + $userAddress: String! + ) { + rounds( + filter: { + chainId: { equalTo: $chainId } + projectId: { equalTo: $programId } + roles: { some: { address: { equalTo: $userAddress } } } + } + ) { + id + chainId + applicationsStartTime + applicationsEndTime + donationsStartTime + donationsEndTime + matchTokenAddress + roundMetadata + roundMetadataCid + applicationMetadata + applicationMetadataCid + strategyAddress + strategyName + roles { + role + address + createdAtBlock + } + } + } +`; diff --git a/packages/builder/src/types/roundApplication.ts b/packages/data-layer/src/roundApplication.types.ts similarity index 99% rename from packages/builder/src/types/roundApplication.ts rename to packages/data-layer/src/roundApplication.types.ts index e38ac2def1..fd84aad78c 100644 --- a/packages/builder/src/types/roundApplication.ts +++ b/packages/data-layer/src/roundApplication.types.ts @@ -63,6 +63,15 @@ export type RoundApplicationQuestion = | DropdownQuestion | NumberQuestion; +export interface RoundApplicationMetadata { + version: string; + lastUpdatedOn: number; + applicationSchema: { + questions: RoundApplicationQuestion[]; + requirements: ProjectRequirements; + }; +} + export interface ProjectRequirements { twitter: { required: boolean; @@ -74,15 +83,6 @@ export interface ProjectRequirements { }; } -export interface RoundApplicationMetadata { - version: string; - lastUpdatedOn: number; - applicationSchema: { - questions: RoundApplicationQuestion[]; - requirements: ProjectRequirements; - }; -} - export type RoundApplicationAnswers = { [key: string | number]: string | string[] | number; -}; +}; \ No newline at end of file diff --git a/packages/data-layer/tsconfig.json b/packages/data-layer/tsconfig.json index 2593e6c20f..9752651bb0 100644 --- a/packages/data-layer/tsconfig.json +++ b/packages/data-layer/tsconfig.json @@ -4,7 +4,8 @@ "outDir": "dist", "declaration": true, "sourceMap": true, - "jsx": "react-jsx" + "jsx": "react-jsx", + "incremental": true }, "include": ["src/**/*"] } diff --git a/packages/grant-explorer/e2e/homepage.test.ts b/packages/grant-explorer/e2e/homepage.test.ts new file mode 100644 index 0000000000..1b33237ca2 --- /dev/null +++ b/packages/grant-explorer/e2e/homepage.test.ts @@ -0,0 +1,14 @@ +import { test } from "../fixtures"; +import * as metamask from "@synthetixio/synpress/commands/metamask"; + +test.beforeEach(async ({ page }) => { + // baseUrl is set in playwright.config.ts + await page.goto("/"); +}); + +test("main page loads and wallet connects", async ({ page }) => { + await page.getByRole("navigation").getByTestId("rk-connect-button").click(); + await page.getByText("Metamask").click(); + await metamask.acceptAccess(); + await page.getByText("Explore rounds").waitFor(); +}); diff --git a/packages/grant-explorer/fixtures.ts b/packages/grant-explorer/fixtures.ts new file mode 100644 index 0000000000..832f32fa75 --- /dev/null +++ b/packages/grant-explorer/fixtures.ts @@ -0,0 +1,67 @@ +import { type BrowserContext, chromium, test as base } from "@playwright/test"; +import { initialSetup } from "@synthetixio/synpress/commands/metamask"; +import { setExpectInstance } from "@synthetixio/synpress/commands/playwright"; +import { resetState } from "@synthetixio/synpress/commands/synpress"; +import { prepareMetamask } from "@synthetixio/synpress/helpers"; +import { config } from "dotenv"; + +export const test = base.extend<{ + context: BrowserContext; +}>({ + // eslint-disable-next-line no-empty-pattern + context: async ({}, use) => { + config({ + path: ["./.env.local", "./.env", "./.env.test"], + }); + + // required for synpress as it shares same expect instance as playwright + await setExpectInstance(expect); + + // download metamask + const metamaskPath = await prepareMetamask( + process.env.METAMASK_VERSION || "10.25.0" + ); + + // prepare browser args + const browserArgs = [ + `--disable-extensions-except=${metamaskPath}`, + `--load-extension=${metamaskPath}`, + "--remote-debugging-port=9222", + ]; + + if (process.env.CI) { + browserArgs.push("--disable-gpu"); + } + + if (process.env.HEADLESS_MODE) { + browserArgs.push("--headless=new"); + } + + // launch browser + const context = await chromium.launchPersistentContext("", { + headless: false, + args: browserArgs, + }); + + // wait for metamask + await context.pages()[0].waitForTimeout(3000); + + // setup metamask + await initialSetup(chromium, { + secretWordsOrPrivateKey: + process.env.TEST_PRIVATE_KEY ?? + "test test test test test test test test test test test junk", + network: "optimism", + password: "Tester@1234", + enableAdvancedSettings: true, + }); + + await use(context); + + await context.close(); + + await resetState(); + }, +}); + +export const expect = test.expect; diff --git a/packages/grant-explorer/package.json b/packages/grant-explorer/package.json index fb3eee2d7d..06660175d6 100644 --- a/packages/grant-explorer/package.json +++ b/packages/grant-explorer/package.json @@ -5,6 +5,7 @@ "license": "AGPL-3.0-only", "scripts": { "build": "env REACT_APP_GIT_SHA=$(git rev-parse --short HEAD) craco build", + "synpress:test": "playwright test --project=chromium", "lint:local": "eslint", "lint:ci": "CI=false pnpm lint", "lint:fix": "eslint ./src --fix", @@ -43,11 +44,17 @@ "@gitcoinco/passport-sdk-verifier": "^0.2.2", "@headlessui/react": "^1.7.4", "@heroicons/react": "^2.0.13", + "@playwright/test": "^1.41.1", "@rainbow-me/rainbowkit": "1.0.10", + "@rsbuild/core": "^0.4.1", + "@rsbuild/plugin-react": "^0.3.11", + "@rsbuild/plugin-svgr": "^0.3.11", + "@rsdoctor/rspack-plugin": "^0.1.1", "@sentry/integrations": "^7.28.0", "@sentry/react": "^7.27.0", "@sentry/tracing": "^7.26.0", "@sentry/webpack-plugin": "^1.20.0", + "@synthetixio/synpress": "3.7.2-beta.10", "@tailwindcss/forms": "^0.5.3", "@tailwindcss/line-clamp": "^0.4.2", "@tailwindcss/typography": "^0.5.9", @@ -75,6 +82,9 @@ "https-browserify": "^1.0.0", "ipfs-core": "^0.14.3", "ipfs-core-types": "^0.14.0", + "jest": "^27.0", + "jszip": "^3.10.1", + "localforage": "^1.10.0", "lodash": "^4.17.21", "lodash-es": "^4.17.21", "moment": "^2.29.3", @@ -126,6 +136,7 @@ "@types/testing-library__jest-dom": "^5.14.5", "@vitest/coverage-v8": "^0.34.2", "autoprefixer": "^10.4.7", + "dotenv": "^16.4.1", "dotenv-flow": "^3.3.0", "eslint-config-gitcoin": "workspace:*", "happy-dom": "^11.0.2", diff --git a/packages/grant-explorer/playwright.config.js b/packages/grant-explorer/playwright.config.js new file mode 100644 index 0000000000..b876896c4b --- /dev/null +++ b/packages/grant-explorer/playwright.config.js @@ -0,0 +1,36 @@ +import { defineConfig, devices } from "@playwright/test"; + +export default defineConfig({ + testDir: "./e2e", + timeout: 60 * 1000, + expect: { + timeout: 5000, + }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 1 : 0, + workers: 1, + reporter: "html", + use: { + actionTimeout: 0, + baseURL: "http://localhost:3000", + trace: "on-first-retry", + headless: false, + }, + // start local web server before tests + webServer: [ + { + command: "pnpm start", + url: "http://localhost:3000", + timeout: 5000, + reuseExistingServer: true, + }, + ], + projects: [ + { + name: "chromium", + use: { ...devices["Desktop Chrome"] }, + }, + ], + outputDir: "test-results", +}); diff --git a/packages/grant-explorer/rsbuild.config.ts b/packages/grant-explorer/rsbuild.config.ts new file mode 100644 index 0000000000..622691e70d --- /dev/null +++ b/packages/grant-explorer/rsbuild.config.ts @@ -0,0 +1,41 @@ +import { defineConfig, loadEnv } from "@rsbuild/core"; +import { pluginReact } from "@rsbuild/plugin-react"; +import { pluginSvgr } from "@rsbuild/plugin-svgr"; +import { RsdoctorRspackPlugin } from "@rsdoctor/rspack-plugin"; +const path = require("path"); + +const { publicVars } = loadEnv({ prefixes: ["REACT_APP_"] }); + +export default defineConfig({ + plugins: [pluginReact(), pluginSvgr()], + html: { + template: "./public/index.html", + }, + server: { + port: Number(process.env.PORT) || 3000, + }, + source: { + define: publicVars, + alias: { + localforage: path.resolve( + __dirname, + "./node_modules/localforage/src/localforage.js" + ), + jszip: path.resolve(__dirname, "./node_modules/jszip/lib/index.js"), + "readable-stream": require.resolve("readable-stream"), + "csv-stringify": "csv-stringify/browser/esm", + }, + }, + tools: { + rspack(config, { appendPlugins }) { + // Only register the plugin when RSDOCTOR is true, as the plugin will increase the build time. + if (process.env.RSDOCTOR) { + appendPlugins( + new RsdoctorRspackPlugin({ + // plugin options + }) + ); + } + }, + }, +}); diff --git a/packages/grant-explorer/src/features/common/DefaultLayout.tsx b/packages/grant-explorer/src/features/common/DefaultLayout.tsx index 9aa8ef65cb..2b29747f71 100644 --- a/packages/grant-explorer/src/features/common/DefaultLayout.tsx +++ b/packages/grant-explorer/src/features/common/DefaultLayout.tsx @@ -36,6 +36,12 @@ export function GradientLayout({