diff --git a/backend/src/app.module.ts b/backend/src/app.module.ts index e84f8fb7f99dce4a2c70ac8c2e47df6f20ea4d7c..1d9ff61b391483de734469500ce0749a6ba11b55 100644 --- a/backend/src/app.module.ts +++ b/backend/src/app.module.ts @@ -11,6 +11,7 @@ import { CommentsModule } from './comments/comments.module'; import { ReactionsModule } from './reactions/reactions.module'; import { AuthService } from './auth/auth.service'; import { HttpModule } from '@nestjs/axios'; +import { ProposalsModule } from './proposals/proposals.module'; @Module({ imports: [ @@ -28,6 +29,7 @@ import { HttpModule } from '@nestjs/axios'; VoterModule, CommentsModule, ReactionsModule, + ProposalsModule, ], controllers: [], providers: [AuthService], diff --git a/backend/src/proposals/proposals.controller.ts b/backend/src/proposals/proposals.controller.ts new file mode 100644 index 0000000000000000000000000000000000000000..935df927b20bfe92e14bc0427c5cf49bc7041b7f --- /dev/null +++ b/backend/src/proposals/proposals.controller.ts @@ -0,0 +1,13 @@ +import { Controller, Get, Param, Query } from '@nestjs/common'; +import { ProposalsService } from './proposals.service'; + +@Controller('proposals') +export class ProposalsController { + constructor(private readonly proposalsService: ProposalsService) {} + @Get('') + getProposalByQuery( + @Query('query') query: string, + ) { + return this.proposalsService.getProposalByQuery(query); + } +} diff --git a/backend/src/proposals/proposals.module.ts b/backend/src/proposals/proposals.module.ts new file mode 100644 index 0000000000000000000000000000000000000000..1b1cf9b5edfc411756429c1ae83ba5872009a12b --- /dev/null +++ b/backend/src/proposals/proposals.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { ProposalsController } from './proposals.controller'; +import { ProposalsService } from './proposals.service'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([], 'dbsync'), + ], + controllers: [ProposalsController], + providers: [ProposalsService] +}) +export class ProposalsModule {} diff --git a/backend/src/proposals/proposals.service.ts b/backend/src/proposals/proposals.service.ts new file mode 100644 index 0000000000000000000000000000000000000000..ae8463ba58bfc2ff8549be933787f77e034a35e4 --- /dev/null +++ b/backend/src/proposals/proposals.service.ts @@ -0,0 +1,27 @@ +import { HttpException, Injectable } from '@nestjs/common'; +import { InjectDataSource } from '@nestjs/typeorm'; +import { getProposalByHashQuery } from 'src/queries/getProposalsViaQuery'; +import { DataSource } from 'typeorm'; + +@Injectable() +export class ProposalsService { + constructor( + @InjectDataSource('dbsync') + private cexplorerService: DataSource, + ) {} + async getProposalByQuery(query: string) { + if (!query) throw new HttpException('query is required', 400); + if (query.length < 5) + throw new HttpException( + 'Query string should be greater than 5 chars', + 400, + ); + const matchingProposals = await this.cexplorerService.manager.query( + getProposalByHashQuery, + [`%${query}%`], + ); + if (!matchingProposals.length) + throw new HttpException('No matching proposals found', 404); + return matchingProposals; + } +} diff --git a/backend/src/queries/getProposalsViaQuery.ts b/backend/src/queries/getProposalsViaQuery.ts new file mode 100644 index 0000000000000000000000000000000000000000..fd91f28fcbff50c01449393e5cddd7de2e8abbb1 --- /dev/null +++ b/backend/src/queries/getProposalsViaQuery.ts @@ -0,0 +1,19 @@ +export const getProposalByHashQuery = ` +SELECT + ga.id, + ga.type, + ga.description, + SUBSTRING(CAST(gov_tx.hash AS TEXT) FROM 3) AS hash, +tx_block.time AS prop_inception_time +FROM + "gov_action_proposal" as ga +LEFT JOIN + tx as gov_tx +ON + ga.tx_id = gov_tx.id +LEFT JOIN +block as tx_block on gov_tx.block_id=tx_block.id +WHERE + SUBSTRING(CAST(gov_tx.hash AS TEXT) FROM 3) LIKE $1 +LIMIT 50; +`; diff --git a/frontend/src/components/atoms/DrepGovActionSubmitCard.tsx b/frontend/src/components/atoms/DrepGovActionSubmitCard.tsx index 653f489bb0437155cc52f13bf3462d046d7b82bc..9dc54c82cadb8067c86854f993a85f12246fbe21 100644 --- a/frontend/src/components/atoms/DrepGovActionSubmitCard.tsx +++ b/frontend/src/components/atoms/DrepGovActionSubmitCard.tsx @@ -1,104 +1,155 @@ import { urls } from '@/constants'; -import { convertString } from '@/lib'; +import { useGetProposalsQuery } from '@/hooks/useGetProposalByHashQuery'; import Link from 'next/link'; -import React from 'react'; -import { CopyToClipboard } from 'react-copy-to-clipboard'; +import React, { useState, useEffect } from 'react'; + const SubmittedChip = ({ date }: { date: string }) => { return ( <div className="flex flex-row items-center justify-between"> <div className="flex w-fit rounded-full bg-blue-800 px-3 py-1 text-white"> <p className="text-xs font-light">Submitted a Governance Action</p> </div> - <p>{new Date().toLocaleDateString('en-GB')}</p> + <p> + {date + ? new Date(date).toLocaleDateString('en-GB') + : new Date().toLocaleDateString('en-GB')} + </p> </div> ); }; +const SkeletonLoader = () => ( + <div className="flex max-w-md animate-pulse flex-col gap-3 rounded-xl border-2 bg-white p-3 shadow-xl"> + <div className="h-6 w-3/4 rounded bg-gray-200"></div> + <hr /> + <div className="h-10 w-1/4 rounded bg-gray-200"></div> + <div className="h-6 w-1/2 rounded bg-gray-200"></div> + <div className="h-4 w-1/3 rounded bg-gray-200"></div> + </div> +); +const NotFoundState = ({ hash }: { hash: string }) => ( + <div className="flex max-w-md flex-col gap-3 rounded-xl border-2 border-red-300 bg-white p-3 shadow-xl"> + <p className="text-lg font-medium text-red-500">Proposal not found</p> + <ViewActionLink hash={hash} /> + </div> +); + +const ViewActionLink = ({ hash }: { hash: string }) => ( + <Link + href={`${urls.govToolUrl}/governance_actions/${hash}#0`} + target="_blank" + className="text-sm text-blue-800" + > + View Governance Action + </Link> +); + +const capitalizeFirstLetter = (str: string) => { + return str.charAt(0).toUpperCase() + str.slice(1); +}; + const DrepGovActionSubmitCard = ({ - actionType = '', + actionTypeParam = '', + hash = '', item, }: { - actionType: string; - item: any; + actionTypeParam?: string; + hash?: string; + item?: any; }) => { - let style: any = { + const [cardData, setCardData] = useState(item); + const [actionType, setActionType] = useState(actionTypeParam); + const { Proposals, isProposalsLoading } = + useGetProposalsQuery({ + hashQueryString: hash, + }); + + useEffect(() => { + if (Proposals) { + setCardData(Proposals[0]); + setActionType(Proposals[0].description.tag.toLowerCase()); + } + }, [Proposals]); + + let style: any = { + borderColor: 'border-[#D19471]', + bgColor: 'bg-[#D19471]', + imgSrc: '/svgs/exchange.svg', + actionName: '', + }; + + switch (true) { + case actionType.includes('protocolparameter'): + style = { borderColor: 'border-[#D19471]', bgColor: 'bg-[#D19471]', imgSrc: '/svgs/exchange.svg', - actionName:'' + actionName: 'Protocol Parameter Changes', }; - - switch (true) { - case actionType.includes('protocol parameter'): - style = { - borderColor: 'border-[#D19471]', - bgColor: 'bg-[#D19471]', - imgSrc: '/svgs/exchange.svg', - actionName:'Protocol Parameter Changes' - }; - break; - case actionType.includes('info'): - style = { - borderColor: 'border-[#BB7AEE]', - bgColor: 'bg-[#BB7AEE]', - imgSrc: '/svgs/info-circle.svg', - actionName:"Info" - }; - break; - case actionType.includes('hard-fork'): - style = { - borderColor: 'border-[#A3D96C]', - bgColor: 'bg-[#A3D96C]', - imgSrc: '/svgs/status-change.svg', - actionName:"Hard-Fork Initiation" - }; - break; - case actionType.includes('new constitution or guardrails script'): - style = { - borderColor: 'border-[#D96CAE]', - bgColor: 'bg-[#D96CAE]', - imgSrc: '/svgs/notebook.svg', - actionName:"New Constitution or Guardrails Script" - }; - break; - case actionType.includes('update committee '): - style = { - borderColor: 'border-[#6FDF8E]', - bgColor: 'bg-[#6FDF8E]', - imgSrc: '/svgs/users-group.svg', - actionName:"Update committee and/or threshold and/or terms" - }; - break; - default: - style = { - borderColor: 'border-[#6FDF8E]', - bgColor: 'bg-[#6FDF8E]', - imgSrc: '/svgs/users-group.svg', - actionName:actionType - }; - // Handle other cases - break; - } + break; + case actionType.includes('info'): + style = { + borderColor: 'border-[#BB7AEE]', + bgColor: 'bg-[#BB7AEE]', + imgSrc: '/svgs/info-circle.svg', + actionName: 'Info', + }; + break; + case actionType.includes('hardfork'): + style = { + borderColor: 'border-[#A3D96C]', + bgColor: 'bg-[#A3D96C]', + imgSrc: '/svgs/status-change.svg', + actionName: 'Hard-Fork Initiation', + }; + break; + case actionType.includes('newconstitution'): + style = { + borderColor: 'border-[#D96CAE]', + bgColor: 'bg-[#D96CAE]', + imgSrc: '/svgs/notebook.svg', + actionName: 'New Constitution or Guardrails Script', + }; + break; + case actionType.includes('updatecommittee'): + style = { + borderColor: 'border-[#6FDF8E]', + bgColor: 'bg-[#6FDF8E]', + imgSrc: '/svgs/users-group.svg', + actionName: 'Update committee and/or threshold and/or terms', + }; + break; + default: + style = { + borderColor: 'border-[#6FDF8E]', + bgColor: 'bg-[#6FDF8E]', + imgSrc: '/svgs/users-group.svg', + actionName: capitalizeFirstLetter(actionType), + }; + break; + } + + if (isProposalsLoading) { + return <SkeletonLoader />; + } + + if (!cardData) { + return <NotFoundState hash={hash} />; + } return ( <div id="epoch-card" className={`flex max-w-md flex-col gap-3 rounded-xl border-2 bg-white p-3 shadow-xl ${style.borderColor}`} > - <SubmittedChip date={item?.time_voted} /> + <SubmittedChip date={cardData?.prop_inception_time} /> <hr /> <div className="flex flex-col gap-1"> <div className={`rounded-full p-1 ${style.bgColor} w-fit`}> <img src={style.imgSrc} alt="" className="h-5 w-5" /> </div> - <p className="text-lg font-medium text-wrap">{style.actionName}</p> + <p className="text-wrap text-lg font-medium">{style.actionName}</p> </div> - <Link - href={`${urls.govToolUrl}/governance_actions/${item?.gov_action_proposal_id}`} - target="_blank" - className="text-sm text-blue-800" - > - View Governance Action - </Link> + <ViewActionLink hash={hash} /> </div> ); }; diff --git a/frontend/src/components/atoms/MarkdownEditor.tsx b/frontend/src/components/atoms/MarkdownEditor.tsx index 43f463fbe4a221568ab5ffa82073275277196410..91fb62da8cc7526977fe7005a3bf1d64a85afc00 100644 --- a/frontend/src/components/atoms/MarkdownEditor.tsx +++ b/frontend/src/components/atoms/MarkdownEditor.tsx @@ -59,7 +59,7 @@ const MarkdownEditor = ({ control, errors, name }: MarkdownEditorProps) => { /> ) : ( <div className="min-h-40 w-full overflow-auto rounded-b-xl border p-2"> - {parts.map((item, index) => { + {parts && parts.map((item, index) => { if (typeof item === 'string') { return ( <Typography diff --git a/frontend/src/components/molecules/MultipartDataForm.tsx b/frontend/src/components/molecules/MultipartDataForm.tsx index 86335d469d3c24c45a3c556db15bb6f8130026ba..2cc6401b302709f1fa78f0003b7cd496ab3eee7b 100644 --- a/frontend/src/components/molecules/MultipartDataForm.tsx +++ b/frontend/src/components/molecules/MultipartDataForm.tsx @@ -121,10 +121,7 @@ const MultipartDataForm = ({ formData.append('parentEntity', 'note'); formData.append('parentId', null); const mimeType = file.type; - const res = await axiosInstance.post( - `/attachments/add`, - formData, - ); + const res = await axiosInstance.post(`/attachments/add`, formData); return { name: res.data.name, type: mimeType }; }), ); diff --git a/frontend/src/components/molecules/ProposalActionForm.tsx b/frontend/src/components/molecules/ProposalActionForm.tsx index 7bc963768074c02f9e73ac719e57cc50942ba795..3342602f9639baa5cf68a969a616c22599e7d345 100644 --- a/frontend/src/components/molecules/ProposalActionForm.tsx +++ b/frontend/src/components/molecules/ProposalActionForm.tsx @@ -1,115 +1,51 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState, useCallback } from 'react'; import Button from '../atoms/Button'; +import { useDebouncedCallback } from 'use-debounce'; import { CircularProgress } from '@mui/material'; import DrepGovActionSubmitCard from '../atoms/DrepGovActionSubmitCard'; import { MDXEditorMethods } from '@mdxeditor/editor'; +import { useGetProposalsQuery } from '@/hooks/useGetProposalByHashQuery'; + interface ProposalActionFormProps { nullify: () => void; setProposalHashPayload?: (payload: any) => void; - editor?: MDXEditorMethods | any; //any type of editor + editor?: MDXEditorMethods | any; } + const ProposalActionForm = ({ nullify, setProposalHashPayload, editor, }: ProposalActionFormProps) => { - const [proposals, setProposals] = useState(null); // only hashes + const [proposals, setProposals] = useState(null); + const [error, setError] = useState(''); const [fetchedProposals, setFetchedProposals] = useState(null); const [currentHash, setCurrentHash] = useState(''); - const [isFetching, setIsFetching] = useState(false); const formRef = useRef<HTMLDivElement>(null); - const sample = [ - { - hash: '0x1a2b3c4d5e6f7g8h9i0j', - description: { - tag: 'Protocol Parameter Changes', - }, - createdAt: '2024-03-15T09:23:14Z', - }, - { - hash: '0xk1l2m3n4o5p6q7r8s9t', - description: { - tag: 'New Constitution or Guardrails Script', - }, - createdAt: '2024-07-22T14:45:30Z', - }, - { - hash: '0xu1v2w3x4y5z6a7b8c9d', - description: { - tag: 'Treasury Withdrawals', - }, - createdAt: '2024-01-05T11:17:42Z', - }, - { - hash: '0xe1f2g3h4i5j6k7l8m9n0', - description: { - tag: 'Hard-Fork Initiation', - }, - createdAt: '2024-09-30T16:55:03Z', - }, - { - hash: '0xo1p2q3r4s5t6u7v8w9x', - description: { - tag: 'Update committee and/or threshold and/or terms', - }, - createdAt: '2024-05-18T08:39:27Z', - }, - { - hash: '0xy1z2a3b4c5d6e7f8g9h', - description: { - tag: 'Motion of no-confidence', - }, - createdAt: '2024-11-07T13:12:59Z', - }, - { - hash: '0xi1j2k3l4m5n6o7p8q9r', - description: { - tag: 'Protocol Parameter Changes', - }, - createdAt: '2024-02-29T10:05:33Z', - }, - { - hash: '0xs1t2u3v4w5x6y7z8a9b', - description: { - tag: 'Info', - }, - createdAt: '2024-08-14T15:37:21Z', - }, - { - hash: '0xc1d2e3f4g5h6i7j8k9l0', - description: { - tag: 'Treasury Withdrawals', - }, - createdAt: '2024-04-03T12:50:48Z', - }, - { - hash: '0xm1n2o3p4q5r6s7t8u9v', - description: { - tag: 'New Constitution or Guardrails Script', - }, - createdAt: '2024-10-25T17:28:06Z', - }, - ]; - const handleInputChange = async (e) => { - console.log('searching'); - if (e.target.value === '') { - setCurrentHash(''); - setFetchedProposals(null); - return; + + const { + Proposals, + isProposalsFetching, + proposalFetchError, + } = useGetProposalsQuery({ + hashQueryString: currentHash, + }); + + useEffect(() => { + if (Proposals && Proposals.length > 0) { + setFetchedProposals(Proposals); + setError(''); } - setCurrentHash(e.target.value); - setIsFetching(true); - try { - await new Promise((resolve) => setTimeout(resolve, 1000)); // 500ms delay - const matchingProposals = sample.filter((proposal) => { - return proposal.hash.includes(e.target.value); - }); - setFetchedProposals(matchingProposals); - setIsFetching(false); - } catch (error) { - console.log(error); + if (proposalFetchError) { + setFetchedProposals([]); + setError(proposalFetchError?.response?.data?.message as string || 'Error'); } - }; + }, [Proposals]); + + const handleInputChange = useDebouncedCallback((value) => { + setCurrentHash(value); + }, 300); + const uploadProposal = async () => { try { const markdown = `[gov_action hash='${proposals[0]}']`; @@ -123,7 +59,6 @@ const ProposalActionForm = ({ } }; - // Handle clicks/taps outside the form useEffect(() => { const handleClickOutside = (event: MouseEvent | TouchEvent) => { if ( @@ -153,12 +88,12 @@ const ProposalActionForm = ({ <div className="h-11 text-[1.375rem] font-bold text-zinc-800"> Add Proposal </div> - <div className="flex w-fit flex-col gap-1"> + <div className="flex w-full flex-col gap-1"> <input type="text" - value={currentHash} - onChange={handleInputChange} - className={`w-fit rounded-full border border-zinc-100 py-3 pl-5`} + defaultValue={currentHash} + onChange={(e) => handleInputChange(e.target.value)} + className={`w-full rounded-full border border-zinc-100 py-3 pl-5`} placeholder={'Paste proposal hash here...'} /> </div> @@ -167,7 +102,7 @@ const ProposalActionForm = ({ Proposals should exist on chain for addition. </p> <div className="mt-3 flex max-h-52 flex-col gap-3 overflow-y-auto"> - {!isFetching ? ( + {!isProposalsFetching ? ( fetchedProposals && fetchedProposals.length > 0 ? ( fetchedProposals.map((proposal, index) => ( <div @@ -175,17 +110,16 @@ const ProposalActionForm = ({ key={index} onClick={() => { setProposals([proposal.hash]); - setCurrentHash(proposal.hash); }} > <DrepGovActionSubmitCard - actionType={proposal.description.tag.toLowerCase()} + actionTypeParam={proposal.description.tag.toLowerCase()} item={proposal} /> </div> )) ) : ( - <p>No proposals found</p> + <p>{error}</p> ) ) : ( <div className="flex items-center justify-center"> diff --git a/frontend/src/hooks/useGetProposalByHashQuery.ts b/frontend/src/hooks/useGetProposalByHashQuery.ts new file mode 100644 index 0000000000000000000000000000000000000000..94ae9fd8882a4bf7c678250d1146b0e3c5363e6a --- /dev/null +++ b/frontend/src/hooks/useGetProposalByHashQuery.ts @@ -0,0 +1,20 @@ +import { getProposalByHashQueryString } from '@/services/requests/getProposalByHashQueryString'; +import { useQuery } from 'react-query'; +export type getProposalByHashQueryStringProps = { + hashQueryString: string; +}; +export const useGetProposalsQuery = ({ + hashQueryString, +}: getProposalByHashQueryStringProps) => { + const { data, isLoading, isFetching, error } = useQuery({ + queryKey: ['getProposalByHashQueryString', hashQueryString], + queryFn: async () => await getProposalByHashQueryString(hashQueryString), + enabled: !!hashQueryString, + }); + return { + Proposals: data, + isProposalsLoading: isLoading, + isProposalsFetching: isFetching, + proposalFetchError: error as any, + }; +}; diff --git a/frontend/src/lib/noteContentProcessor/governanceActionProcessor.tsx b/frontend/src/lib/noteContentProcessor/governanceActionProcessor.tsx index 441d6bb8cd7b96072486cd09b52381f9cf30c7c0..d7a98651c19ba9bb48ab8d813253989022f15aa0 100644 --- a/frontend/src/lib/noteContentProcessor/governanceActionProcessor.tsx +++ b/frontend/src/lib/noteContentProcessor/governanceActionProcessor.tsx @@ -26,8 +26,7 @@ export const governanceActionProcessor = (content: string) => { parts.push( <DrepGovActionSubmitCard key={hash} - item={govActionData} - actionType={govActionData.description.tag} + hash={hash} /> ); diff --git a/frontend/src/services/requests/getProposalByHashQueryString.ts b/frontend/src/services/requests/getProposalByHashQueryString.ts new file mode 100644 index 0000000000000000000000000000000000000000..30042233ad52bb7e632f05a3de77c2c895a0d914 --- /dev/null +++ b/frontend/src/services/requests/getProposalByHashQueryString.ts @@ -0,0 +1,10 @@ +import axiosInstance from '../axiosInstance'; + +export const getProposalByHashQueryString = async (hashQueryString: string) => { + const response = await axiosInstance.get(`proposals`, { + params: { + query: hashQueryString, + }, + }); + return response.data; +};