From 7ae0e66778d3843f72a9de725986924fbd787c8a Mon Sep 17 00:00:00 2001 From: Iveta Date: Fri, 15 Mar 2024 10:50:37 -0400 Subject: [PATCH] Explore Endpoints: data flow functional --- .../explore-endpoints/[[...pages]]/page.tsx | 167 +++++++++++++++++- src/helpers/isEmptyObject.ts | 5 + src/helpers/sanitizeObject.ts | 13 ++ src/hooks/usePrevious.ts | 11 ++ src/store/createStore.ts | 74 +++++++- src/types/types.ts | 1 + src/validate/index.ts | 11 ++ src/validate/methods/asset.ts | 60 +++++++ src/validate/methods/assetCode.ts | 36 ++++ src/validate/methods/positiveInt.ts | 9 + src/validate/methods/publicKey.ts | 21 +++ 11 files changed, 404 insertions(+), 4 deletions(-) create mode 100644 src/helpers/isEmptyObject.ts create mode 100644 src/helpers/sanitizeObject.ts create mode 100644 src/hooks/usePrevious.ts create mode 100644 src/validate/index.ts create mode 100644 src/validate/methods/asset.ts create mode 100644 src/validate/methods/assetCode.ts create mode 100644 src/validate/methods/positiveInt.ts create mode 100644 src/validate/methods/publicKey.ts diff --git a/src/app/(sidebar)/explore-endpoints/[[...pages]]/page.tsx b/src/app/(sidebar)/explore-endpoints/[[...pages]]/page.tsx index 33b49d27..44121722 100644 --- a/src/app/(sidebar)/explore-endpoints/[[...pages]]/page.tsx +++ b/src/app/(sidebar)/explore-endpoints/[[...pages]]/page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { usePathname } from "next/navigation"; import { Alert, @@ -19,10 +19,115 @@ import { SdsLink } from "@/components/SdsLink"; import { Routes } from "@/constants/routes"; import { WithInfoText } from "@/components/WithInfoText"; +import { useStore } from "@/store/useStore"; +import { validate } from "@/validate"; +import { isEmptyObject } from "@/helpers/isEmptyObject"; +import { AnyObject, Network } from "@/types/types"; + +// TODO: build URL with valid params +// TODO: render fields based on route +// TODO: add streaming + export default function ExploreEndpoints() { const pathname = usePathname(); + const { exploreEndpoints, network } = useStore(); + const { + params, + currentEndpoint, + network: endpointNetwork, + updateParams, + updateCurrentEndpoint, + updateNetwork, + resetParams, + } = exploreEndpoints; + + const requiredFields = ["sponsor"]; + + // TODO: fields to validate + const paramValidation = { + sponsor: validate.publicKey, + signer: validate.publicKey, + asset: validate.asset, + }; + + // TODO: + // const formParams = { + // sponsor: "", + // signer: "", + // // asset: "", + // cursor: "", + // limit: "", + // // order: "", + // }; + const [activeTab, setActiveTab] = useState("endpoints-tab-params"); + const [formError, setFormError] = useState({}); + const currentPage = pathname.split(Routes.EXPLORE_ENDPOINTS)?.[1]; + + const isSubmitEnabled = () => { + const missingReqFields = requiredFields.reduce((res, cur) => { + if (!params[cur]) { + return [...res, cur]; + } + + return res; + }, [] as string[]); + + if (missingReqFields.length !== 0) { + return false; + } + + return isEmptyObject(formError); + }; + + useEffect(() => { + // Validate saved params when the page loads + const paramErrors = () => { + return Object.keys(params).reduce((res, param) => { + const error = (paramValidation as any)?.[param]( + params[param], + requiredFields.includes(param), + ); + + if (error) { + return { ...res, [param]: error }; + } + + return res; + }, {}); + }; + + setFormError(paramErrors()); + + // We want to check this only when the page mounts for the first time + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + useEffect(() => { + if (currentPage) { + updateCurrentEndpoint(currentPage); + } + + // Clear form and errors if navigating to another endpoint page. We don't + // want to keep previous form values. + if (currentEndpoint && currentEndpoint !== currentPage) { + resetParams(); + setFormError({}); + } + }, [currentPage, currentEndpoint, updateCurrentEndpoint, resetParams]); + + useEffect(() => { + // Save network for endpoints if we don't have it yet. + if (network.id && !endpointNetwork.id) { + updateNetwork(network as Network); + // When network changes, clear saved params and errors. + } else if (network.id && network.id !== endpointNetwork.id) { + resetParams(); + setFormError({}); + updateNetwork(network as Network); + } + }, [endpointNetwork.id, network, resetParams, updateNetwork]); if (pathname === Routes.EXPLORE_ENDPOINTS) { return ; @@ -46,8 +151,12 @@ export default function ExploreEndpoints() { // TODO: set request type leftElement={
GET
} /> - {/* TODO: disable if can't submit */} - {/* TODO: add text to copy */} @@ -64,6 +173,58 @@ export default function ExploreEndpoints() {
{/* TODO: render fields for path */} {`Explore Endpoints: ${pathname}`} + +
+ { + updateParams({ [e.target.id]: e.target.value }); + const error = paramValidation.sponsor( + e.target.value, + requiredFields.includes(e.target.id), + ); + + if (error) { + setFormError({ ...formError, [e.target.id]: error }); + } else { + if (formError[e.target.id]) { + const updatedErrors = { ...formError }; + delete updatedErrors[e.target.id]; + setFormError(updatedErrors); + } + } + }} + error={formError.sponsor} + /> + + { + updateParams({ [e.target.id]: e.target.value }); + const error = paramValidation.signer( + e.target.value, + requiredFields.includes(e.target.id), + ); + + if (error) { + setFormError({ ...formError, [e.target.id]: error }); + } else { + if (formError[e.target.id]) { + const updatedErrors = { ...formError }; + delete updatedErrors[e.target.id]; + setFormError(updatedErrors); + } + } + }} + error={formError.signer} + /> +
diff --git a/src/helpers/isEmptyObject.ts b/src/helpers/isEmptyObject.ts new file mode 100644 index 00000000..3547627e --- /dev/null +++ b/src/helpers/isEmptyObject.ts @@ -0,0 +1,5 @@ +import { AnyObject } from "@/types/types"; + +export const isEmptyObject = (obj: AnyObject) => { + return Object.keys(obj).length === 0; +}; diff --git a/src/helpers/sanitizeObject.ts b/src/helpers/sanitizeObject.ts new file mode 100644 index 00000000..54268caa --- /dev/null +++ b/src/helpers/sanitizeObject.ts @@ -0,0 +1,13 @@ +import { AnyObject } from "@/types/types"; + +export const sanitizeObject = (obj: T) => { + return Object.keys(obj).reduce((res, param) => { + const paramValue = obj[param]; + + if (paramValue) { + return { ...res, [param]: paramValue }; + } + + return res; + }, {} as T); +}; diff --git a/src/hooks/usePrevious.ts b/src/hooks/usePrevious.ts new file mode 100644 index 00000000..ea66eb11 --- /dev/null +++ b/src/hooks/usePrevious.ts @@ -0,0 +1,11 @@ +import { useEffect, useRef } from "react"; + +export const usePrevious = (value: T) => { + const ref = useRef(); + + useEffect(() => { + ref.current = value; + }, [value]); + + return ref.current; +}; diff --git a/src/store/createStore.ts b/src/store/createStore.ts index a47d98e6..1e963395 100644 --- a/src/store/createStore.ts +++ b/src/store/createStore.ts @@ -1,12 +1,15 @@ import { create } from "zustand"; import { immer } from "zustand/middleware/immer"; import { querystring } from "zustand-querystring"; -import { EmptyObj, Network } from "@/types/types"; + +import { AnyObject, EmptyObj, Network } from "@/types/types"; +import { sanitizeObject } from "@/helpers/sanitizeObject"; export interface Store { // Shared network: Network | EmptyObj; selectNetwork: (network: Network) => void; + resetStoredData: () => void; // Account account: { @@ -22,22 +25,57 @@ export interface Store { }) => void; reset: () => void; }; + + // Explore Endpoints + exploreEndpoints: { + // TODO: do we need this? + network: Network | EmptyObj; + currentEndpoint: string | undefined; + // TODO: ??? type every endpoint and use that type here + params: AnyObject; + // TODO: move to params? + isStreaming: boolean; + updateNetwork: (network: Network) => void; + updateCurrentEndpoint: (endpoint: string) => void; + updateParams: (params: AnyObject) => void; + resetParams: () => void; + reset: () => void; + }; } interface CreateStoreOptions { url?: string; } +// Initial states +const initExploreEndpointState = { + network: {}, + currentEndpoint: undefined, + params: {}, + isStreaming: false, +}; + // Store export const createStore = (options: CreateStoreOptions) => create()( + // https://github.com/nitedani/zustand-querystring querystring( immer((set) => ({ + // Shared network: {}, selectNetwork: (network: Network) => set((state) => { state.network = network; }), + resetStoredData: () => + set((state) => { + // Add stores that need global reset + state.exploreEndpoints = { + ...state.exploreEndpoints, + ...initExploreEndpointState, + }; + }), + // Account account: { value: "", nestedObject: { @@ -60,6 +98,36 @@ export const createStore = (options: CreateStoreOptions) => state.account.value = ""; }), }, + // Explore Endpoints + exploreEndpoints: { + ...initExploreEndpointState, + updateNetwork: (network: Network) => + set((state) => { + state.exploreEndpoints.network = network; + }), + updateCurrentEndpoint: (endpoint: string) => + set((state) => { + state.exploreEndpoints.currentEndpoint = endpoint; + }), + updateParams: (params: AnyObject) => + set((state) => { + state.exploreEndpoints.params = sanitizeObject({ + ...state.exploreEndpoints.params, + ...params, + }); + }), + resetParams: () => + set((state) => { + state.exploreEndpoints.params = {}; + }), + reset: () => + set((state) => { + state.exploreEndpoints = { + ...state.exploreEndpoints, + ...initExploreEndpointState, + }; + }), + }, })), { url: options.url, @@ -68,6 +136,10 @@ export const createStore = (options: CreateStoreOptions) => return { network: true, account: true, + exploreEndpoints: { + params: true, + isStreaming: true, + }, }; }, key: "||", diff --git a/src/types/types.ts b/src/types/types.ts index d621088f..a4177afc 100644 --- a/src/types/types.ts +++ b/src/types/types.ts @@ -1,6 +1,7 @@ // ============================================================================= // Generic // ============================================================================= +export type AnyObject = { [key: string]: any }; export type EmptyObj = Record; // ============================================================================= diff --git a/src/validate/index.ts b/src/validate/index.ts new file mode 100644 index 00000000..45e11173 --- /dev/null +++ b/src/validate/index.ts @@ -0,0 +1,11 @@ +import { asset } from "./methods/asset"; +import { assetCode } from "./methods/assetCode"; +import { positiveInt } from "./methods/positiveInt"; +import { publicKey } from "./methods/publicKey"; + +export const validate = { + asset, + assetCode, + positiveInt, + publicKey, +}; diff --git a/src/validate/methods/asset.ts b/src/validate/methods/asset.ts new file mode 100644 index 00000000..c82f6518 --- /dev/null +++ b/src/validate/methods/asset.ts @@ -0,0 +1,60 @@ +import { isEmptyObject } from "@/helpers/isEmptyObject"; +import { assetCode } from "./assetCode"; +import { publicKey } from "./publicKey"; + +// TODO: remove once other PR is merged +type AssetType = + | "none" + | "native" + | "issued" + | "credit_alphanum4" + | "credit_alphanum12" + | "liquidity_pool_shares"; + +type AssetObjectValue = { + type: AssetType | undefined; + code: string; + issuer: string; +}; + +export const asset = ( + asset: string | AssetObjectValue, + isRequired?: boolean, +) => { + let code; + let issuer; + let type; + + if (typeof asset === "string") { + // No need to validate native asset + if (asset === "native") { + return false; + } + + [code, issuer] = asset.split(":"); + } else { + // No need to validate native asset + if (asset.type === "native") { + return false; + } + + code = asset?.code; + issuer = asset?.issuer; + type = asset?.type; + } + + const invalid = Object.entries({ + code: assetCode(code, type, isRequired), + issuer: publicKey(issuer, isRequired), + }).reduce((res, cur) => { + const [key, value] = cur; + + if (value) { + return { ...res, [key]: value }; + } + + return res; + }, {}); + + return isEmptyObject(invalid) ? false : invalid; +}; diff --git a/src/validate/methods/assetCode.ts b/src/validate/methods/assetCode.ts new file mode 100644 index 00000000..aee5c54b --- /dev/null +++ b/src/validate/methods/assetCode.ts @@ -0,0 +1,36 @@ +import { AssetType } from "@/types/types"; + +export const assetCode = ( + code: string, + assetType: AssetType | undefined, + isRequired?: boolean, +) => { + if (isRequired && !code) { + return "Asset code is required."; + } + + let minLength; + let maxLength; + + switch (assetType) { + case "credit_alphanum4": + minLength = 1; + maxLength = 4; + break; + case "credit_alphanum12": + minLength = 5; + maxLength = 12; + break; + default: + minLength = 1; + maxLength = 12; + } + + if (!code.match(/^[a-zA-Z0-9]+$/g)) { + return "Asset code must consist of only letters and numbers."; + } else if (code.length < minLength || code.length > maxLength) { + return `Asset code must be between ${minLength} and ${maxLength} characters long.`; + } + + return false; +}; diff --git a/src/validate/methods/positiveInt.ts b/src/validate/methods/positiveInt.ts new file mode 100644 index 00000000..a0702b43 --- /dev/null +++ b/src/validate/methods/positiveInt.ts @@ -0,0 +1,9 @@ +export const positiveInt = (value: string) => { + if (value.charAt(0) === "-") { + return "Expected a positive number or zero."; + } else if (!value.match(/^[0-9]*$/g)) { + return "Expected a whole number."; + } + + return false; +}; diff --git a/src/validate/methods/publicKey.ts b/src/validate/methods/publicKey.ts new file mode 100644 index 00000000..deaae556 --- /dev/null +++ b/src/validate/methods/publicKey.ts @@ -0,0 +1,21 @@ +import { StrKey } from "stellar-sdk"; + +export const publicKey = (publicKey: string, isRequired?: boolean) => { + if (!publicKey) { + if (isRequired) { + return "Asset issuer is required."; + } else { + return false; + } + } + + if (publicKey.startsWith("M")) { + if (!StrKey.isValidMed25519PublicKey(publicKey)) { + return "Muxed account address is invalid."; + } + } else if (!StrKey.isValidEd25519PublicKey(publicKey)) { + return "Public key is invalid."; + } + + return false; +};