From baf96954ff25006b02d6c40ee1e60773cb4e5377 Mon Sep 17 00:00:00 2001 From: Harman-singh-waraich Date: Tue, 2 Sep 2025 21:18:32 +0530 Subject: [PATCH 1/5] feat: initial-features-implementation --- web/src/context/NewDisputeContext.tsx | 29 ++ web/src/pages/Resolver/Parameters/Court.tsx | 356 ------------------ .../FeatureSelection/JurorEligibility.tsx | 248 ++++++++++++ .../Court/FeatureSelection/ShieldedVoting.tsx | 107 ++++++ .../Court/FeatureSelection/index.tsx | 71 ++++ .../pages/Resolver/Parameters/Court/index.tsx | 92 +++++ 6 files changed, 547 insertions(+), 356 deletions(-) delete mode 100644 web/src/pages/Resolver/Parameters/Court.tsx create mode 100644 web/src/pages/Resolver/Parameters/Court/FeatureSelection/JurorEligibility.tsx create mode 100644 web/src/pages/Resolver/Parameters/Court/FeatureSelection/ShieldedVoting.tsx create mode 100644 web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx create mode 100644 web/src/pages/Resolver/Parameters/Court/index.tsx diff --git a/web/src/context/NewDisputeContext.tsx b/web/src/context/NewDisputeContext.tsx index 5fc109cef..e59c9cfc3 100644 --- a/web/src/context/NewDisputeContext.tsx +++ b/web/src/context/NewDisputeContext.tsx @@ -5,9 +5,13 @@ import { Address } from "viem"; import { DEFAULT_CHAIN } from "consts/chains"; import { klerosCoreAddress } from "hooks/contracts/generated"; +import { useSupportedDisputeKits } from "hooks/queries/useSupportedDisputeKits"; +import { useDisputeKitAddressesAll } from "hooks/useDisputeKitAddresses"; import { useLocalStorage } from "hooks/useLocalStorage"; import { isEmpty, isUndefined } from "utils/index"; +import { DisputeKits } from "src/consts"; + export const MIN_DISPUTE_BATCH_SIZE = 2; export type Answer = { @@ -24,6 +28,12 @@ export type AliasArray = { isValid?: boolean; }; +export type DisputeKitOption = { + text: DisputeKits; + value: number; + gated: boolean; +}; + export type Alias = Record; export interface IDisputeTemplate { answers: Answer[]; @@ -83,6 +93,7 @@ interface INewDisputeContext { setIsBatchCreation: (isBatchCreation: boolean) => void; batchSize: number; setBatchSize: (batchSize?: number) => void; + disputeKitOptions: DisputeKitOption[]; } const getInitialDisputeData = (): IDisputeData => ({ @@ -137,6 +148,22 @@ export const NewDisputeProvider: React.FC<{ children: React.ReactNode }> = ({ ch // eslint-disable-next-line react-hooks/exhaustive-deps }, [location.pathname]); + const { data: supportedDisputeKits } = useSupportedDisputeKits(disputeData.courtId); + const { availableDisputeKits } = useDisputeKitAddressesAll(); + + const disputeKitOptions: DisputeKitOption[] = useMemo(() => { + return ( + supportedDisputeKits?.court?.supportedDisputeKits.map((dk) => { + const text = availableDisputeKits[dk.address.toLowerCase()] ?? ""; + return { + text, + value: Number(dk.id), + gated: text === DisputeKits.Gated || text === DisputeKits.GatedShutter, + }; + }) || [] + ); + }, [supportedDisputeKits, availableDisputeKits]); + const contextValues = useMemo( () => ({ disputeData, @@ -151,6 +178,7 @@ export const NewDisputeProvider: React.FC<{ children: React.ReactNode }> = ({ ch setIsBatchCreation, batchSize, setBatchSize, + disputeKitOptions, }), [ disputeData, @@ -163,6 +191,7 @@ export const NewDisputeProvider: React.FC<{ children: React.ReactNode }> = ({ ch setIsBatchCreation, batchSize, setBatchSize, + disputeKitOptions, ] ); diff --git a/web/src/pages/Resolver/Parameters/Court.tsx b/web/src/pages/Resolver/Parameters/Court.tsx deleted file mode 100644 index df4767f9d..000000000 --- a/web/src/pages/Resolver/Parameters/Court.tsx +++ /dev/null @@ -1,356 +0,0 @@ -import React, { useMemo, useEffect } from "react"; -import styled, { css } from "styled-components"; - -import { AlertMessage, Checkbox, DropdownCascader, DropdownSelect, Field } from "@kleros/ui-components-library"; - -import { DisputeKits } from "consts/index"; -import { IGatedDisputeData, useNewDisputeContext } from "context/NewDisputeContext"; -import { rootCourtToItems, useCourtTree } from "hooks/queries/useCourtTree"; -import { useDisputeKitAddressesAll } from "hooks/useDisputeKitAddresses"; -import { useERC20ERC721Validation, useERC1155Validation } from "hooks/useTokenAddressValidation"; -import { isUndefined } from "utils/index"; - -import { useSupportedDisputeKits } from "queries/useSupportedDisputeKits"; - -import { landscapeStyle } from "styles/landscapeStyle"; -import { responsiveSize } from "styles/responsiveSize"; - -import { StyledSkeleton } from "components/StyledSkeleton"; -import Header from "pages/Resolver/Header"; - -import NavigationButtons from "../NavigationButtons"; - -const Container = styled.div` - display: flex; - flex-direction: column; - align-items: center; - - ${landscapeStyle( - () => css` - padding-bottom: 115px; - ` - )} -`; - -const StyledDropdownCascader = styled(DropdownCascader)` - width: 84vw; - ${landscapeStyle( - () => css` - width: ${responsiveSize(442, 700, 900)}; - ` - )} - > button { - width: 100%; - } -`; - -const AlertMessageContainer = styled.div` - width: 84vw; - ${landscapeStyle( - () => css` - width: ${responsiveSize(442, 700, 900)}; - ` - )} - margin-top: 24px; -`; - -const StyledDropdownSelect = styled(DropdownSelect)` - width: 84vw; - margin-top: 24px; - ${landscapeStyle( - () => css` - width: ${responsiveSize(442, 700, 900)}; - ` - )} -`; - -const StyledField = styled(Field)` - width: 84vw; - margin-top: 24px; - ${landscapeStyle( - () => css` - width: ${responsiveSize(442, 700, 900)}; - ` - )} - > small { - margin-top: 16px; - } -`; - -const StyledCheckbox = styled(Checkbox)` - width: 84vw; - margin-top: 24px; - ${landscapeStyle( - () => css` - width: ${responsiveSize(442, 700, 900)}; - ` - )} -`; - -const ValidationContainer = styled.div` - width: 84vw; - display: flex; - align-items: left; - gap: 8px; - margin-top: 8px; - ${landscapeStyle( - () => css` - width: ${responsiveSize(442, 700, 900)}; - ` - )} -`; - -const ValidationIcon = styled.div<{ $isValid?: boolean | null; $isValidating?: boolean }>` - width: 16px; - height: 16px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - font-size: 12px; - - ${({ $isValidating, $isValid }) => { - if ($isValidating) { - return css` - border: 2px solid ${({ theme }) => theme.stroke}; - border-top-color: ${({ theme }) => theme.primaryBlue}; - animation: spin 1s linear infinite; - - @keyframes spin { - to { - transform: rotate(360deg); - } - } - `; - } - - if ($isValid === true) { - return css` - background-color: ${({ theme }) => theme.success}; - color: white; - &::after { - content: "✓"; - } - `; - } - - if ($isValid === false) { - return css` - background-color: ${({ theme }) => theme.error}; - color: white; - &::after { - content: "✗"; - } - `; - } - - return css` - display: none; - `; - }} -`; - -const ValidationMessage = styled.small<{ $isError?: boolean }>` - color: ${({ $isError, theme }) => ($isError ? theme.error : theme.success)}; - font-size: 14px; - font-style: italic; - font-weight: normal; -`; - -const StyledFieldWithValidation = styled(StyledField)<{ $isValid?: boolean | null }>` - > input { - border-color: ${({ $isValid, theme }) => { - if ($isValid === true) return theme.success; - if ($isValid === false) return theme.error; - return "inherit"; - }}; - } -`; - -const Court: React.FC = () => { - const { disputeData, setDisputeData } = useNewDisputeContext(); - const { data: courtTree } = useCourtTree(); - const { data: supportedDisputeKits } = useSupportedDisputeKits(disputeData.courtId); - const items = useMemo(() => !isUndefined(courtTree?.court) && [rootCourtToItems(courtTree.court)], [courtTree]); - const { availableDisputeKits } = useDisputeKitAddressesAll(); - - const disputeKitOptions = useMemo(() => { - return ( - supportedDisputeKits?.court?.supportedDisputeKits.map((dk) => { - const text = availableDisputeKits[dk.address.toLowerCase()] ?? ""; - return { - text, - value: Number(dk.id), - gated: text === DisputeKits.Gated || text === DisputeKits.GatedShutter, - }; - }) || [] - ); - }, [supportedDisputeKits, availableDisputeKits]); - - const isGatedDisputeKit = useMemo(() => { - const options = disputeKitOptions.find((dk) => String(dk.value) === String(disputeData.disputeKitId)); - return options?.gated ?? false; - }, [disputeKitOptions, disputeData.disputeKitId]); - - // Token validation for token gate address (conditional based on ERC1155 checkbox) - const tokenGateAddress = (disputeData.disputeKitData as IGatedDisputeData)?.tokenGate ?? ""; - const isERC1155 = (disputeData.disputeKitData as IGatedDisputeData)?.isERC1155 ?? false; - const validationEnabled = isGatedDisputeKit && !!tokenGateAddress.trim(); - - const { - isValidating: isValidatingERC20, - isValid: isValidERC20, - error: validationErrorERC20, - } = useERC20ERC721Validation({ - address: tokenGateAddress, - enabled: validationEnabled && !isERC1155, - }); - - const { - isValidating: isValidatingERC1155, - isValid: isValidERC1155, - error: validationErrorERC1155, - } = useERC1155Validation({ - address: tokenGateAddress, - enabled: validationEnabled && isERC1155, - }); - - // Combine validation results based on token type - const isValidating = isERC1155 ? isValidatingERC1155 : isValidatingERC20; - const isValidToken = isERC1155 ? isValidERC1155 : isValidERC20; - const validationError = isERC1155 ? validationErrorERC1155 : validationErrorERC20; - - // Update validation state in dispute context - useEffect(() => { - if (isGatedDisputeKit && disputeData.disputeKitData) { - const currentData = disputeData.disputeKitData as IGatedDisputeData; - if (currentData.isTokenGateValid !== isValidToken) { - setDisputeData({ - ...disputeData, - disputeKitData: { ...currentData, isTokenGateValid: isValidToken }, - }); - } - } - }, [isValidToken, isGatedDisputeKit, disputeData.disputeKitData, setDisputeData]); - - const handleCourtChange = (courtId: string) => { - if (disputeData.courtId !== courtId) { - setDisputeData({ ...disputeData, courtId, disputeKitId: undefined }); - } - }; - - const handleDisputeKitChange = (newValue: string | number) => { - const options = disputeKitOptions.find((dk) => String(dk.value) === String(newValue)); - const gatedDisputeKitData: IGatedDisputeData | undefined = - (options?.gated ?? false) - ? { - type: "gated", - tokenGate: "", - isERC1155: false, - tokenId: "0", - } - : undefined; - setDisputeData({ ...disputeData, disputeKitId: Number(newValue), disputeKitData: gatedDisputeKitData }); - }; - - const handleTokenAddressChange = (event: React.ChangeEvent) => { - const currentData = disputeData.disputeKitData as IGatedDisputeData; - setDisputeData({ - ...disputeData, - disputeKitData: { - ...currentData, - tokenGate: event.target.value, - isTokenGateValid: null, // Reset validation state when address changes - }, - }); - }; - - const handleERC1155TokenChange = (event: React.ChangeEvent) => { - const currentData = disputeData.disputeKitData as IGatedDisputeData; - setDisputeData({ - ...disputeData, - disputeKitData: { - ...currentData, - isERC1155: event.target.checked, - isTokenGateValid: null, // Reset validation state when token type changes - }, - }); - }; - - const handleTokenIdChange = (event: React.ChangeEvent) => { - const currentData = disputeData.disputeKitData as IGatedDisputeData; - setDisputeData({ - ...disputeData, - disputeKitData: { ...currentData, tokenId: event.target.value }, - }); - }; - - return ( - -
- {items ? ( - typeof path === "string" && handleCourtChange(path.split("/").pop()!)} - placeholder="Select Court" - value={`/courts/${disputeData.courtId}`} - /> - ) : ( - - )} - {disputeData?.courtId && disputeKitOptions.length > 0 && ( - - )} - {isGatedDisputeKit && ( - <> - - {tokenGateAddress.trim() !== "" && ( - - - - {isValidating && `Validating ${isERC1155 ? "ERC-1155" : "ERC-20 or ERC-721"} token...`} - {validationError && validationError} - {isValidToken === true && `Valid ${isERC1155 ? "ERC-1155" : "ERC-20 or ERC-721"} token`} - - - )} - - {(disputeData.disputeKitData as IGatedDisputeData)?.isERC1155 && ( - - )} - - )} - - - - - - ); -}; - -export default Court; diff --git a/web/src/pages/Resolver/Parameters/Court/FeatureSelection/JurorEligibility.tsx b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/JurorEligibility.tsx new file mode 100644 index 000000000..3e1febbd4 --- /dev/null +++ b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/JurorEligibility.tsx @@ -0,0 +1,248 @@ +import React, { useEffect, useMemo, useState } from "react"; +import styled from "styled-components"; + +import { Field, Radio } from "@kleros/ui-components-library"; + +import { IGatedDisputeData, useNewDisputeContext } from "context/NewDisputeContext"; +import { useERC1155Validation, useERC20ERC721Validation } from "hooks/useTokenAddressValidation"; + +import { DisputeKits } from "src/consts"; +import { isUndefined } from "src/utils"; + +import WithHelpTooltip from "components/WithHelpTooltip"; + +const Container = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; + align-items: start; + padding-top: 16px; +`; + +const HeaderContainer = styled.div` + width: 100%; + padding-top: 16px; +`; + +const Header = styled.h2` + font-size: 16px; + font-weight: 600; + margin: 0; +`; + +const SubTitle = styled.p` + font-size: 14px; + color: ${({ theme }) => theme.secondaryText}; + padding: 0; + margin: 0; +`; + +const FieldContainer = styled.div` + width: 100%; + padding-left: 32px; +`; +const StyledField = styled(Field)` + width: 100%; + margin-top: 8px; + margin-bottom: 32px; + > small { + margin-top: 16px; + } +`; + +const StyledRadio = styled(Radio)` + font-size: 14px; +`; + +enum EligibilityType { + Classic, + GatedERC20, + GatedERC1155, +} + +const JurorEligibility: React.FC = () => { + const [eligibilityType, setEligibilityType] = useState(); + const { disputeData, setDisputeData, disputeKitOptions } = useNewDisputeContext(); + + const tokenGateAddress = (disputeData.disputeKitData as IGatedDisputeData)?.tokenGate ?? ""; + const validationEnabled = !isUndefined(tokenGateAddress) && tokenGateAddress.trim() !== ""; + const isERC1155 = eligibilityType === EligibilityType.GatedERC1155; + const { + isValidating: isValidatingERC20, + isValid: isValidERC20, + error: validationErrorERC20, + } = useERC20ERC721Validation({ + address: tokenGateAddress, + enabled: validationEnabled && !isERC1155, + }); + + const { + isValidating: isValidatingERC1155, + isValid: isValidERC1155, + error: validationErrorERC1155, + } = useERC1155Validation({ + address: tokenGateAddress, + enabled: validationEnabled && isERC1155, + }); + + // Combine validation results based on token type + const isValidating = isERC1155 ? isValidatingERC1155 : isValidatingERC20; + const isValidToken = isERC1155 ? isValidERC1155 : isValidERC20; + const validationError = isERC1155 ? validationErrorERC1155 : validationErrorERC20; + + const [validationMessage, variant] = useMemo(() => { + if (isValidating) return [`Validating ${isERC1155 ? "ERC-1155" : "ERC-20 or ERC-721"} token...`, "info"]; + else if (validationError) return [validationError, "error"]; + else if (isValidToken === true) return [`Valid ${isERC1155 ? "ERC-1155" : "ERC-20 or ERC-721"} token`, "success"]; + else return [undefined, "info"]; + }, [isValidating, validationError, isERC1155, isValidToken]); + + // Update validation state in dispute context + useEffect(() => { + if (disputeData.disputeKitData) { + const currentData = disputeData.disputeKitData as IGatedDisputeData; + if (currentData.isTokenGateValid !== isValidToken) { + setDisputeData({ + ...disputeData, + disputeKitData: { ...currentData, isTokenGateValid: isValidToken }, + }); + } + } + }, [isValidToken, disputeData.disputeKitData, setDisputeData]); + + const handleTokenAddressChange = (event: React.ChangeEvent) => { + const currentData = disputeData.disputeKitData as IGatedDisputeData; + setDisputeData({ + ...disputeData, + disputeKitData: { + ...currentData, + tokenGate: event.target.value, + isTokenGateValid: null, // Reset validation state when address changes + }, + }); + }; + + const handleTokenIdChange = (event: React.ChangeEvent) => { + const currentData = disputeData.disputeKitData as IGatedDisputeData; + setDisputeData({ + ...disputeData, + disputeKitData: { ...currentData, tokenId: event.target.value }, + }); + }; + + useEffect(() => { + if (eligibilityType === EligibilityType.Classic) { + const disputeKit = disputeKitOptions.find((dk) => dk.text === DisputeKits.Classic); + + setDisputeData({ ...disputeData, disputeKitId: disputeKit?.value, disputeKitData: undefined }); + } else if (eligibilityType === EligibilityType.GatedERC20 || eligibilityType === EligibilityType.GatedERC1155) { + const disputeKitGated = disputeKitOptions.find((dk) => dk.text === DisputeKits.Gated); + const disputeKitGatedShutter = disputeKitOptions.find((dk) => dk.text === DisputeKits.GatedShutter); + + const currentDisputeKit = disputeKitOptions.find((dk) => dk.value === disputeData.disputeKitId); + + const disputeKitData: IGatedDisputeData = { + ...(disputeData.disputeKitData as IGatedDisputeData), + type: "gated", + isERC1155: eligibilityType === EligibilityType.GatedERC1155, + }; + // classic is selected, so here we change it to TokenGated + if (currentDisputeKit?.text === DisputeKits.Classic) { + setDisputeData({ + ...disputeData, + disputeKitId: disputeKitGated?.value, + disputeKitData, + }); + } + // shutter is selected, so here we change it to TokenGatedShutter + else if (currentDisputeKit?.text === DisputeKits.Shutter) { + setDisputeData({ + ...disputeData, + disputeKitId: disputeKitGatedShutter?.value, + disputeKitData, + }); + } else { + setDisputeData({ + ...disputeData, + disputeKitId: disputeKitGated?.value, + disputeKitData, + }); + } + } + }, [eligibilityType, disputeKitOptions]); + + return ( + + +
Jurors Eligibility
+ Who can be selected as a juror?. +
+ + setEligibilityType(EligibilityType.Classic)} + checked={eligibilityType === EligibilityType.Classic} + /> + + + setEligibilityType(EligibilityType.GatedERC20)} + checked={eligibilityType === EligibilityType.GatedERC20} + /> + + {eligibilityType === EligibilityType.GatedERC20 ? ( + + + + ) : null} + + setEligibilityType(EligibilityType.GatedERC1155)} + checked={eligibilityType === EligibilityType.GatedERC1155} + /> + + {eligibilityType === EligibilityType.GatedERC1155 ? ( + + + + + ) : null} +
+ ); +}; + +export default JurorEligibility; diff --git a/web/src/pages/Resolver/Parameters/Court/FeatureSelection/ShieldedVoting.tsx b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/ShieldedVoting.tsx new file mode 100644 index 000000000..85a5caf98 --- /dev/null +++ b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/ShieldedVoting.tsx @@ -0,0 +1,107 @@ +import React, { useEffect, useState } from "react"; +import styled from "styled-components"; + +import { Radio } from "@kleros/ui-components-library"; + +import { useNewDisputeContext } from "context/NewDisputeContext"; + +import { DisputeKits } from "src/consts"; + +import WithHelpTooltip from "components/WithHelpTooltip"; + +const VotingContainer = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; + align-items: start; + border-bottom: 1px solid ${({ theme }) => theme.stroke}; + padding-bottom: 16px; +`; + +const VotingHeaderContainer = styled.div` + width: 100%; + padding-top: 16px; +`; + +const VotingHeader = styled.h2` + font-size: 16px; + font-weight: 600; + margin: 0; +`; + +const VotingSubTitle = styled.p` + font-size: 14px; + color: ${({ theme }) => theme.secondaryText}; + padding: 0; + margin: 0; +`; + +const StyledRadio = styled(Radio)` + font-size: 14px; +`; + +enum VotingType { + OneStep = "Shutter", + TwoStep = "Classic", +} + +// Selects one of Classic or Shutter DisputeKit +const ShieldedVoting: React.FC = () => { + const [votingType, setVotingType] = useState(); + const { disputeData, setDisputeData, disputeKitOptions } = useNewDisputeContext(); + + // we try to keep this structure among feature components + // keep in mind the other options that will need to be disabled if a certain feature is selected + useEffect(() => { + // disable TokenGatedShutter if selected, if TokenGated Selected do nothing + if (votingType === VotingType.TwoStep && disputeData.disputeKitData?.type !== "gated") { + const disputeKit = disputeKitOptions.find((dk) => dk.text === DisputeKits.Classic); + + setDisputeData({ ...disputeData, disputeKitId: disputeKit?.value }); + } + + if (votingType === VotingType.OneStep) { + // user has already selected TokenGated, so selecting Shutter here, we need to select TokenGatedShutter + if (disputeData.disputeKitData?.type === "gated") { + const disputeKit = disputeKitOptions.find((dk) => dk.text === DisputeKits.GatedShutter); + + // no need to set DisputeKitData, will already be set by JurorEligibility + setDisputeData({ ...disputeData, disputeKitId: disputeKit?.value }); + } else { + const disputeKit = disputeKitOptions.find((dk) => dk.text === DisputeKits.Shutter); + + setDisputeData({ ...disputeData, disputeKitId: disputeKit?.value }); + } + } + }, [votingType]); + + return ( + + + Shielded Voting + It hides the jurors' votes until the end of the voting period. + + + setVotingType(VotingType.OneStep)} + checked={votingType === VotingType.OneStep} + /> + + + setVotingType(VotingType.TwoStep)} + checked={votingType === VotingType.TwoStep} + /> + + + ); +}; + +export default ShieldedVoting; diff --git a/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx new file mode 100644 index 000000000..8fdb8d7af --- /dev/null +++ b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx @@ -0,0 +1,71 @@ +import React, { useEffect, useMemo, useState } from "react"; +import styled from "styled-components"; + +import { Card } from "@kleros/ui-components-library"; + +import { useNewDisputeContext } from "context/NewDisputeContext"; + +import { DisputeKits } from "src/consts"; + +import JurorEligibility from "./JurorEligibility"; +import ShieldedVoting from "./ShieldedVoting"; + +const Container = styled(Card)` + width: 100%; + height: auto; + padding: 32px; + display: flex; + flex-direction: column; + margin-top: 16px; +`; + +const SubTitle = styled.p` + font-size: 14px; + color: ${({ theme }) => theme.secondaryBlue}; + padding: 0; + margin: 0; +`; + +const FeatureSelection: React.FC = () => { + const [showFeatures, setShowFeatures] = useState(false); + const { disputeKitOptions, disputeData, setDisputeData } = useNewDisputeContext(); + + // if supported dispute kits have Classic and Shutter + const showVotingFeatures = useMemo(() => { + return ( + disputeKitOptions.some((dk) => dk.text === DisputeKits.Classic) && + disputeKitOptions.some((dk) => dk.text === DisputeKits.Shutter) + ); + }, [disputeKitOptions]); + + // if supported dispute kits have Classic, TokenGated, TokenGatedShutter + const showEligibilityFeatures = useMemo(() => { + return ( + disputeKitOptions.some((dk) => dk.text === DisputeKits.Classic) && + disputeKitOptions.some((dk) => dk.text === DisputeKits.GatedShutter) && + disputeKitOptions.some((dk) => dk.text === DisputeKits.Gated) + ); + }, [disputeKitOptions]); + + useEffect(() => { + // there's only one, NOTE: what happens when here only TokenGated is support? we need the value + if (disputeKitOptions.length === 1) { + const disputeKit = disputeKitOptions[0]; + setDisputeData({ ...disputeData, disputeKitId: disputeKit.value }); + setShowFeatures(false); + } else { + setShowFeatures(true); + } + }, [disputeKitOptions]); + + if (!showFeatures) return null; + return ( + + Additional features available in this court: + {showVotingFeatures ? : null} + {showEligibilityFeatures ? : null} + + ); +}; + +export default FeatureSelection; diff --git a/web/src/pages/Resolver/Parameters/Court/index.tsx b/web/src/pages/Resolver/Parameters/Court/index.tsx new file mode 100644 index 000000000..4e50c0bdc --- /dev/null +++ b/web/src/pages/Resolver/Parameters/Court/index.tsx @@ -0,0 +1,92 @@ +import React, { useMemo } from "react"; +import styled, { css } from "styled-components"; + +import { AlertMessage, DropdownCascader } from "@kleros/ui-components-library"; + +import { useNewDisputeContext } from "context/NewDisputeContext"; +import { rootCourtToItems, useCourtTree } from "hooks/queries/useCourtTree"; +import { isUndefined } from "utils/index"; + +import { landscapeStyle } from "styles/landscapeStyle"; +import { responsiveSize } from "styles/responsiveSize"; + +import { StyledSkeleton } from "components/StyledSkeleton"; +import Header from "pages/Resolver/Header"; + +import NavigationButtons from "../../NavigationButtons"; + +import FeatureSelection from "./FeatureSelection"; + +const Container = styled.div` + display: flex; + flex-direction: column; + align-items: center; + + ${landscapeStyle( + () => css` + padding-bottom: 115px; + ` + )} +`; + +const StyledDropdownCascader = styled(DropdownCascader)` + width: 84vw; + ${landscapeStyle( + () => css` + width: ${responsiveSize(442, 700, 900)}; + ` + )} + > button { + width: 100%; + } +`; + +const AlertMessageContainer = styled.div` + width: 84vw; + ${landscapeStyle( + () => css` + width: ${responsiveSize(442, 700, 900)}; + ` + )} + margin-top: 24px; +`; + +const Court: React.FC = () => { + const { disputeData, setDisputeData } = useNewDisputeContext(); + const { data: courtTree } = useCourtTree(); + const items = useMemo(() => !isUndefined(courtTree?.court) && [rootCourtToItems(courtTree.court)], [courtTree]); + + const handleCourtChange = (courtId: string) => { + if (disputeData.courtId !== courtId) { + setDisputeData({ ...disputeData, courtId, disputeKitId: undefined }); + } + }; + + return ( + +
+ {items ? ( + typeof path === "string" && handleCourtChange(path.split("/").pop()!)} + placeholder="Select Court" + value={`/courts/${disputeData.courtId}`} + /> + ) : ( + + )} + + + + + + + + ); +}; + +export default Court; From 83a21bd426d078d26c731f004852a092eb477c8e Mon Sep 17 00:00:00 2001 From: Harman-singh-waraich Date: Tue, 9 Sep 2025 16:21:28 +0530 Subject: [PATCH 2/5] feat(web): dynamic-dispute-kit-feature-selection --- .../DisputeFeatures/Features/ClassicVote.tsx | 31 +++ .../DisputeFeatures/Features/GatedErc1155.tsx | 121 +++++++++ .../DisputeFeatures/Features/GatedErc20.tsx | 105 ++++++++ .../DisputeFeatures/Features/index.tsx | 50 ++++ .../components/DisputeFeatures/GroupsUI.tsx | 53 ++++ web/src/consts/disputeFeature.ts | 242 +++++++++++++++++ web/src/context/NewDisputeContext.tsx | 29 +- web/src/hooks/queries/useCourtDetails.ts | 1 + .../FeatureSelection/FeatureSkeleton.tsx | 47 ++++ .../FeatureSelection/JurorEligibility.tsx | 248 ------------------ .../Court/FeatureSelection/ShieldedVoting.tsx | 107 -------- .../Court/FeatureSelection/index.tsx | 166 +++++++++--- .../pages/Resolver/Parameters/Court/index.tsx | 7 +- 13 files changed, 793 insertions(+), 414 deletions(-) create mode 100644 web/src/components/DisputeFeatures/Features/ClassicVote.tsx create mode 100644 web/src/components/DisputeFeatures/Features/GatedErc1155.tsx create mode 100644 web/src/components/DisputeFeatures/Features/GatedErc20.tsx create mode 100644 web/src/components/DisputeFeatures/Features/index.tsx create mode 100644 web/src/components/DisputeFeatures/GroupsUI.tsx create mode 100644 web/src/consts/disputeFeature.ts create mode 100644 web/src/pages/Resolver/Parameters/Court/FeatureSelection/FeatureSkeleton.tsx delete mode 100644 web/src/pages/Resolver/Parameters/Court/FeatureSelection/JurorEligibility.tsx delete mode 100644 web/src/pages/Resolver/Parameters/Court/FeatureSelection/ShieldedVoting.tsx diff --git a/web/src/components/DisputeFeatures/Features/ClassicVote.tsx b/web/src/components/DisputeFeatures/Features/ClassicVote.tsx new file mode 100644 index 000000000..efc780a02 --- /dev/null +++ b/web/src/components/DisputeFeatures/Features/ClassicVote.tsx @@ -0,0 +1,31 @@ +import React from "react"; + +import { useNewDisputeContext } from "context/NewDisputeContext"; + +import { useCourtDetails } from "queries/useCourtDetails"; + +import WithHelpTooltip from "components/WithHelpTooltip"; + +import { RadioInput, StyledRadio } from "."; + +const ClassicVote: React.FC = (props) => { + const { disputeData } = useNewDisputeContext(); + const { data: courtData } = useCourtDetails(disputeData.courtId); + const isCommitEnabled = Boolean(courtData?.court?.hiddenVotes); + return ( + + + + ); +}; + +export default ClassicVote; diff --git a/web/src/components/DisputeFeatures/Features/GatedErc1155.tsx b/web/src/components/DisputeFeatures/Features/GatedErc1155.tsx new file mode 100644 index 000000000..1467379ca --- /dev/null +++ b/web/src/components/DisputeFeatures/Features/GatedErc1155.tsx @@ -0,0 +1,121 @@ +import React, { useEffect, useMemo } from "react"; +import styled from "styled-components"; + +import { Field } from "@kleros/ui-components-library"; + +import { IGatedDisputeData, useNewDisputeContext } from "context/NewDisputeContext"; +import { useERC1155Validation } from "hooks/useTokenAddressValidation"; + +import { isUndefined } from "src/utils"; + +import WithHelpTooltip from "components/WithHelpTooltip"; + +import { RadioInput, StyledRadio } from "."; + +const FieldContainer = styled.div` + width: 100%; + padding-left: 32px; +`; + +const StyledField = styled(Field)` + width: 100%; + margin-top: 8px; + margin-bottom: 32px; + > small { + margin-top: 16px; + } +`; + +const GatedErc1155: React.FC = (props) => { + const { disputeData, setDisputeData } = useNewDisputeContext(); + + const tokenGateAddress = (disputeData.disputeKitData as IGatedDisputeData)?.tokenGate ?? ""; + const validationEnabled = !isUndefined(tokenGateAddress) && tokenGateAddress.trim() !== ""; + + const { + isValidating, + isValid, + error: validationError, + } = useERC1155Validation({ + address: tokenGateAddress, + enabled: validationEnabled, + }); + + const [validationMessage, variant] = useMemo(() => { + if (isValidating) return [`Validating ERC-1155 token...`, "info"]; + else if (validationError) return [validationError, "error"]; + else if (isValid === true) return [`Valid ERC-1155 token`, "success"]; + else return [undefined, "info"]; + }, [isValidating, validationError, isValid]); + + // Update validation state in dispute context + useEffect(() => { + // this can clash with erc20 check + if (!props.checked) return; + // Only update if isValid has actually changed + if (disputeData.disputeKitData) { + const currentData = disputeData.disputeKitData as IGatedDisputeData; + + if (currentData.isTokenGateValid !== isValid) { + setDisputeData({ + ...disputeData, + disputeKitData: { ...currentData, isTokenGateValid: isValid }, + }); + } + } + }, [isValid, setDisputeData, props.checked]); + + const handleTokenAddressChange = (event: React.ChangeEvent) => { + const currentData = disputeData.disputeKitData as IGatedDisputeData; + + setDisputeData({ + ...disputeData, + disputeKitData: { + ...currentData, + tokenGate: event.target.value, + isTokenGateValid: null, // Reset validation state when address changes + }, + }); + }; + + const handleTokenIdChange = (event: React.ChangeEvent) => { + const currentData = disputeData.disputeKitData as IGatedDisputeData; + // DEV: we only update the tokenGate value here, and the disputeKidID, + // and type are still handled in Resolver/Court/FeatureSelection.tsx + setDisputeData({ + ...disputeData, + disputeKitData: { ...currentData, tokenId: event.target.value }, + }); + }; + + return ( + <> + + + + {props.checked ? ( + + + + + ) : null} + + ); +}; + +export default GatedErc1155; diff --git a/web/src/components/DisputeFeatures/Features/GatedErc20.tsx b/web/src/components/DisputeFeatures/Features/GatedErc20.tsx new file mode 100644 index 000000000..748f320a2 --- /dev/null +++ b/web/src/components/DisputeFeatures/Features/GatedErc20.tsx @@ -0,0 +1,105 @@ +import React, { useEffect, useMemo } from "react"; +import styled from "styled-components"; + +import { Field } from "@kleros/ui-components-library"; + +import { IGatedDisputeData, useNewDisputeContext } from "context/NewDisputeContext"; +import { useERC20ERC721Validation } from "hooks/useTokenAddressValidation"; + +import { isUndefined } from "src/utils"; + +import WithHelpTooltip from "components/WithHelpTooltip"; + +import { RadioInput, StyledRadio } from "."; + +const FieldContainer = styled.div` + width: 100%; + padding-left: 32px; +`; + +const StyledField = styled(Field)` + width: 100%; + margin-top: 8px; + margin-bottom: 32px; + > small { + margin-top: 16px; + } +`; + +const GatedErc20: React.FC = (props) => { + const { disputeData, setDisputeData } = useNewDisputeContext(); + + const tokenGateAddress = (disputeData.disputeKitData as IGatedDisputeData)?.tokenGate ?? ""; + const validationEnabled = !isUndefined(tokenGateAddress) && tokenGateAddress.trim() !== ""; + + const { + isValidating, + isValid, + error: validationError, + } = useERC20ERC721Validation({ + address: tokenGateAddress, + enabled: validationEnabled && props.checked, + }); + + const [validationMessage, variant] = useMemo(() => { + if (isValidating) return [`Validating ERC-20 or ERC-721 token...`, "info"]; + else if (validationError) return [validationError, "error"]; + else if (isValid === true) return [`Valid ERC-20 or ERC-721 token`, "success"]; + else return [undefined, "info"]; + }, [isValidating, validationError, isValid]); + + // Update validation state in dispute context + useEffect(() => { + // this can clash with erc1155 check + if (!props.checked) return; + if (disputeData.disputeKitData) { + const currentData = disputeData.disputeKitData as IGatedDisputeData; + + if (currentData.isTokenGateValid !== isValid) { + setDisputeData({ + ...disputeData, + disputeKitData: { ...currentData, isTokenGateValid: isValid }, + }); + } + } + }, [isValid, setDisputeData, props.checked]); + + const handleTokenAddressChange = (event: React.ChangeEvent) => { + const currentData = disputeData.disputeKitData as IGatedDisputeData; + // DEV: we only update the tokenGate value here, and the disputeKidID, + // and type are still handled in Resolver/Court/FeatureSelection.tsx + setDisputeData({ + ...disputeData, + disputeKitData: { + ...currentData, + tokenGate: event.target.value, + isTokenGateValid: null, // Reset validation state when address changes + }, + }); + }; + + return ( + <> + + + + {props.checked ? ( + + + + ) : null} + + ); +}; + +export default GatedErc20; diff --git a/web/src/components/DisputeFeatures/Features/index.tsx b/web/src/components/DisputeFeatures/Features/index.tsx new file mode 100644 index 000000000..5c3157a52 --- /dev/null +++ b/web/src/components/DisputeFeatures/Features/index.tsx @@ -0,0 +1,50 @@ +import React from "react"; +import styled from "styled-components"; + +import { Radio } from "@kleros/ui-components-library"; + +import { Features } from "consts/disputeFeature"; + +import WithHelpTooltip from "components/WithHelpTooltip"; + +import ClassicVote from "./ClassicVote"; +import GatedErc1155 from "./GatedErc1155"; +import GatedErc20 from "./GatedErc20"; + +export type RadioInput = { + name: string; + value: Features; + checked: boolean; + disabled: boolean; + onClick: () => void; +}; + +export type FeatureUI = (props: RadioInput) => JSX.Element; + +export const StyledRadio = styled(Radio)` + font-size: 14px; + color: ${({ theme, disabled }) => (disabled ? theme.secondaryText : theme.primaryText)}; + opacity: ${({ disabled }) => (disabled ? "0.7" : 1)}; +`; + +export const FeatureUIs: Record = { + [Features.ShieldedVote]: (props: RadioInput) => ( + + + + ), + + [Features.ClassicVote]: (props: RadioInput) => , + + [Features.ClassicEligibility]: (props: RadioInput) => ( + + ), + + [Features.GatedErc20]: (props: RadioInput) => , + + [Features.GatedErc1155]: (props: RadioInput) => , +}; diff --git a/web/src/components/DisputeFeatures/GroupsUI.tsx b/web/src/components/DisputeFeatures/GroupsUI.tsx new file mode 100644 index 000000000..4a2714287 --- /dev/null +++ b/web/src/components/DisputeFeatures/GroupsUI.tsx @@ -0,0 +1,53 @@ +import React from "react"; +import styled from "styled-components"; + +import { Group } from "consts/disputeFeature"; + +const Container = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; + align-items: start; + padding-bottom: 16px; +`; + +const HeaderContainer = styled.div` + width: 100%; + padding-top: 16px; +`; + +const Header = styled.h2` + font-size: 16px; + font-weight: 600; + margin: 0; +`; + +const SubTitle = styled.p` + font-size: 14px; + color: ${({ theme }) => theme.secondaryText}; + padding: 0; + margin: 0; +`; + +export type GroupUI = (props: { children: JSX.Element }) => JSX.Element; +export const GroupsUI: Record = { + [Group.Voting]: ({ children }) => ( + + +
Shielded Voting
+ It hides the jurors' votes until the end of the voting period. +
+ {children} +
+ ), + [Group.Eligibility]: ({ children }) => ( + + +
Jurors Eligibility
+ Who can be selected as a juror?. +
+ {children} +
+ ), +}; diff --git a/web/src/consts/disputeFeature.ts b/web/src/consts/disputeFeature.ts new file mode 100644 index 000000000..a6f257c48 --- /dev/null +++ b/web/src/consts/disputeFeature.ts @@ -0,0 +1,242 @@ +export enum Group { + Voting = "Voting", + Eligibility = "Eligibility", +} + +/** A single feature, grouped into categories. has to be atomic. + * For gated, we split them into atomic erc20 and erc1155 */ +export enum Features { + ShieldedVote = "shieldedVote", + ClassicVote = "classicVote", + ClassicEligibility = "classicEligibility", + GatedErc20 = "gatedErc20", + GatedErc1155 = "gatedErc1155", +} + +/** Group of features (like radio buttons per category) */ +export type FeatureGroups = Record; + +/** Definition of a dispute kit */ +export interface DisputeKit { + id: number; + /** + * The feature sets this kit supports. + * Each array represents a valid configuration, and has to be 1:1, + * if either subset matches the selected feature array this dispute kit is selected + */ + featureSets: Features[][]; + /** + * If true => kit accepts any subset of a featureSet. + * If false => must be an exact match. + */ + allowSubset?: boolean; + + type: "general" | "gated"; +} + +export type DisputeKits = DisputeKit[]; + +// groups +// withing a group only one feature can be selected, we deselect the other one when a new one is selected +// we don't use these directly in here for utils because these need to be filtered based on court selection. +// NOTE: a feature cannot appear in more than one Group +// DEV: the order of features in array , determine the order the radios appear on UI +export const featureGroups: FeatureGroups = { + [Group.Voting]: [Features.ClassicVote, Features.ShieldedVote], + [Group.Eligibility]: [Features.ClassicEligibility, Features.GatedErc20, Features.GatedErc1155], +}; + +// dispute kits +// each array is a unique match, for multiple combinations, add more arrays. +export const disputeKits: DisputeKits = [ + { + id: 1, + featureSets: [[Features.ClassicVote, Features.ClassicEligibility]], + type: "general", + }, // strict + { id: 2, featureSets: [[Features.ShieldedVote, Features.ClassicEligibility]], type: "general" }, // strict + { + id: 3, + // strictly keep the common feature in front and in order. + featureSets: [ + [Features.ClassicVote, Features.GatedErc20], + [Features.ClassicVote, Features.GatedErc1155], + ], + allowSubset: true, + type: "gated", + }, + { + id: 4, + featureSets: [ + [Features.ShieldedVote, Features.GatedErc20], + [Features.ShieldedVote, Features.GatedErc1155], + ], + allowSubset: true, + type: "gated", + }, +]; + +/** Canonical string for a feature set (order-independent) */ +function normalize(features: Features[]): string { + return [...features].sort().join("|"); +} + +/** Check if `a` is exactly the same as `b` (order-insensitive) */ +function arraysEqual(a: Features[], b: Features[]): boolean { + return normalize(a) === normalize(b); +} + +/** Check if `a` is a superset of `b` */ +function includesAll(a: Features[], b: Features[]): boolean { + const setA = new Set(a); + return b.every((x) => setA.has(x)); +} + +/** + * Toggle a feature, ensuring radio behavior per group + * @returns the updated selected features array + */ +export function toggleFeature(selected: Features[], feature: Features, groups: FeatureGroups): Features[] { + const group = Object.entries(groups).find(([_, feats]) => feats.includes(feature)); + if (!group) return selected; // not found in any group + const [_, features] = group; // <= this is the group we are making selection in currently + + // Remove any feature from this group + const withoutGroup = selected.filter((f) => !features.includes(f)); + + // If it was already selected => deselect + if (selected.includes(feature)) { + return withoutGroup; + } + + // Otherwise => select this one + return [...withoutGroup, feature]; +} + +/** + * Find dispute kits that match the given selection + */ +export function findMatchingKits(selected: Features[], kits: DisputeKits): DisputeKit[] { + return kits.filter((kit) => + kit.featureSets.some( + (set) => + kit.allowSubset + ? includesAll(selected, set) // kit allows subset + : arraysEqual(set, selected) // strict exact match + ) + ); +} + +/** + * Ensures that the current selection of features is always in a valid state. + * We use this just to make sure we don't accidently allow user to select an invalid state in handleToggle + * + * "Valid" means: + * - Either matches at least one dispute kit fully, OR + * - Could still be completed into a valid kit (prefix of a valid set). + * + * If the selection is invalid: + * 1. Try removing one conflicting group to recover validity. + * 2. If nothing works, fallback to keeping only the last clicked feature. + * + * @returns A corrected selection that is guaranteed to be valid. + */ +export function ensureValidSmart(selected: Features[], groups: FeatureGroups, kits: DisputeKits): Features[] { + // --- Helper: checks if a candidate is valid or could still become valid --- + function isValidOrPrefix(candidate: Features[]): boolean { + return ( + findMatchingKits(candidate, kits).length > 0 || + kits.some((kit) => + kit.featureSets.some( + (set) => candidate.every((f) => set.includes(f)) // prefix check + ) + ) + ); + } + + // --- Case 1: Current selection is already valid --- + if (isValidOrPrefix(selected)) { + return selected; + } + + // --- Case 2: Try fixing by removing one group at a time --- + for (const [_, features] of Object.entries(groups)) { + const withoutGroup = selected.filter((f) => !features.includes(f)); + if (isValidOrPrefix(withoutGroup)) { + return withoutGroup; + } + } + + // --- Case 3: Fallback to only the last picked feature --- + return selected.length > 0 ? [selected[selected.length - 1]] : []; +} + +// Checks if the candidate if selected, can still lead to a match +function canStillLeadToMatch(candidate: Features[], kits: DisputeKits): boolean { + return kits.some((kit) => + kit.featureSets.some((set) => + // candidate must be subset of this set + candidate.every((f) => set.includes(f)) + ) + ); +} + +/** + * Compute which features should be disabled, + * given the current selection. + */ +export function getDisabledOptions(selected: Features[], groups: FeatureGroups, kits: DisputeKits): Set { + const disabled = new Set(); + + // If nothing is selected => allow all + if (selected.length === 0) { + return disabled; + } + + for (const [_, features] of Object.entries(groups)) { + for (const feature of features) { + const candidate = toggleFeature(selected, feature, groups); + + // Instead of only checking full matches: + const valid = findMatchingKits(candidate, kits).length > 0 || canStillLeadToMatch(candidate, kits); + + if (!valid) { + disabled.add(feature); + } + } + } + + return disabled; +} + +/** + * Features that are visible for a given court, + * based only on the kits that court supports. + */ +export function getVisibleFeaturesForCourt( + supportedKits: number[], + allKits: DisputeKits, + groups: FeatureGroups +): FeatureGroups { + // Get supported kits for this court + const filteredKits = allKits.filter((kit) => supportedKits.includes(kit.id)); + + // Gather all features that appear in these kits + const visible = new Set(); + for (const kit of filteredKits) { + for (const set of kit.featureSets) { + set.forEach((f) => visible.add(f)); + } + } + + // Filter groups => only keep features that are visible + const filteredGroups: FeatureGroups = {}; + for (const [groupName, features] of Object.entries(groups)) { + const visibleFeatures = features.filter((f) => visible.has(f)); + if (visibleFeatures.length > 0) { + filteredGroups[groupName] = visibleFeatures; + } + } + + return filteredGroups; +} diff --git a/web/src/context/NewDisputeContext.tsx b/web/src/context/NewDisputeContext.tsx index e59c9cfc3..25fdb1ccd 100644 --- a/web/src/context/NewDisputeContext.tsx +++ b/web/src/context/NewDisputeContext.tsx @@ -4,9 +4,8 @@ import { useLocation } from "react-router-dom"; import { Address } from "viem"; import { DEFAULT_CHAIN } from "consts/chains"; +import { Features } from "consts/disputeFeature"; import { klerosCoreAddress } from "hooks/contracts/generated"; -import { useSupportedDisputeKits } from "hooks/queries/useSupportedDisputeKits"; -import { useDisputeKitAddressesAll } from "hooks/useDisputeKitAddresses"; import { useLocalStorage } from "hooks/useLocalStorage"; import { isEmpty, isUndefined } from "utils/index"; @@ -93,7 +92,8 @@ interface INewDisputeContext { setIsBatchCreation: (isBatchCreation: boolean) => void; batchSize: number; setBatchSize: (batchSize?: number) => void; - disputeKitOptions: DisputeKitOption[]; + selectedFeatures: Features[]; + setSelectedFeatures: React.Dispatch>; } const getInitialDisputeData = (): IDisputeData => ({ @@ -129,6 +129,7 @@ export const NewDisputeProvider: React.FC<{ children: React.ReactNode }> = ({ ch const [isPolicyUploading, setIsPolicyUploading] = useState(false); const [isBatchCreation, setIsBatchCreation] = useState(false); const [batchSize, setBatchSize] = useLocalStorage("disputeBatchSize", MIN_DISPUTE_BATCH_SIZE); + const [selectedFeatures, setSelectedFeatures] = useState([]); const disputeTemplate = useMemo(() => constructDisputeTemplate(disputeData), [disputeData]); const location = useLocation(); @@ -148,22 +149,6 @@ export const NewDisputeProvider: React.FC<{ children: React.ReactNode }> = ({ ch // eslint-disable-next-line react-hooks/exhaustive-deps }, [location.pathname]); - const { data: supportedDisputeKits } = useSupportedDisputeKits(disputeData.courtId); - const { availableDisputeKits } = useDisputeKitAddressesAll(); - - const disputeKitOptions: DisputeKitOption[] = useMemo(() => { - return ( - supportedDisputeKits?.court?.supportedDisputeKits.map((dk) => { - const text = availableDisputeKits[dk.address.toLowerCase()] ?? ""; - return { - text, - value: Number(dk.id), - gated: text === DisputeKits.Gated || text === DisputeKits.GatedShutter, - }; - }) || [] - ); - }, [supportedDisputeKits, availableDisputeKits]); - const contextValues = useMemo( () => ({ disputeData, @@ -178,7 +163,8 @@ export const NewDisputeProvider: React.FC<{ children: React.ReactNode }> = ({ ch setIsBatchCreation, batchSize, setBatchSize, - disputeKitOptions, + selectedFeatures, + setSelectedFeatures, }), [ disputeData, @@ -191,7 +177,8 @@ export const NewDisputeProvider: React.FC<{ children: React.ReactNode }> = ({ ch setIsBatchCreation, batchSize, setBatchSize, - disputeKitOptions, + selectedFeatures, + setSelectedFeatures, ] ); diff --git a/web/src/hooks/queries/useCourtDetails.ts b/web/src/hooks/queries/useCourtDetails.ts index 46795d763..5cfa7a2e9 100644 --- a/web/src/hooks/queries/useCourtDetails.ts +++ b/web/src/hooks/queries/useCourtDetails.ts @@ -26,6 +26,7 @@ const courtDetailsQuery = graphql(` timesPerPeriod feeForJuror name + hiddenVotes } } `); diff --git a/web/src/pages/Resolver/Parameters/Court/FeatureSelection/FeatureSkeleton.tsx b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/FeatureSkeleton.tsx new file mode 100644 index 000000000..3e38e8716 --- /dev/null +++ b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/FeatureSkeleton.tsx @@ -0,0 +1,47 @@ +import React from "react"; +import styled from "styled-components"; + +import Skeleton from "react-loading-skeleton"; + +const Container = styled.div` + width: 100%; + display: flex; + flex-direction: column; + gap: 16px; + margin-top: 16px; +`; + +const HeaderContainer = styled.div` + display: flex; + flex-direction: column; + gap: 8px; +`; + +const HeaderSkeleton = styled(Skeleton)` + width: 25%; + height: 22px; +`; + +const SubHeaderSkeleton = styled(Skeleton)` + width: 75%; + height: 19px; +`; + +const RadioSkeleton = styled(Skeleton)` + width: 20%; + height: 19px; +`; + +const FeatureSkeleton: React.FC = () => { + return ( + + + + + + + + ); +}; + +export default FeatureSkeleton; diff --git a/web/src/pages/Resolver/Parameters/Court/FeatureSelection/JurorEligibility.tsx b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/JurorEligibility.tsx deleted file mode 100644 index 3e1febbd4..000000000 --- a/web/src/pages/Resolver/Parameters/Court/FeatureSelection/JurorEligibility.tsx +++ /dev/null @@ -1,248 +0,0 @@ -import React, { useEffect, useMemo, useState } from "react"; -import styled from "styled-components"; - -import { Field, Radio } from "@kleros/ui-components-library"; - -import { IGatedDisputeData, useNewDisputeContext } from "context/NewDisputeContext"; -import { useERC1155Validation, useERC20ERC721Validation } from "hooks/useTokenAddressValidation"; - -import { DisputeKits } from "src/consts"; -import { isUndefined } from "src/utils"; - -import WithHelpTooltip from "components/WithHelpTooltip"; - -const Container = styled.div` - width: 100%; - display: flex; - flex-direction: column; - gap: 16px; - align-items: start; - padding-top: 16px; -`; - -const HeaderContainer = styled.div` - width: 100%; - padding-top: 16px; -`; - -const Header = styled.h2` - font-size: 16px; - font-weight: 600; - margin: 0; -`; - -const SubTitle = styled.p` - font-size: 14px; - color: ${({ theme }) => theme.secondaryText}; - padding: 0; - margin: 0; -`; - -const FieldContainer = styled.div` - width: 100%; - padding-left: 32px; -`; -const StyledField = styled(Field)` - width: 100%; - margin-top: 8px; - margin-bottom: 32px; - > small { - margin-top: 16px; - } -`; - -const StyledRadio = styled(Radio)` - font-size: 14px; -`; - -enum EligibilityType { - Classic, - GatedERC20, - GatedERC1155, -} - -const JurorEligibility: React.FC = () => { - const [eligibilityType, setEligibilityType] = useState(); - const { disputeData, setDisputeData, disputeKitOptions } = useNewDisputeContext(); - - const tokenGateAddress = (disputeData.disputeKitData as IGatedDisputeData)?.tokenGate ?? ""; - const validationEnabled = !isUndefined(tokenGateAddress) && tokenGateAddress.trim() !== ""; - const isERC1155 = eligibilityType === EligibilityType.GatedERC1155; - const { - isValidating: isValidatingERC20, - isValid: isValidERC20, - error: validationErrorERC20, - } = useERC20ERC721Validation({ - address: tokenGateAddress, - enabled: validationEnabled && !isERC1155, - }); - - const { - isValidating: isValidatingERC1155, - isValid: isValidERC1155, - error: validationErrorERC1155, - } = useERC1155Validation({ - address: tokenGateAddress, - enabled: validationEnabled && isERC1155, - }); - - // Combine validation results based on token type - const isValidating = isERC1155 ? isValidatingERC1155 : isValidatingERC20; - const isValidToken = isERC1155 ? isValidERC1155 : isValidERC20; - const validationError = isERC1155 ? validationErrorERC1155 : validationErrorERC20; - - const [validationMessage, variant] = useMemo(() => { - if (isValidating) return [`Validating ${isERC1155 ? "ERC-1155" : "ERC-20 or ERC-721"} token...`, "info"]; - else if (validationError) return [validationError, "error"]; - else if (isValidToken === true) return [`Valid ${isERC1155 ? "ERC-1155" : "ERC-20 or ERC-721"} token`, "success"]; - else return [undefined, "info"]; - }, [isValidating, validationError, isERC1155, isValidToken]); - - // Update validation state in dispute context - useEffect(() => { - if (disputeData.disputeKitData) { - const currentData = disputeData.disputeKitData as IGatedDisputeData; - if (currentData.isTokenGateValid !== isValidToken) { - setDisputeData({ - ...disputeData, - disputeKitData: { ...currentData, isTokenGateValid: isValidToken }, - }); - } - } - }, [isValidToken, disputeData.disputeKitData, setDisputeData]); - - const handleTokenAddressChange = (event: React.ChangeEvent) => { - const currentData = disputeData.disputeKitData as IGatedDisputeData; - setDisputeData({ - ...disputeData, - disputeKitData: { - ...currentData, - tokenGate: event.target.value, - isTokenGateValid: null, // Reset validation state when address changes - }, - }); - }; - - const handleTokenIdChange = (event: React.ChangeEvent) => { - const currentData = disputeData.disputeKitData as IGatedDisputeData; - setDisputeData({ - ...disputeData, - disputeKitData: { ...currentData, tokenId: event.target.value }, - }); - }; - - useEffect(() => { - if (eligibilityType === EligibilityType.Classic) { - const disputeKit = disputeKitOptions.find((dk) => dk.text === DisputeKits.Classic); - - setDisputeData({ ...disputeData, disputeKitId: disputeKit?.value, disputeKitData: undefined }); - } else if (eligibilityType === EligibilityType.GatedERC20 || eligibilityType === EligibilityType.GatedERC1155) { - const disputeKitGated = disputeKitOptions.find((dk) => dk.text === DisputeKits.Gated); - const disputeKitGatedShutter = disputeKitOptions.find((dk) => dk.text === DisputeKits.GatedShutter); - - const currentDisputeKit = disputeKitOptions.find((dk) => dk.value === disputeData.disputeKitId); - - const disputeKitData: IGatedDisputeData = { - ...(disputeData.disputeKitData as IGatedDisputeData), - type: "gated", - isERC1155: eligibilityType === EligibilityType.GatedERC1155, - }; - // classic is selected, so here we change it to TokenGated - if (currentDisputeKit?.text === DisputeKits.Classic) { - setDisputeData({ - ...disputeData, - disputeKitId: disputeKitGated?.value, - disputeKitData, - }); - } - // shutter is selected, so here we change it to TokenGatedShutter - else if (currentDisputeKit?.text === DisputeKits.Shutter) { - setDisputeData({ - ...disputeData, - disputeKitId: disputeKitGatedShutter?.value, - disputeKitData, - }); - } else { - setDisputeData({ - ...disputeData, - disputeKitId: disputeKitGated?.value, - disputeKitData, - }); - } - } - }, [eligibilityType, disputeKitOptions]); - - return ( - - -
Jurors Eligibility
- Who can be selected as a juror?. -
- - setEligibilityType(EligibilityType.Classic)} - checked={eligibilityType === EligibilityType.Classic} - /> - - - setEligibilityType(EligibilityType.GatedERC20)} - checked={eligibilityType === EligibilityType.GatedERC20} - /> - - {eligibilityType === EligibilityType.GatedERC20 ? ( - - - - ) : null} - - setEligibilityType(EligibilityType.GatedERC1155)} - checked={eligibilityType === EligibilityType.GatedERC1155} - /> - - {eligibilityType === EligibilityType.GatedERC1155 ? ( - - - - - ) : null} -
- ); -}; - -export default JurorEligibility; diff --git a/web/src/pages/Resolver/Parameters/Court/FeatureSelection/ShieldedVoting.tsx b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/ShieldedVoting.tsx deleted file mode 100644 index 85a5caf98..000000000 --- a/web/src/pages/Resolver/Parameters/Court/FeatureSelection/ShieldedVoting.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import React, { useEffect, useState } from "react"; -import styled from "styled-components"; - -import { Radio } from "@kleros/ui-components-library"; - -import { useNewDisputeContext } from "context/NewDisputeContext"; - -import { DisputeKits } from "src/consts"; - -import WithHelpTooltip from "components/WithHelpTooltip"; - -const VotingContainer = styled.div` - width: 100%; - display: flex; - flex-direction: column; - gap: 16px; - align-items: start; - border-bottom: 1px solid ${({ theme }) => theme.stroke}; - padding-bottom: 16px; -`; - -const VotingHeaderContainer = styled.div` - width: 100%; - padding-top: 16px; -`; - -const VotingHeader = styled.h2` - font-size: 16px; - font-weight: 600; - margin: 0; -`; - -const VotingSubTitle = styled.p` - font-size: 14px; - color: ${({ theme }) => theme.secondaryText}; - padding: 0; - margin: 0; -`; - -const StyledRadio = styled(Radio)` - font-size: 14px; -`; - -enum VotingType { - OneStep = "Shutter", - TwoStep = "Classic", -} - -// Selects one of Classic or Shutter DisputeKit -const ShieldedVoting: React.FC = () => { - const [votingType, setVotingType] = useState(); - const { disputeData, setDisputeData, disputeKitOptions } = useNewDisputeContext(); - - // we try to keep this structure among feature components - // keep in mind the other options that will need to be disabled if a certain feature is selected - useEffect(() => { - // disable TokenGatedShutter if selected, if TokenGated Selected do nothing - if (votingType === VotingType.TwoStep && disputeData.disputeKitData?.type !== "gated") { - const disputeKit = disputeKitOptions.find((dk) => dk.text === DisputeKits.Classic); - - setDisputeData({ ...disputeData, disputeKitId: disputeKit?.value }); - } - - if (votingType === VotingType.OneStep) { - // user has already selected TokenGated, so selecting Shutter here, we need to select TokenGatedShutter - if (disputeData.disputeKitData?.type === "gated") { - const disputeKit = disputeKitOptions.find((dk) => dk.text === DisputeKits.GatedShutter); - - // no need to set DisputeKitData, will already be set by JurorEligibility - setDisputeData({ ...disputeData, disputeKitId: disputeKit?.value }); - } else { - const disputeKit = disputeKitOptions.find((dk) => dk.text === DisputeKits.Shutter); - - setDisputeData({ ...disputeData, disputeKitId: disputeKit?.value }); - } - } - }, [votingType]); - - return ( - - - Shielded Voting - It hides the jurors' votes until the end of the voting period. - - - setVotingType(VotingType.OneStep)} - checked={votingType === VotingType.OneStep} - /> - - - setVotingType(VotingType.TwoStep)} - checked={votingType === VotingType.TwoStep} - /> - - - ); -}; - -export default ShieldedVoting; diff --git a/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx index 8fdb8d7af..6f6e4f102 100644 --- a/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx +++ b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx @@ -1,14 +1,28 @@ -import React, { useEffect, useMemo, useState } from "react"; +import React, { Fragment, useEffect, useMemo } from "react"; import styled from "styled-components"; import { Card } from "@kleros/ui-components-library"; -import { useNewDisputeContext } from "context/NewDisputeContext"; +import { + disputeKits, + ensureValidSmart, + featureGroups, + Features, + findMatchingKits, + getDisabledOptions, + getVisibleFeaturesForCourt, + toggleFeature, +} from "consts/disputeFeature"; +import { IGatedDisputeData, useNewDisputeContext } from "context/NewDisputeContext"; -import { DisputeKits } from "src/consts"; +import { useSupportedDisputeKits } from "queries/useSupportedDisputeKits"; -import JurorEligibility from "./JurorEligibility"; -import ShieldedVoting from "./ShieldedVoting"; +import { isUndefined } from "src/utils"; + +import { FeatureUIs } from "components/DisputeFeatures/Features"; +import { GroupsUI } from "components/DisputeFeatures/GroupsUI"; + +import FeatureSkeleton from "./FeatureSkeleton"; const Container = styled(Card)` width: 100%; @@ -26,44 +40,126 @@ const SubTitle = styled.p` margin: 0; `; +const Separator = styled.hr` + width: 100%; +`; const FeatureSelection: React.FC = () => { - const [showFeatures, setShowFeatures] = useState(false); - const { disputeKitOptions, disputeData, setDisputeData } = useNewDisputeContext(); - - // if supported dispute kits have Classic and Shutter - const showVotingFeatures = useMemo(() => { - return ( - disputeKitOptions.some((dk) => dk.text === DisputeKits.Classic) && - disputeKitOptions.some((dk) => dk.text === DisputeKits.Shutter) - ); - }, [disputeKitOptions]); - - // if supported dispute kits have Classic, TokenGated, TokenGatedShutter - const showEligibilityFeatures = useMemo(() => { - return ( - disputeKitOptions.some((dk) => dk.text === DisputeKits.Classic) && - disputeKitOptions.some((dk) => dk.text === DisputeKits.GatedShutter) && - disputeKitOptions.some((dk) => dk.text === DisputeKits.Gated) - ); - }, [disputeKitOptions]); + const { + disputeData, + setDisputeData, + selectedFeatures: selected, + setSelectedFeatures: setSelected, + } = useNewDisputeContext(); + const { data: supportedDisputeKits } = useSupportedDisputeKits(disputeData.courtId); + // DEV: initial feature selection logic, included hardcoded logic useEffect(() => { - // there's only one, NOTE: what happens when here only TokenGated is support? we need the value - if (disputeKitOptions.length === 1) { - const disputeKit = disputeKitOptions[0]; - setDisputeData({ ...disputeData, disputeKitId: disputeKit.value }); - setShowFeatures(false); - } else { - setShowFeatures(true); + if (!isUndefined(disputeData?.disputeKitId)) { + const defaultKit = disputeKits.find((dk) => dk.id === disputeData.disputeKitId); + if (!defaultKit) return; + + // some kits like gated can have two feature sets, one for gatedERC20 and other for ERC1155 + if (defaultKit?.allowSubset) { + if ((disputeData?.disputeKitData as IGatedDisputeData)?.isERC1155) { + // defaultKit.featureSets[0][0] - is either Classic or Shutter + setSelected([defaultKit.featureSets[0][0], Features.GatedErc1155]); + } else { + setSelected([defaultKit.featureSets[0][0], Features.GatedErc20]); + } + } else { + setSelected(defaultKit.featureSets[0]); + } } - }, [disputeKitOptions]); + }, []); + + const allowedDisputeKits = useMemo(() => { + if (!supportedDisputeKits?.court?.supportedDisputeKits) return []; + const allowedIds = supportedDisputeKits.court.supportedDisputeKits.map((dk) => Number(dk.id)); + return disputeKits.filter((kit) => allowedIds.includes(kit.id)); + }, [supportedDisputeKits]); + + // Court specific groups + const courtGroups = useMemo(() => { + const courtKits = supportedDisputeKits?.court?.supportedDisputeKits.map((dk) => Number(dk.id)); + if (isUndefined(courtKits) || allowedDisputeKits.length === 0) return {}; + return getVisibleFeaturesForCourt(courtKits, allowedDisputeKits, featureGroups); + }, [supportedDisputeKits, allowedDisputeKits]); + + const disabled = useMemo( + () => getDisabledOptions(selected, courtGroups, allowedDisputeKits), + [selected, courtGroups, allowedDisputeKits] + ); + + const matchingKits = useMemo(() => findMatchingKits(selected, allowedDisputeKits), [selected, allowedDisputeKits]); + + const handleToggle = (feature: Features) => { + setSelected((prev) => { + const toggled = toggleFeature(prev, feature, courtGroups); + // we don't necessarily need ensureValidSmart here, + // but in case a bug allows picking a disabled option, this will correct that + return ensureValidSmart(toggled, courtGroups, allowedDisputeKits); + }); + }; + + // if each group only has one feature, select them by default + // This should not clash with the initial selection logic, + // as it only runs when there's one disputeKit and featureSet to pick + useEffect(() => { + // if only one disputeKit is found, and that dk has only one featureSEt to pick, then select by default + if (allowedDisputeKits.length === 1 && allowedDisputeKits[0].featureSets.length === 1) { + setSelected(allowedDisputeKits[0].featureSets[0]); + } + }, [allowedDisputeKits, setSelected]); + + useEffect(() => { + // work of feature selection ends here by giving us the disputeKitId, + // any further checks we do separately, like for NextButton + // right now we don't have kits that can have same features, so we assume it will be 1 length array + if (matchingKits.length === 1) { + const selectedKit = matchingKits[0]; + + setDisputeData({ + ...disputeData, + disputeKitId: selectedKit.id, + disputeKitData: + selectedKit.type === "general" + ? undefined + : ({ ...disputeData.disputeKitData, type: selectedKit.type } as IGatedDisputeData), + }); + } else if (matchingKits.length === 0) { + setDisputeData({ ...disputeData, disputeKitId: undefined }); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [matchingKits]); - if (!showFeatures) return null; return ( Additional features available in this court: - {showVotingFeatures ? : null} - {showEligibilityFeatures ? : null} + + {Object.entries(courtGroups).length > 0 ? ( + Object.entries(courtGroups).map(([groupName, features], index) => ( + <> + {GroupsUI[groupName]({ + children: ( + + {features.map((feature) => + FeatureUIs[feature]({ + name: groupName, + checked: selected.includes(feature), + disabled: disabled.has(feature), + onClick: () => handleToggle(feature), + value: feature, + }) + )} + + ), + })} + {index !== Object.entries(courtGroups).length - 1 ? : null} + + )) + ) : ( + + )} ); }; diff --git a/web/src/pages/Resolver/Parameters/Court/index.tsx b/web/src/pages/Resolver/Parameters/Court/index.tsx index 4e50c0bdc..758f167bc 100644 --- a/web/src/pages/Resolver/Parameters/Court/index.tsx +++ b/web/src/pages/Resolver/Parameters/Court/index.tsx @@ -52,13 +52,14 @@ const AlertMessageContainer = styled.div` `; const Court: React.FC = () => { - const { disputeData, setDisputeData } = useNewDisputeContext(); + const { disputeData, setDisputeData, setSelectedFeatures } = useNewDisputeContext(); const { data: courtTree } = useCourtTree(); const items = useMemo(() => !isUndefined(courtTree?.court) && [rootCourtToItems(courtTree.court)], [courtTree]); const handleCourtChange = (courtId: string) => { if (disputeData.courtId !== courtId) { - setDisputeData({ ...disputeData, courtId, disputeKitId: undefined }); + setDisputeData({ ...disputeData, courtId, disputeKitId: undefined, disputeKitData: undefined }); + setSelectedFeatures([]); } }; @@ -83,7 +84,7 @@ const Court: React.FC = () => { variant="info" /> - + {isUndefined(disputeData.courtId) ? null : } ); From 0e3fb9c5d7068385d5b01cece7c0fdd6ca203858 Mon Sep 17 00:00:00 2001 From: Harman-singh-waraich Date: Tue, 9 Sep 2025 19:32:03 +0530 Subject: [PATCH 3/5] chore(web): rabbit-review --- .../DisputeFeatures/Features/ClassicVote.tsx | 2 ++ .../DisputeFeatures/Features/GatedErc1155.tsx | 9 +++++---- .../DisputeFeatures/Features/GatedErc20.tsx | 7 ++++--- .../DisputeFeatures/Features/index.tsx | 5 +++-- web/src/consts/disputeFeature.ts | 18 +----------------- .../Court/FeatureSelection/index.tsx | 4 ++-- 6 files changed, 17 insertions(+), 28 deletions(-) diff --git a/web/src/components/DisputeFeatures/Features/ClassicVote.tsx b/web/src/components/DisputeFeatures/Features/ClassicVote.tsx index efc780a02..1cbf78c3c 100644 --- a/web/src/components/DisputeFeatures/Features/ClassicVote.tsx +++ b/web/src/components/DisputeFeatures/Features/ClassicVote.tsx @@ -1,5 +1,6 @@ import React from "react"; +import { Features } from "consts/disputeFeature"; import { useNewDisputeContext } from "context/NewDisputeContext"; import { useCourtDetails } from "queries/useCourtDetails"; @@ -22,6 +23,7 @@ const ClassicVote: React.FC = (props) => { : `The jurors' votes are not hidden. Everybody can see the justification and voted choice before the voting period completes.` } + key={Features.ClassicVote} > diff --git a/web/src/components/DisputeFeatures/Features/GatedErc1155.tsx b/web/src/components/DisputeFeatures/Features/GatedErc1155.tsx index 1467379ca..beaec398b 100644 --- a/web/src/components/DisputeFeatures/Features/GatedErc1155.tsx +++ b/web/src/components/DisputeFeatures/Features/GatedErc1155.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useMemo } from "react"; +import React, { Fragment, useEffect, useMemo } from "react"; import styled from "styled-components"; import { Field } from "@kleros/ui-components-library"; +import { Features } from "consts/disputeFeature"; import { IGatedDisputeData, useNewDisputeContext } from "context/NewDisputeContext"; import { useERC1155Validation } from "hooks/useTokenAddressValidation"; @@ -38,7 +39,7 @@ const GatedErc1155: React.FC = (props) => { error: validationError, } = useERC1155Validation({ address: tokenGateAddress, - enabled: validationEnabled, + enabled: validationEnabled && props.checked, }); const [validationMessage, variant] = useMemo(() => { @@ -89,7 +90,7 @@ const GatedErc1155: React.FC = (props) => { }; return ( - <> + = (props) => { /> ) : null} - + ); }; diff --git a/web/src/components/DisputeFeatures/Features/GatedErc20.tsx b/web/src/components/DisputeFeatures/Features/GatedErc20.tsx index 748f320a2..7f072daab 100644 --- a/web/src/components/DisputeFeatures/Features/GatedErc20.tsx +++ b/web/src/components/DisputeFeatures/Features/GatedErc20.tsx @@ -1,8 +1,9 @@ -import React, { useEffect, useMemo } from "react"; +import React, { Fragment, useEffect, useMemo } from "react"; import styled from "styled-components"; import { Field } from "@kleros/ui-components-library"; +import { Features } from "consts/disputeFeature"; import { IGatedDisputeData, useNewDisputeContext } from "context/NewDisputeContext"; import { useERC20ERC721Validation } from "hooks/useTokenAddressValidation"; @@ -79,7 +80,7 @@ const GatedErc20: React.FC = (props) => { }; return ( - <> + = (props) => { /> ) : null} - + ); }; diff --git a/web/src/components/DisputeFeatures/Features/index.tsx b/web/src/components/DisputeFeatures/Features/index.tsx index 5c3157a52..d1b6d7c48 100644 --- a/web/src/components/DisputeFeatures/Features/index.tsx +++ b/web/src/components/DisputeFeatures/Features/index.tsx @@ -19,7 +19,7 @@ export type RadioInput = { onClick: () => void; }; -export type FeatureUI = (props: RadioInput) => JSX.Element; +export type FeatureUI = React.FC; export const StyledRadio = styled(Radio)` font-size: 14px; @@ -33,6 +33,7 @@ export const FeatureUIs: Record = { tooltipMsg={`The jurors' votes are hidden. Nobody can see them before the voting period completes. (It takes place in one step via Shutter Network)`} + key={Features.ShieldedVote} > @@ -41,7 +42,7 @@ export const FeatureUIs: Record = { [Features.ClassicVote]: (props: RadioInput) => , [Features.ClassicEligibility]: (props: RadioInput) => ( - + ), [Features.GatedErc20]: (props: RadioInput) => , diff --git a/web/src/consts/disputeFeature.ts b/web/src/consts/disputeFeature.ts index a6f257c48..f852047ac 100644 --- a/web/src/consts/disputeFeature.ts +++ b/web/src/consts/disputeFeature.ts @@ -25,11 +25,6 @@ export interface DisputeKit { * if either subset matches the selected feature array this dispute kit is selected */ featureSets: Features[][]; - /** - * If true => kit accepts any subset of a featureSet. - * If false => must be an exact match. - */ - allowSubset?: boolean; type: "general" | "gated"; } @@ -62,7 +57,6 @@ export const disputeKits: DisputeKits = [ [Features.ClassicVote, Features.GatedErc20], [Features.ClassicVote, Features.GatedErc1155], ], - allowSubset: true, type: "gated", }, { @@ -71,7 +65,6 @@ export const disputeKits: DisputeKits = [ [Features.ShieldedVote, Features.GatedErc20], [Features.ShieldedVote, Features.GatedErc1155], ], - allowSubset: true, type: "gated", }, ]; @@ -86,12 +79,6 @@ function arraysEqual(a: Features[], b: Features[]): boolean { return normalize(a) === normalize(b); } -/** Check if `a` is a superset of `b` */ -function includesAll(a: Features[], b: Features[]): boolean { - const setA = new Set(a); - return b.every((x) => setA.has(x)); -} - /** * Toggle a feature, ensuring radio behavior per group * @returns the updated selected features array @@ -119,10 +106,7 @@ export function toggleFeature(selected: Features[], feature: Features, groups: F export function findMatchingKits(selected: Features[], kits: DisputeKits): DisputeKit[] { return kits.filter((kit) => kit.featureSets.some( - (set) => - kit.allowSubset - ? includesAll(selected, set) // kit allows subset - : arraysEqual(set, selected) // strict exact match + (set) => arraysEqual(set, selected) // strict exact match ) ); } diff --git a/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx index 6f6e4f102..56860e906 100644 --- a/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx +++ b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx @@ -59,7 +59,7 @@ const FeatureSelection: React.FC = () => { if (!defaultKit) return; // some kits like gated can have two feature sets, one for gatedERC20 and other for ERC1155 - if (defaultKit?.allowSubset) { + if (defaultKit?.featureSets.length > 0) { if ((disputeData?.disputeKitData as IGatedDisputeData)?.isERC1155) { // defaultKit.featureSets[0][0] - is either Classic or Shutter setSelected([defaultKit.featureSets[0][0], Features.GatedErc1155]); @@ -141,7 +141,7 @@ const FeatureSelection: React.FC = () => { <> {GroupsUI[groupName]({ children: ( - + {features.map((feature) => FeatureUIs[feature]({ name: groupName, From f77b92f892891a447145ebb115f9d4ae3f863640 Mon Sep 17 00:00:00 2001 From: Harman-singh-waraich Date: Tue, 9 Sep 2025 19:54:13 +0530 Subject: [PATCH 4/5] fix(web): incorrect-feature-selection --- .../Resolver/Parameters/Court/FeatureSelection/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx index 56860e906..f750345bd 100644 --- a/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx +++ b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx @@ -59,14 +59,14 @@ const FeatureSelection: React.FC = () => { if (!defaultKit) return; // some kits like gated can have two feature sets, one for gatedERC20 and other for ERC1155 - if (defaultKit?.featureSets.length > 0) { + if (defaultKit?.featureSets.length > 1) { if ((disputeData?.disputeKitData as IGatedDisputeData)?.isERC1155) { // defaultKit.featureSets[0][0] - is either Classic or Shutter setSelected([defaultKit.featureSets[0][0], Features.GatedErc1155]); } else { setSelected([defaultKit.featureSets[0][0], Features.GatedErc20]); } - } else { + } else if (defaultKit.featureSets.length === 1) { setSelected(defaultKit.featureSets[0]); } } From e41329b59175b8062169f73489fd40c2ffc05346 Mon Sep 17 00:00:00 2001 From: jaybuidl Date: Tue, 9 Sep 2025 16:56:16 +0100 Subject: [PATCH 5/5] feat: court features wording, sub-title styling --- .../components/DisputeFeatures/Features/ClassicVote.tsx | 8 ++++---- .../components/DisputeFeatures/Features/GatedErc1155.tsx | 4 ++-- .../components/DisputeFeatures/Features/GatedErc20.tsx | 4 ++-- web/src/components/DisputeFeatures/Features/index.tsx | 4 ++-- web/src/components/DisputeFeatures/GroupsUI.tsx | 4 ++-- .../Resolver/Parameters/Court/FeatureSelection/index.tsx | 4 ++-- 6 files changed, 14 insertions(+), 14 deletions(-) diff --git a/web/src/components/DisputeFeatures/Features/ClassicVote.tsx b/web/src/components/DisputeFeatures/Features/ClassicVote.tsx index 1cbf78c3c..44f3bf237 100644 --- a/web/src/components/DisputeFeatures/Features/ClassicVote.tsx +++ b/web/src/components/DisputeFeatures/Features/ClassicVote.tsx @@ -17,15 +17,15 @@ const ClassicVote: React.FC = (props) => { - + ); }; diff --git a/web/src/components/DisputeFeatures/Features/GatedErc1155.tsx b/web/src/components/DisputeFeatures/Features/GatedErc1155.tsx index beaec398b..3d48228c1 100644 --- a/web/src/components/DisputeFeatures/Features/GatedErc1155.tsx +++ b/web/src/components/DisputeFeatures/Features/GatedErc1155.tsx @@ -92,8 +92,8 @@ const GatedErc1155: React.FC = (props) => { return ( diff --git a/web/src/components/DisputeFeatures/Features/GatedErc20.tsx b/web/src/components/DisputeFeatures/Features/GatedErc20.tsx index 7f072daab..1ac053b42 100644 --- a/web/src/components/DisputeFeatures/Features/GatedErc20.tsx +++ b/web/src/components/DisputeFeatures/Features/GatedErc20.tsx @@ -82,8 +82,8 @@ const GatedErc20: React.FC = (props) => { return ( diff --git a/web/src/components/DisputeFeatures/Features/index.tsx b/web/src/components/DisputeFeatures/Features/index.tsx index d1b6d7c48..c98cc77e0 100644 --- a/web/src/components/DisputeFeatures/Features/index.tsx +++ b/web/src/components/DisputeFeatures/Features/index.tsx @@ -30,9 +30,9 @@ export const StyledRadio = styled(Radio)` export const FeatureUIs: Record = { [Features.ShieldedVote]: (props: RadioInput) => ( diff --git a/web/src/components/DisputeFeatures/GroupsUI.tsx b/web/src/components/DisputeFeatures/GroupsUI.tsx index 4a2714287..ca28c05a4 100644 --- a/web/src/components/DisputeFeatures/GroupsUI.tsx +++ b/web/src/components/DisputeFeatures/GroupsUI.tsx @@ -36,7 +36,7 @@ export const GroupsUI: Record = {
Shielded Voting
- It hides the jurors' votes until the end of the voting period. + This feature hides the jurors votes until the end of the voting period.
{children}
@@ -45,7 +45,7 @@ export const GroupsUI: Record = {
Jurors Eligibility
- Who can be selected as a juror?. + This feature determines who can be selected as a juror.
{children}
diff --git a/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx index f750345bd..5444836c3 100644 --- a/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx +++ b/web/src/pages/Resolver/Parameters/Court/FeatureSelection/index.tsx @@ -34,7 +34,7 @@ const Container = styled(Card)` `; const SubTitle = styled.p` - font-size: 14px; + font-size: 18px; color: ${({ theme }) => theme.secondaryBlue}; padding: 0; margin: 0; @@ -134,7 +134,7 @@ const FeatureSelection: React.FC = () => { return ( - Additional features available in this court: + Features in this Court {Object.entries(courtGroups).length > 0 ? ( Object.entries(courtGroups).map(([groupName, features], index) => (