From b4f565b27edfc7a9b9b1be1ecfbe41a9e2214eb3 Mon Sep 17 00:00:00 2001 From: Rudransh Shrivastava Date: Sat, 20 Jun 2026 21:09:54 +0530 Subject: [PATCH 01/58] cherry pick claim related files --- backend/settings/graphql.py | 5 +- .../[login]/claims/[claimKey]/edit/page.tsx | 146 +++++++++++ .../[login]/claims/[claimKey]/page.tsx | 150 ++++++++++++ .../candidates/[login]/claims/create/page.tsx | 107 ++++++++ .../[year]/candidates/[login]/claims/page.tsx | 230 ++++++++++++++++++ .../src/app/board/[year]/candidates/page.tsx | 16 +- frontend/src/components/ClaimActions.tsx | 210 ++++++++++++++++ frontend/src/components/ClaimForm.tsx | 133 ++++++++++ frontend/src/components/DropdownActions.tsx | 140 +++++++++++ .../forms/shared/formValidationUtils.ts | 19 ++ .../src/server/mutations/claimMutations.ts | 61 +++++ frontend/src/server/queries/boardQueries.ts | 10 + frontend/src/server/queries/claimQueries.ts | 30 +++ .../src/server/queries/evidenceQueries.ts | 47 ++++ .../__generated__/boardQueries.generated.ts | 9 + .../__generated__/claimMutations.generated.ts | 90 +++++++ .../__generated__/claimQueries.generated.ts | 35 +++ .../evidenceQueries.generated.ts | 40 +++ frontend/src/types/__generated__/graphql.ts | 223 +++++++++++++++++ 19 files changed, 1698 insertions(+), 3 deletions(-) create mode 100644 frontend/src/app/board/[year]/candidates/[login]/claims/[claimKey]/edit/page.tsx create mode 100644 frontend/src/app/board/[year]/candidates/[login]/claims/[claimKey]/page.tsx create mode 100644 frontend/src/app/board/[year]/candidates/[login]/claims/create/page.tsx create mode 100644 frontend/src/app/board/[year]/candidates/[login]/claims/page.tsx create mode 100644 frontend/src/components/ClaimActions.tsx create mode 100644 frontend/src/components/ClaimForm.tsx create mode 100644 frontend/src/components/DropdownActions.tsx create mode 100644 frontend/src/server/mutations/claimMutations.ts create mode 100644 frontend/src/server/queries/claimQueries.ts create mode 100644 frontend/src/server/queries/evidenceQueries.ts create mode 100644 frontend/src/types/__generated__/claimMutations.generated.ts create mode 100644 frontend/src/types/__generated__/claimQueries.generated.ts create mode 100644 frontend/src/types/__generated__/evidenceQueries.generated.ts diff --git a/backend/settings/graphql.py b/backend/settings/graphql.py index 84fed080f6..13997df061 100644 --- a/backend/settings/graphql.py +++ b/backend/settings/graphql.py @@ -50,9 +50,10 @@ class Query( class NestQueryDepthLimiter(QueryDepthLimiter): """Query depth limiter configured for the Nest schema.""" - def __init__(self) -> None: + def __init__(self, **kwargs) -> None: """Initialize with the Nest schema max query depth.""" - super().__init__(max_depth=5) + kwargs.pop("execution_context") + super().__init__(max_depth=5, **kwargs) extensions: list[type[SchemaExtension] | Callable[[], SchemaExtension]] = [ diff --git a/frontend/src/app/board/[year]/candidates/[login]/claims/[claimKey]/edit/page.tsx b/frontend/src/app/board/[year]/candidates/[login]/claims/[claimKey]/edit/page.tsx new file mode 100644 index 0000000000..84db0598c6 --- /dev/null +++ b/frontend/src/app/board/[year]/candidates/[login]/claims/[claimKey]/edit/page.tsx @@ -0,0 +1,146 @@ +'use client' +import { useMutation, useQuery } from '@apollo/client/react' +import { addToast } from '@heroui/toast' +import { useDjangoSession } from 'hooks/useDjangoSession' +import { useParams, useRouter } from 'next/navigation' +import React, { useEffect, useState } from 'react' +import { ErrorDisplay, handleAppError } from 'app/global-error' +import { UpdateBoardCandidateClaimDocument } from 'types/__generated__/claimMutations.generated' +import { GetBoardCandidateClaimDocument } from 'types/__generated__/claimQueries.generated' +import { extractGraphQLErrors } from 'utils/helpers/handleGraphQLError' +import slugify from 'utils/slugify' +import AccessDeniedDisplay from 'components/AccessDeniedDisplay' +import ClaimForm from 'components/ClaimForm' +import LoadingSpinner from 'components/LoadingSpinner' + +const EditClaimPage = () => { + const router = useRouter() + const { claimKey, login, year } = useParams<{ claimKey: string; login: string; year: string }>() + const { isSyncing, session } = useDjangoSession() + const { + data: graphQLData, + error: graphQLRequestError, + loading: isLoading, + } = useQuery(GetBoardCandidateClaimDocument, { + fetchPolicy: 'cache-and-network', + skip: !claimKey, + variables: { key: claimKey, login: login, year: Number.parseInt(year) }, + }) + + const [updateClaim, { loading }] = useMutation(UpdateBoardCandidateClaimDocument) + const [formData, setFormData] = useState({ + description: '', + name: '', + }) + + useEffect(() => { + if (graphQLRequestError) { + handleAppError(graphQLRequestError) + } + }, [graphQLRequestError]) + + const claim = graphQLData?.boardCandidateClaim + + useEffect(() => { + if (claim) { + setFormData({ + description: claim.description ?? '', + name: claim.name ?? '', + }) + } + }, [claim]) + + if (isLoading || isSyncing) return + + if (graphQLRequestError) { + return ( + + ) + } + + if (!graphQLData || !claim) { + return ( + + ) + } + + if (session?.user?.login !== login) { + return ( + + ) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + try { + const input = { + description: formData.description, + key: claimKey, + name: formData.name, + year: Number.parseInt(year), + } + + const result = await updateClaim({ + awaitRefetchQueries: true, + refetchQueries: [ + { + query: GetBoardCandidateClaimDocument, + variables: { key: claimKey, login, year: Number.parseInt(year) }, + }, + ], + variables: { input }, + }) + + if (!result.data?.updateBoardCandidateClaim?.ok) { + throw new Error(result.data?.updateBoardCandidateClaim?.message ?? 'Claim update failed.') + } + + addToast({ + description: 'Claim updated successfully!', + title: 'Success', + timeout: 3000, + shouldShowTimeoutProgress: true, + color: 'success', + }) + + router.push(`/board/${year}/candidates/${login}/claims/${slugify(formData.name)}`) + } catch (err) { + const { hasValidationErrors } = extractGraphQLErrors(err) + if (!hasValidationErrors) { + addToast({ + description: + err instanceof Error ? err.message : 'Unable to complete the requested operation.', + timeout: 3000, + shouldShowTimeoutProgress: true, + color: 'danger', + }) + } + throw err + } + } + + if (isSyncing) { + return + } + + return ( + + ) +} + +export default EditClaimPage diff --git a/frontend/src/app/board/[year]/candidates/[login]/claims/[claimKey]/page.tsx b/frontend/src/app/board/[year]/candidates/[login]/claims/[claimKey]/page.tsx new file mode 100644 index 0000000000..42e39ec144 --- /dev/null +++ b/frontend/src/app/board/[year]/candidates/[login]/claims/[claimKey]/page.tsx @@ -0,0 +1,150 @@ +'use client' + +import { useQuery } from '@apollo/client/react' + +import { Button } from '@heroui/button' +import { BreadcrumbStyleProvider } from 'contexts/BreadcrumbContext' +import { useDjangoSession } from 'hooks/useDjangoSession' +import { toLower, upperFirst } from 'lodash' +import { useParams, useRouter } from 'next/navigation' +import { useEffect } from 'react' +import { FaPlus } from 'react-icons/fa6' +import { ErrorDisplay, handleAppError } from 'app/global-error' +import { GetBoardCandidateClaimDocument } from 'types/__generated__/claimQueries.generated' +import { GetBoardCandidateClaimEvidencesDocument } from 'types/__generated__/evidenceQueries.generated' +import { titleCaseWord } from 'utils/capitalize' +import { formatDate } from 'utils/dateFormatter' +import AccessDeniedDisplay from 'components/AccessDeniedDisplay' +import ActionButton from 'components/ActionButton' +import Metadata from 'components/cards/Metadata' +import PageWrapper from 'components/cards/PageWrapper' +import ClaimActions from 'components/ClaimActions' +import LoadingSpinner from 'components/LoadingSpinner' +import SecondaryCard from 'components/SecondaryCard' + +const ClaimDetailsPage = () => { + const router = useRouter() + const { claimKey, login, year } = useParams<{ claimKey: string; login: string; year: string }>() + const { isSyncing, session } = useDjangoSession() + const { + data: claimGraphQLData, + error: claimGraphQLRequestError, + loading: isClaimLoading, + } = useQuery(GetBoardCandidateClaimDocument, { + fetchPolicy: 'cache-and-network', + skip: !claimKey, + variables: { key: claimKey, login: login, year: Number.parseInt(year) }, + }) + + const { + data: evidenceGraphQLData, + error: evidenceGraphQLRequestError, + loading: isEvidenceLoading, + } = useQuery(GetBoardCandidateClaimEvidencesDocument, { + fetchPolicy: 'cache-and-network', + skip: !claimKey, + variables: { claimKey: claimKey, login: login, year: Number.parseInt(year) }, + }) + + const claim = claimGraphQLData?.boardCandidateClaim + const evidences = evidenceGraphQLData?.boardCandidateClaimEvidences ?? [] + + useEffect(() => { + if (claimGraphQLRequestError) { + handleAppError(claimGraphQLRequestError) + } + if (evidenceGraphQLRequestError) { + handleAppError(evidenceGraphQLRequestError) + } + }, [claimGraphQLRequestError, evidenceGraphQLRequestError]) + + if (isClaimLoading || isEvidenceLoading || isSyncing) return + + if (claimGraphQLRequestError || evidenceGraphQLRequestError) { + return ( + + ) + } + + if (!claimGraphQLData || !claim) { + return ( + + ) + } + + if (session?.user?.login !== login) { + return ( + + ) + } + + const claimDetails = [ + { label: 'Name', value: titleCaseWord(claim.name) }, + { label: 'Description', value: claim.description }, + { label: 'Status', value: upperFirst(toLower(claim.status)) }, + { label: 'Last Updated', value: formatDate(claim.updatedAt) }, + ] + + const handleAddEvidence = () => + router.push(`/board/${year}/candidates/${login}/claims/${claimKey}/evidences/create`) + + const handleEvidenceClick = (evidenceKey: string) => + router.push(`/board/${year}/candidates/${login}/claims/${claimKey}/evidences/${evidenceKey}`) + + return ( + + +
+
+

Claim

+
+
+ {claim.status == 'DRAFT' && ( + + + {'Add Evidence'} + + )} + +
+
+ + + {evidences.length == 0 ? ( +

No evidences.

+ ) : ( +
+ {evidences.map((evidence) => ( + + ))} +
+ )} +
+
+
+ ) +} + +export default ClaimDetailsPage diff --git a/frontend/src/app/board/[year]/candidates/[login]/claims/create/page.tsx b/frontend/src/app/board/[year]/candidates/[login]/claims/create/page.tsx new file mode 100644 index 0000000000..841f0ea87e --- /dev/null +++ b/frontend/src/app/board/[year]/candidates/[login]/claims/create/page.tsx @@ -0,0 +1,107 @@ +'use client' +import { useMutation, useQuery } from '@apollo/client/react' +import { addToast } from '@heroui/toast' +import { useDjangoSession } from 'hooks/useDjangoSession' +import { useParams, useRouter } from 'next/navigation' +import React, { useState } from 'react' + +import { GetBoardCandidateDocument } from 'types/__generated__/boardQueries.generated' +import { CreateBoardCandidateClaimDocument } from 'types/__generated__/claimMutations.generated' +import { GetBoardCandidateClaimsDocument } from 'types/__generated__/claimQueries.generated' +import { extractGraphQLErrors } from 'utils/helpers/handleGraphQLError' +import AccessDeniedDisplay from 'components/AccessDeniedDisplay' +import ClaimForm from 'components/ClaimForm' +import LoadingSpinner from 'components/LoadingSpinner' + +const CreateClaimPage = () => { + const router = useRouter() + const { isSyncing, session } = useDjangoSession() + const { login, year } = useParams<{ login: string; year: string }>() + + const [createClaim, { loading }] = useMutation(CreateBoardCandidateClaimDocument) + + const [formData, setFormData] = useState({ + description: '', + name: '', + }) + + const { data: candidateGraphQLData } = useQuery(GetBoardCandidateDocument, { + variables: { login: login, year: Number.parseInt(year) }, + }) + + const isCandidate = candidateGraphQLData?.boardOfDirectors?.candidate != null + + if (!isCandidate || session?.user?.login !== login) { + return ( + + ) + } + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + + try { + const input = { + description: formData.description, + name: formData.name, + year: Number.parseInt(year), + } + + const result = await createClaim({ + awaitRefetchQueries: true, + refetchQueries: [ + { + query: GetBoardCandidateClaimsDocument, + variables: { login, year: Number.parseInt(year) }, + }, + ], + variables: { input }, + }) + + if (!result.data?.createBoardCandidateClaim?.ok) { + throw new Error(result.data?.createBoardCandidateClaim?.message ?? 'Claim creation failed.') + } + + addToast({ + description: 'Claim created successfully!', + title: 'Success', + timeout: 3000, + shouldShowTimeoutProgress: true, + color: 'success', + }) + + router.push(`/board/${year}/candidates/${login}/claims`) + } catch (err) { + const { hasValidationErrors } = extractGraphQLErrors(err) + if (!hasValidationErrors) { + addToast({ + description: + err instanceof Error ? err.message : 'Unable to complete the requested operation.', + timeout: 3000, + shouldShowTimeoutProgress: true, + color: 'danger', + }) + } + throw err + } + } + + if (isSyncing) { + return + } + + return ( + + ) +} + +export default CreateClaimPage diff --git a/frontend/src/app/board/[year]/candidates/[login]/claims/page.tsx b/frontend/src/app/board/[year]/candidates/[login]/claims/page.tsx new file mode 100644 index 0000000000..085f812af2 --- /dev/null +++ b/frontend/src/app/board/[year]/candidates/[login]/claims/page.tsx @@ -0,0 +1,230 @@ +'use client' + +import { useMutation, useQuery } from '@apollo/client/react' +import { Button } from '@heroui/button' +import { useDjangoSession } from 'hooks/useDjangoSession' +import { useParams, useRouter } from 'next/navigation' +import { useEffect, useState } from 'react' +import { FaChevronUp, FaChevronDown } from 'react-icons/fa' +import { FaPlus } from 'react-icons/fa6' +import { handleAppError } from 'app/global-error' +import { GetBoardCandidateDocument } from 'types/__generated__/boardQueries.generated' +import { ReorderBoardCandidateClaimsDocument } from 'types/__generated__/claimMutations.generated' +import { GetBoardCandidateClaimsDocument } from 'types/__generated__/claimQueries.generated' +import { formatDate } from 'utils/dateFormatter' +import AccessDeniedDisplay from 'components/AccessDeniedDisplay' +import ActionButton from 'components/ActionButton' +import LoadingSpinner from 'components/LoadingSpinner' +import SecondaryCard from 'components/SecondaryCard' + +const CandidateClaimsPage = () => { + const router = useRouter() + const { isSyncing, session } = useDjangoSession() + const { login, year } = useParams<{ login: string; year: string }>() + const [draftOrder, setDraftOrder] = useState([]) + const [approvedOrder, setApprovedOrder] = useState([]) + const [reorderClaims] = useMutation(ReorderBoardCandidateClaimsDocument) + + const { + data: graphQLData, + error: graphQLRequestError, + loading: isLoading, + } = useQuery(GetBoardCandidateClaimsDocument, { + skip: !login || !year, + variables: { login: login, year: Number.parseInt(year) }, + }) + + const { data: candidateGraphQLData } = useQuery(GetBoardCandidateDocument, { + variables: { login: login, year: Number.parseInt(year) }, + }) + + const isCandidate = candidateGraphQLData?.boardOfDirectors?.candidate != null + const claims = graphQLData?.boardCandidateClaims ?? [] + + useEffect(() => { + const c = graphQLData?.boardCandidateClaims ?? [] + setDraftOrder(c.filter((claim) => claim.status === 'DRAFT').map((claim) => claim.key)) + setApprovedOrder(c.filter((claim) => claim.status === 'APPROVED').map((claim) => claim.key)) + }, [graphQLData]) + + useEffect(() => { + if (graphQLRequestError) { + handleAppError(graphQLRequestError) + } + }, [graphQLRequestError]) + + if (isSyncing || isLoading) { + return + } + + if (!isCandidate || session?.user?.login !== login) { + return ( + + ) + } + + const originalDraftOrder = claims.filter((c) => c.status === 'DRAFT').map((c) => c.key) + const originalApprovedOrder = claims.filter((c) => c.status === 'APPROVED').map((c) => c.key) + + const draftChanged = draftOrder.join() !== originalDraftOrder.join() + const approvedChanged = approvedOrder.join() !== originalApprovedOrder.join() + + const handleReorder = (key: string, direction: 'up' | 'down', status: 'DRAFT' | 'APPROVED') => { + const setOrder = status === 'DRAFT' ? setDraftOrder : setApprovedOrder + setOrder((prev) => { + const idx = prev.indexOf(key) + const swapIdx = direction === 'up' ? idx - 1 : idx + 1 + if (swapIdx < 0 || swapIdx >= prev.length) return prev + const next = [...prev] + ;[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]] + return next + }) + } + + const handleSave = async (status: string) => { + const keys = status === 'DRAFT' ? draftOrder : approvedOrder + await reorderClaims({ + variables: { input: { keys, year: Number.parseInt(year) } }, + refetchQueries: [ + { + query: GetBoardCandidateClaimsDocument, + variables: { login, year: Number.parseInt(year) }, + }, + ], + }) + } + + const handleCreate = () => router.push(`/board/${year}/candidates/${login}/claims/create`) + const handleClaimClick = (key: string) => + router.push(`/board/${year}/candidates/${login}/claims/${key}`) + + const sectionConfig = [ + { + type: 'DRAFT', + title: 'Draft Claims', + items: [...claims.filter((c) => c.status === 'DRAFT')].sort( + (a, b) => draftOrder.indexOf(a.key) - draftOrder.indexOf(b.key) + ), + }, + { + type: 'SUBMITTED', + title: 'Submitted Claims', + items: claims.filter((c) => c.status === 'SUBMITTED'), + }, + { + type: 'APPROVED', + title: 'Approved Claims', + items: [...claims.filter((c) => c.status === 'APPROVED')].sort( + (a, b) => approvedOrder.indexOf(a.key) - approvedOrder.indexOf(b.key) + ), + }, + { + type: 'REJECTED', + title: 'Rejected Claims', + items: claims.filter((c) => c.status === 'REJECTED'), + }, + { + type: 'WITHDRAWN', + title: 'Withdrawn Claims', + items: claims.filter((c) => c.status === 'WITHDRAWN'), + }, + ] + const orderChanged: Record = { DRAFT: draftChanged, APPROVED: approvedChanged } + + return ( +
+
+
+

Claims

+

Claims you've created

+
+ + + {'Create Claim'} + +
+ {sectionConfig.map(({ type: statusType, title, items }) => ( + + {title} + handleSave(statusType)}>Save Order +
+ ) : ( + title + ) + } + > + {items.length == 0 ? ( +

No {title.toLowerCase()}.

+ ) : ( +
+ {items.map((claim) => ( + + ))} +
+ )} + + ))} + + ) +} + +export default CandidateClaimsPage diff --git a/frontend/src/app/board/[year]/candidates/page.tsx b/frontend/src/app/board/[year]/candidates/page.tsx index 809e596c73..77b0b612a4 100644 --- a/frontend/src/app/board/[year]/candidates/page.tsx +++ b/frontend/src/app/board/[year]/candidates/page.tsx @@ -3,13 +3,14 @@ import { useQuery, useApolloClient } from '@apollo/client/react' import { Button } from '@heroui/button' import dayjs from 'dayjs' import relativeTime from 'dayjs/plugin/relativeTime' +import { useDjangoSession } from 'hooks/useDjangoSession' import millify from 'millify' import Image from 'next/image' import Link from 'next/link' import { useParams } from 'next/navigation' import { useEffect, useState } from 'react' import { FaCode, FaExclamationCircle } from 'react-icons/fa' -import { FaLinkedin, FaCodeBranch, FaCodeMerge } from 'react-icons/fa6' +import { FaLinkedin, FaCodeBranch, FaCodeMerge, FaPenToSquare } from 'react-icons/fa6' import { handleAppError, ErrorDisplay } from 'app/global-error' import { @@ -91,6 +92,7 @@ interface CandidateCardProps { } const CandidateCard = ({ candidate, year }: CandidateCardProps) => { + const { session } = useDjangoSession() const client = useApolloClient() const [snapshot, setSnapshot] = useState(null) const [ledChapters, setLedChapters] = useState([]) @@ -288,6 +290,8 @@ const CandidateCard = ({ candidate, year }: CandidateCardProps) => { // Check if candidate leads any flagship level projects const leadsFlagshipProject = ledProjects.some((project) => project.level === 'flagship') + const isOwnProfile = session?.user?.login === candidate.member?.login + return (