From ab6f2cfe3b5d7d3c30a58f0fe3d5c3b4dfaa3edd Mon Sep 17 00:00:00 2001 From: Iveta Date: Fri, 5 Apr 2024 13:49:35 -0400 Subject: [PATCH] Explore Endpoints: pages part 3 (#809) * All Assets * Claimable Balances * Effects + refactor endpoint URL template * Fee Stats * Ledgers * Offers * Operations * Payments * Transactions * Update SDS * Order book + asset object params * Trade aggregations * Fix PrettyJson records rendering * Trades * Cleanup * Paths * Liquidity pools * Fix URL validation * Cleanup * Update reserves asset type * Cleanup * Don't hard-code component IDs --- .../explore-endpoints/[[...pages]]/page.tsx | 93 ++++++- .../FormElements/AssetMultiPicker.tsx | 236 ++++++++++++++++++ src/components/FormElements/AssetPicker.tsx | 3 +- src/components/RadioPicker/styles.scss | 3 +- src/components/formComponentTemplate.tsx | 165 +++++++++++- src/components/layout/Box/index.tsx | 2 +- src/constants/exploreEndpointsPages.ts | 117 +++++++-- src/constants/routes.ts | 2 +- src/helpers/isValidUrl.ts | 4 + src/validate/index.ts | 4 + src/validate/methods/amount.ts | 11 + src/validate/methods/assetMulti.ts | 35 +++ 12 files changed, 631 insertions(+), 44 deletions(-) create mode 100644 src/components/FormElements/AssetMultiPicker.tsx create mode 100644 src/validate/methods/amount.ts create mode 100644 src/validate/methods/assetMulti.ts diff --git a/src/app/(sidebar)/explore-endpoints/[[...pages]]/page.tsx b/src/app/(sidebar)/explore-endpoints/[[...pages]]/page.tsx index 7bce3c73..1f41821a 100644 --- a/src/app/(sidebar)/explore-endpoints/[[...pages]]/page.tsx +++ b/src/app/(sidebar)/explore-endpoints/[[...pages]]/page.tsx @@ -30,7 +30,12 @@ import { parseJsonString } from "@/helpers/parseJsonString"; import { Routes } from "@/constants/routes"; import { EXPLORE_ENDPOINTS_PAGES_HORIZON } from "@/constants/exploreEndpointsPages"; import { useExploreEndpoint } from "@/query/useExploreEndpoint"; -import { AnyObject, AssetObject, Network } from "@/types/types"; +import { + AnyObject, + AssetObject, + AssetObjectValue, + Network, +} from "@/types/types"; export default function ExploreEndpoints() { const pathname = usePathname(); @@ -151,6 +156,8 @@ export default function ExploreEndpoints() { params.buying_asset, params.base_asset, params.counter_asset, + params.destination_asset, + params.source_asset, ]; assetParams.forEach((aParam) => { @@ -162,18 +169,47 @@ export default function ExploreEndpoints() { // When non-native asset is selected, code and issuer fields are required if (aParam) { const assetObj = parseJsonString(aParam); + isValidReqAssetFields = validateAssetObj(assetObj); + } + }); - if ( - ["issued", "credit_alphanum4", "credit_alphanum12"].includes( - assetObj.type, - ) - ) { - isValidReqAssetFields = Boolean(assetObj.code && assetObj.issuer); - } + const isAssetMultiValid = true; + + const assetMulti = [ + params.source_assets, + params.destination_assets, + params.reserves, + ]; + + assetMulti.forEach((a) => { + if (a) { + const saParams = parseJsonString(a); + + saParams.forEach((sa: AssetObjectValue) => { + if (!isAssetMultiValid) { + return; + } + + isValidReqAssetFields = validateAssetObj(sa); + }); } }); - return isValidReqAssetFields && isValidReqFields && isValid; + return ( + isValidReqAssetFields && isValidReqFields && isValid && isAssetMultiValid + ); + }; + + const validateAssetObj = (asset: AssetObjectValue) => { + if ( + ["issued", "credit_alphanum4", "credit_alphanum12"].includes( + asset.type as string, + ) + ) { + return Boolean(asset.code && asset.issuer); + } + + return true; }; const resetQuery = useCallback( @@ -280,6 +316,14 @@ export default function ExploreEndpoints() { } }, [isSuccess, isError]); + const createAssetString = (asset: AssetObjectValue) => { + if (asset.type === "native") { + return "native"; + } + + return `${asset.code}:${asset.issuer}`; + }; + const buildUrl = useCallback(() => { const parseUrlPath = (path: string) => { const pathArr: string[] = []; @@ -309,16 +353,23 @@ export default function ExploreEndpoints() { const getParamRequestValue = (param: string) => { const value = parseJsonString(params[param]); + // Boolean values if (!value && typeof value !== "boolean") { return false; } + // Asset string if (["asset", "selling", "buying"].includes(param)) { - if (value.type === "native") { - return "native"; - } + return createAssetString(value); + } - return `${value.code}:${value.issuer}`; + // Comma separated assets string + if (["source_assets", "destination_assets", "reserves"].includes(param)) { + return sanitizeArray( + value.map((v: AssetObjectValue) => + isEmptyObject(sanitizeObject(v)) ? undefined : createAssetString(v), + ), + ).join(","); } return `${value}`; @@ -492,6 +543,8 @@ export default function ExploreEndpoints() { case "buying_asset": case "base_asset": case "counter_asset": + case "destination_asset": + case "source_asset": return component.render({ value: params[f], error: formError[f], @@ -505,6 +558,20 @@ export default function ExploreEndpoints() { ); }, }); + case "source_assets": + case "destination_assets": + case "reserves": + return component.render({ + values: parseJsonString(params[f]), + error: formError[f], + isRequired, + onChange: (assetArr: AssetObject[]) => { + handleChange( + assetArr, + assetArr ? JSON.stringify(assetArr) : undefined, + ); + }, + }); case "order": case "include_failed": return component.render({ diff --git a/src/components/FormElements/AssetMultiPicker.tsx b/src/components/FormElements/AssetMultiPicker.tsx new file mode 100644 index 00000000..a0d59ddd --- /dev/null +++ b/src/components/FormElements/AssetMultiPicker.tsx @@ -0,0 +1,236 @@ +import React from "react"; +import { Button, Input, Label } from "@stellar/design-system"; + +import { ExpandBox } from "@/components/ExpandBox"; +import { RadioPicker } from "@/components/RadioPicker"; +import { PubKeyPicker } from "@/components/FormElements/PubKeyPicker"; + +import { AssetObject, AssetObjectValue } from "@/types/types"; +import { Box } from "../layout/Box"; + +type AssetMultiPickerProps = { + id: string; + label: string; + labelSuffix?: string | React.ReactNode; + values: AssetObjectValue[] | undefined; + error: { code: string | undefined; issuer: string | undefined }[] | undefined; + onChange: (asset: AssetObjectValue[] | undefined) => void; + assetInput: "issued" | "alphanumeric"; + includeNative?: boolean; + customButtonLabel?: string; +}; + +export const AssetMultiPicker = ({ + id, + label, + labelSuffix, + values = [], + error, + onChange, + assetInput, + includeNative = true, + customButtonLabel = "asset", +}: AssetMultiPickerProps) => { + let options: AssetObject[] = []; + + if (includeNative) { + options = [ + { + id: "native", + label: "Native", + value: { + type: "native", + code: "", + issuer: "", + }, + }, + ...options, + ]; + } + + if (assetInput === "alphanumeric") { + options = [ + ...options, + { + id: "credit_alphanum4", + label: "Alphanumeric 4", + value: { + type: "credit_alphanum4", + code: "", + issuer: "", + }, + }, + { + id: "credit_alphanum12", + label: "Alphanumeric 12", + value: { + type: "credit_alphanum12", + code: "", + issuer: "", + }, + }, + // TODO: add Liquidity Pool shares (for Change Trust operation) + ]; + } else { + options = [ + ...options, + { + id: "issued", + label: "Issued", + value: { + type: "issued", + code: "", + issuer: "", + }, + }, + ]; + } + + const updateAssetAtIndex = (value: AssetObjectValue, index: number) => { + const updatedValues = [...values]; + updatedValues[index] = value; + + return updatedValues; + }; + + return ( + + <> + + {values.length ? ( + + <> + {values.map((value, index) => ( +
+ { + onChange( + updateAssetAtIndex( + { type: optionId, code: "", issuer: "" }, + index, + ), + ); + }} + options={options} + /> + + + ) => { + onChange( + updateAssetAtIndex( + { ...value, code: e.target.value }, + index, + ), + ); + }, + error: error?.[index]?.code || "", + }} + issuer={{ + value: value.issuer, + onChange: (e: React.ChangeEvent) => { + onChange( + updateAssetAtIndex( + { ...value, issuer: e.target.value }, + index, + ), + ); + }, + error: error?.[index]?.issuer || "", + }} + /> + + +
+ +
+
+ ))} + +
+ ) : null} + +
+ +
+ +
+ ); +}; + +type AssetPickerFieldsProps = { + id: string; + code: { + value: string; + error: string; + onChange: (e: React.ChangeEvent) => void; + }; + issuer: { + value: string; + error: string; + onChange: (e: React.ChangeEvent) => void; + }; +}; + +const AssetPickerFields = ({ id, code, issuer }: AssetPickerFieldsProps) => ( +
+ + +
+); diff --git a/src/components/FormElements/AssetPicker.tsx b/src/components/FormElements/AssetPicker.tsx index 45542ec7..c5a5b434 100644 --- a/src/components/FormElements/AssetPicker.tsx +++ b/src/components/FormElements/AssetPicker.tsx @@ -106,8 +106,7 @@ export const AssetPicker = ({ value.type, ), )} - offsetTop="custom" - customValue="0" + offsetTop="sm" > void; + isRequired?: boolean; +}; + type TemplateRenderOrderProps = { value: string | undefined; onChange: (optionId: string | undefined, optionValue?: string) => void; @@ -71,7 +79,7 @@ export const formComponentTemplate = ( ( + + ), + validate: validate.publicKey, + }; + case "destination_amount": + return { + render: (templ: TemplateRenderProps) => ( + + ), + validate: validate.amount, + }; + case "destination_asset": + return { + render: (templ: TemplateRenderAssetProps) => ( + + ), + validate: validate.asset, + }; + case "destination_assets": + return { + render: (templ: TemplateRenderAssetMultiProps) => ( + + ), + validate: validate.assetMulti, + }; case "end_time": return { render: (templ: TemplateRenderProps) => ( @@ -342,6 +413,23 @@ export const formComponentTemplate = ( ), validate: null, }; + case "reserves": + return { + render: (templ: TemplateRenderAssetMultiProps) => ( + + ), + validate: validate.assetMulti, + }; case "resolution": return { render: (templ: TemplateRenderProps) => ( @@ -378,7 +466,7 @@ export const formComponentTemplate = ( ( + + ), + validate: validate.publicKey, + }; + case "source_amount": + return { + render: (templ: TemplateRenderProps) => ( + + ), + validate: validate.amount, + }; + case "source_asset": + return { + render: (templ: TemplateRenderAssetProps) => ( + + ), + validate: validate.asset, + }; + case "source_assets": + return { + render: (templ: TemplateRenderAssetMultiProps) => ( + + ), + validate: validate.assetMulti, + }; case "sponsor": return { render: (templ: TemplateRenderProps) => ( diff --git a/src/components/layout/Box/index.tsx b/src/components/layout/Box/index.tsx index 932531ba..ef4663ac 100644 --- a/src/components/layout/Box/index.tsx +++ b/src/components/layout/Box/index.tsx @@ -12,7 +12,7 @@ export const Box = ({ ) & { children: React.ReactElement; addlClassName?: string }) => { return (
{children} diff --git a/src/constants/exploreEndpointsPages.ts b/src/constants/exploreEndpointsPages.ts index ef6cc400..cd7c1d0b 100644 --- a/src/constants/exploreEndpointsPages.ts +++ b/src/constants/exploreEndpointsPages.ts @@ -9,18 +9,15 @@ type ExploreEndpointsPagesProps = { nestedItems: { route: Routes; label: string; - form: - | { - docsUrl: string; - docsLabel?: string; - requestMethod: "GET" | "POST"; - endpointUrlTemplate: string; - requiredParams: string; - isStreaming?: boolean; - custom?: AnyObject; - } - // TODO: remove once all pages are filled - | undefined; + form: { + docsUrl: string; + docsLabel?: string; + requestMethod: "GET" | "POST"; + endpointUrlTemplate: string; + requiredParams: string; + isStreaming?: boolean; + custom?: AnyObject; + }; }[]; }[]; }; @@ -267,12 +264,29 @@ export const EXPLORE_ENDPOINTS_PAGES_HORIZON: ExploreEndpointsPagesProps = { { route: Routes.EXPLORE_ENDPOINTS_LIQUIDITY_POOLS, label: "All Liquidity Pools", - form: undefined, + form: { + docsUrl: + "https://developers.stellar.org/network/horizon/resources/list-liquidity-pools", + docsLabel: "liquidity pools", + requestMethod: "GET", + endpointUrlTemplate: + "/liquidity_pools{?reserves,cursor,limit,order}", + requiredParams: "", + isStreaming: true, + }, }, { route: Routes.EXPLORE_ENDPOINTS_LIQUIDITY_POOLS_SINGLE, label: "Single Liquidity Pool", - form: undefined, + form: { + docsUrl: + "https://developers.stellar.org/network/horizon/resources/retrieve-a-liquidity-pool", + docsLabel: "liquidity pool", + requestMethod: "GET", + endpointUrlTemplate: "/liquidity_pools/{liquidity_pool_id}", + requiredParams: "liquidity_pool_id", + isStreaming: true, + }, }, ], }, @@ -462,23 +476,88 @@ export const EXPLORE_ENDPOINTS_PAGES_HORIZON: ExploreEndpointsPagesProps = { ], }, { - route: Routes.EXPLORE_ENDPOINTS_PATHS_PAYMENT, + route: Routes.EXPLORE_ENDPOINTS_PATHS, label: "Paths", nestedItems: [ { - route: Routes.EXPLORE_ENDPOINTS_PATHS_PAYMENT, + route: Routes.EXPLORE_ENDPOINTS_PATHS, label: "Find Payment Paths", - form: undefined, + form: { + docsUrl: + "https://developers.stellar.org/network/horizon/aggregations/paths", + docsLabel: "payment paths", + requestMethod: "GET", + endpointUrlTemplate: + "/paths{?destination_asset_type,destination_asset_code,destination_asset_issuer,destination_amount,destination_account,source_account}", + requiredParams: + "source_account,destination_asset,destination_amount", + isStreaming: true, + custom: { + renderComponents: ["destination_asset"], + paramMapping: { + destination_asset_type: "destination_asset.type", + destination_asset_code: "destination_asset.code", + destination_asset_issuer: "destination_asset.issuer", + }, + destination_asset: { + assetInput: "alphanumeric", + includeNative: true, + }, + }, + }, }, { route: Routes.EXPLORE_ENDPOINTS_PATHS_STRICT_RECEIVE, label: "Find Strict Receive Payment Paths", - form: undefined, + form: { + docsUrl: + "https://developers.stellar.org/network/horizon/aggregations/paths/strict-receive", + docsLabel: "strict receive payment paths", + requestMethod: "GET", + endpointUrlTemplate: + "/paths/strict-receive{?,destination_asset_type,destination_asset_issuer,destination_asset_code,destination_amount,destination_account,source_account,source_assets}", + requiredParams: + "source_account,destination_asset,destination_amount", + isStreaming: true, + custom: { + renderComponents: ["destination_asset"], + paramMapping: { + destination_asset_type: "destination_asset.type", + destination_asset_code: "destination_asset.code", + destination_asset_issuer: "destination_asset.issuer", + }, + destination_asset: { + assetInput: "alphanumeric", + includeNative: true, + }, + }, + }, }, { route: Routes.EXPLORE_ENDPOINTS_PATHS_STRICT_SEND, label: "Find Strict Send Payment Paths", - form: undefined, + form: { + docsUrl: + "https://developers.stellar.org/network/horizon/aggregations/paths/strict-send", + docsLabel: "strict send payment paths", + requestMethod: "GET", + endpointUrlTemplate: + "/paths/strict-send{?source_amount,destination_account,destination_assets,source_asset_type,source_asset_issuer,source_asset_code}", + requiredParams: "source_asset,source_amount", + isStreaming: true, + custom: { + renderComponents: ["source_asset"], + paramMapping: { + source_asset_type: "source_asset.type", + source_asset_code: "source_asset.code", + source_asset_issuer: "source_asset.issuer", + }, + source_asset: { + assetInput: "alphanumeric", + includeNative: true, + }, + }, + }, }, ], }, diff --git a/src/constants/routes.ts b/src/constants/routes.ts index d0750f15..29d91d43 100644 --- a/src/constants/routes.ts +++ b/src/constants/routes.ts @@ -35,7 +35,7 @@ export enum Routes { EXPLORE_ENDPOINTS_OPERATIONS_LIQUIDITY_POOL = "/explore-endpoints/operations/liquidity-pool", EXPLORE_ENDPOINTS_OPERATIONS_TRANSACTION = "/explore-endpoints/operations/transaction", EXPLORE_ENDPOINTS_ORDER_BOOK_DETAILS = "/explore-endpoints/order-book/details", - EXPLORE_ENDPOINTS_PATHS_PAYMENT = "/explore-endpoints/paths/payment", + EXPLORE_ENDPOINTS_PATHS = "/explore-endpoints/paths", EXPLORE_ENDPOINTS_PATHS_STRICT_RECEIVE = "/explore-endpoints/paths/strict-receive", EXPLORE_ENDPOINTS_PATHS_STRICT_SEND = "/explore-endpoints/paths/strict-send", EXPLORE_ENDPOINTS_PAYMENTS = "/explore-endpoints/payments", diff --git a/src/helpers/isValidUrl.ts b/src/helpers/isValidUrl.ts index 16efd51c..e08f93fd 100644 --- a/src/helpers/isValidUrl.ts +++ b/src/helpers/isValidUrl.ts @@ -1,4 +1,8 @@ export const isValidUrl = (url: string) => { + if (!url.startsWith("http")) { + return false; + } + try { new URL(url); return true; diff --git a/src/validate/index.ts b/src/validate/index.ts index 1770efbd..7a456c3e 100644 --- a/src/validate/index.ts +++ b/src/validate/index.ts @@ -1,13 +1,17 @@ +import { amount } from "./methods/amount"; import { asset } from "./methods/asset"; import { assetCode } from "./methods/assetCode"; +import { assetMulti } from "./methods/assetMulti"; import { positiveInt } from "./methods/positiveInt"; import { publicKey } from "./methods/publicKey"; import { transactionHash } from "./methods/transactionHash"; import { xdr } from "./methods/xdr"; export const validate = { + amount, asset, assetCode, + assetMulti, positiveInt, publicKey, transactionHash, diff --git a/src/validate/methods/amount.ts b/src/validate/methods/amount.ts new file mode 100644 index 00000000..d4ef971f --- /dev/null +++ b/src/validate/methods/amount.ts @@ -0,0 +1,11 @@ +export const amount = (value: string) => { + if (value.toString().charAt(0) === "-") { + return "Amount can only be a positive number."; + } else if (!value.toString().match(/^[0-9]*(\.[0-9]+){0,1}$/g)) { + return "Amount can only contain numbers and a period for the decimal point."; + } else if (value.toString().match(/\.([0-9]){8,}$/g)) { + return "Amount can only support a precision of 7 decimals."; + } + + return false; +}; diff --git a/src/validate/methods/assetMulti.ts b/src/validate/methods/assetMulti.ts new file mode 100644 index 00000000..c3ab26b3 --- /dev/null +++ b/src/validate/methods/assetMulti.ts @@ -0,0 +1,35 @@ +import { isEmptyObject } from "@/helpers/isEmptyObject"; +import { assetCode } from "./assetCode"; +import { publicKey } from "./publicKey"; +import { AssetObjectValue } from "@/types/types"; +import { sanitizeArray } from "@/helpers/sanitizeArray"; + +export const assetMulti = ( + assets: AssetObjectValue[] | undefined, + isRequired?: boolean, +) => { + const errors = assets?.map((asset) => { + if (asset?.type && asset.type === "native") { + return false; + } + + const invalid = Object.entries({ + code: assetCode(asset?.code || "", asset?.type, isRequired), + issuer: publicKey(asset?.issuer || "", isRequired), + }).reduce((res, cur) => { + const [key, value] = cur; + + if (value) { + return { ...res, [key]: value }; + } + + return res; + }, {}); + + return isEmptyObject(invalid) ? false : invalid; + }); + + const sanitized = sanitizeArray(errors || []); + + return sanitized.length === 0 ? false : sanitized; +};