diff --git a/public/locales/en.json b/public/locales/en.json index c4262e8..3aea293 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -389,12 +389,18 @@ "myTransactionsTitle": "My Transactions", "nameHistoryTitle": "Name History", "nameTransactionsTitle": "Name Transactions", + "searchTitle": "Transaction Search", "siteTitleWallets": "My Transactions", "siteTitleNetworkAll": "Network Transactions", "siteTitleNetworkAddress": "{{address}}'s Transactions", "siteTitleNameHistory": "Name History", "siteTitleNameSent": "Name Transactions", + "siteTitleSearch": "Transaction Search", + + "subTitleSearchAddress": "Involving {{address}}", + "subTitleSearchName": "Involving {{name}}", + "subTitleSearchMetadata": "With metadata '{{query}}'", "columnID": "ID", "columnType": "Type", diff --git a/src/global/AppRouter.tsx b/src/global/AppRouter.tsx index 85a5f16..4223701 100644 --- a/src/global/AppRouter.tsx +++ b/src/global/AppRouter.tsx @@ -61,6 +61,12 @@ component: }, { path: "/network/names/:name/transactions", name: "nameTransactions", component: }, + { path: "/network/search/transactions/address", name: "searchTransactionsAddress", + component: }, + { path: "/network/search/transactions/name", name: "searchTransactionsName", + component: }, + { path: "/network/search/transactions/metadata", name: "searchTransactionsMetadata", + component: }, // Settings { path: "/settings", name: "settings", component: }, diff --git a/src/krist/api/index.ts b/src/krist/api/index.ts index 956417b..a5c3fd9 100644 --- a/src/krist/api/index.ts +++ b/src/krist/api/index.ts @@ -39,7 +39,7 @@ const syncNode = store.getState().node.syncNode; // Let the fetch bubble its error upwards - const res = await fetch(syncNode + "/" + endpoint, { + const res = await fetch(syncNode + "/" + endpoint.replace(/^\//, ""), { method, ...options }); diff --git a/src/krist/api/lookup.ts b/src/krist/api/lookup.ts index 0fef0df..6089c9c 100644 --- a/src/krist/api/lookup.ts +++ b/src/krist/api/lookup.ts @@ -18,7 +18,10 @@ export interface KristAddressWithNames extends KristAddress { names?: number } export type AddressLookupResults = Record; -export async function lookupAddresses(addresses: string[], fetchNames?: boolean): Promise { +export async function lookupAddresses( + addresses: string[], + fetchNames?: boolean +): Promise { if (!addresses || addresses.length === 0) return {}; try { @@ -38,7 +41,10 @@ } /** Uses the lookup API to retrieve a single address. */ -export async function lookupAddress(address: string, fetchNames?: boolean): Promise { +export async function lookupAddress( + address: string, + fetchNames?: boolean +): Promise { const data = await api.get( "lookup/addresses/" + encodeURIComponent(address) @@ -62,7 +68,9 @@ blocks: KristBlock[]; } -export async function lookupBlocks(opts: LookupBlocksOptions): Promise { +export async function lookupBlocks( + opts: LookupBlocksOptions +): Promise { const qs = getFilterOptionsQuery(opts); return await api.get("lookup/blocks?" + qs); } @@ -76,41 +84,57 @@ export enum LookupTransactionType { TRANSACTIONS, NAME_HISTORY, - NAME_TRANSACTIONS + NAME_TRANSACTIONS, + SEARCH, } export interface LookupTransactionsOptions extends LookupFilterOptionsBase { includeMined?: boolean; type?: LookupTransactionType; + searchType?: "address" | "name" | "metadata"; } export interface LookupTransactionsResponse extends LookupResponseBase { transactions: KristTransaction[]; } -export async function lookupTransactions(addresses: string[] | undefined, opts: LookupTransactionsOptions): Promise { +/** Maps a transaction lookup type to its appropriate API endpoint. */ +function getTransactionLookupRoute( + addresses: string[] | undefined, + opts: LookupTransactionsOptions +): string { + // Combine an address array into comma-separated values + const addressList = addresses && addresses.length > 0 + ? encodeURIComponent(addresses.join(",")) + : ""; + + switch (opts.type ?? LookupTransactionType.TRANSACTIONS) { + case LookupTransactionType.TRANSACTIONS: + return "lookup/transactions/" + addressList; + case LookupTransactionType.NAME_HISTORY: + return "lookup/names/" + addressList + "/history"; + case LookupTransactionType.NAME_TRANSACTIONS: + return "lookup/names/" + addressList + "/transactions"; + case LookupTransactionType.SEARCH: + return "search/extended/results/transactions/" + opts.searchType; + } +} + +export async function lookupTransactions( + addresses: string[] | undefined, + opts: LookupTransactionsOptions +): Promise { const qs = getFilterOptionsQuery(opts); if (opts.includeMined) qs.append("includeMined", ""); - // Map the lookup type to the appropriate route - // TODO: this is kinda wack const type = opts.type ?? LookupTransactionType.TRANSACTIONS; - const route = type === LookupTransactionType.TRANSACTIONS - ? "transactions" : "names"; - const routeExtra = type !== LookupTransactionType.TRANSACTIONS - ? (type === LookupTransactionType.NAME_HISTORY - ? "/history" - : "/transactions") - : ""; + const route = getTransactionLookupRoute(addresses, opts); - return await api.get( - `lookup/${route}/` - + (addresses && addresses.length > 0 - ? encodeURIComponent(addresses.join(",")) - : "") - + routeExtra - + `?${qs}` - ); + // For searches, append the search query as a query parameter + if (type === LookupTransactionType.SEARCH) + qs.append("q", addresses?.[0] || ""); + + return await api.get(route + "?" + qs); } // ============================================================================= @@ -124,7 +148,10 @@ names: KristName[]; } -export async function lookupNames(addresses: string[] | undefined, opts: LookupNamesOptions): Promise { +export async function lookupNames( + addresses: string[] | undefined, + opts: LookupNamesOptions +): Promise { const qs = getFilterOptionsQuery(opts); return await api.get( "lookup/names/" diff --git a/src/layout/nav/Search.tsx b/src/layout/nav/Search.tsx index cdef7af..0d84fd7 100644 --- a/src/layout/nav/Search.tsx +++ b/src/layout/nav/Search.tsx @@ -206,12 +206,16 @@ } else if (query === "exactTransaction" && exactTransaction) { history.push(`/network/transactions/${encodeURIComponent(exactTransaction.id)}`); } else if (extendedResults) { + const { originalQuery } = extendedResults.query; + const q = "?q=" + encodeURIComponent(originalQuery); + debug("extended search query: %s (?q: %s)", originalQuery, q); + if (query === "extendedTransactionsAddress") { - // TODO + history.push("/network/search/transactions/address" + q); } else if (query === "extendedTransactionsName") { - // TODO + history.push("/network/search/transactions/name" + q); } else if (query === "extendedTransactionsMetadata") { - // TODO + history.push("/network/search/transactions/metadata" + q); } else { matched = false; debug("warn: unknown search type %s", query); @@ -315,7 +319,7 @@ label: }); diff --git a/src/pages/transactions/TransactionsPage.tsx b/src/pages/transactions/TransactionsPage.tsx index 0b27789..6ed4e01 100644 --- a/src/pages/transactions/TransactionsPage.tsx +++ b/src/pages/transactions/TransactionsPage.tsx @@ -5,7 +5,8 @@ import { Switch } from "antd"; import { useTranslation, TFunction } from "react-i18next"; -import { useParams } from "react-router-dom"; +import { useParams, useLocation } from "react-router-dom"; +import { Location } from "history"; import { useSelector, shallowEqual } from "react-redux"; import { RootState } from "../../store"; @@ -36,6 +37,13 @@ NAME_HISTORY, /** Transactions sent to a particular name */ NAME_SENT, + + /** Transaction search for address */ + SEARCH_ADDRESS, + /** Transaction search for name */ + SEARCH_NAME, + /** Transaction search for metadata */ + SEARCH_METADATA } const LISTING_TYPE_TITLES: Record = { @@ -45,12 +53,17 @@ [ListingType.NETWORK_ADDRESS]: "transactions.title", [ListingType.NAME_HISTORY]: "transactions.nameHistoryTitle", - [ListingType.NAME_SENT]: "transactions.nameTransactionsTitle" + [ListingType.NAME_SENT]: "transactions.nameTransactionsTitle", + + [ListingType.SEARCH_ADDRESS]: "transactions.searchTitle", + [ListingType.SEARCH_NAME]: "transactions.searchTitle", + [ListingType.SEARCH_METADATA]: "transactions.searchTitle" }; interface ParamTypes { address?: string; name?: string; + query?: string; } interface Props { @@ -71,6 +84,35 @@ return t("transactions.siteTitleNameHistory"); case ListingType.NAME_SENT: return t("transactions.siteTitleNameSent"); + case ListingType.SEARCH_ADDRESS: + case ListingType.SEARCH_NAME: + case ListingType.SEARCH_METADATA: + return t("transactions.siteTitleSearch"); + } +} + +/** Returns the correct PageHeader sub title for the given listing type. */ +function getSubTitle(t: TFunction, listingType: ListingType, params: ParamTypes): React.ReactNode { + switch (listingType) { + // Lookup for an individual address's transactions show that address + case ListingType.NETWORK_ADDRESS: + return params.address; + + // Name lookups show the name + case ListingType.NAME_HISTORY: + case ListingType.NAME_SENT: + return ; + + // The searches show a special sub title for each type of query + case ListingType.SEARCH_ADDRESS: + return t("transactions.subTitleSearchAddress", { address: params.address }); + case ListingType.SEARCH_NAME: + return t("transactions.subTitleSearchName", { name: params.name }); + case ListingType.SEARCH_METADATA: + return t("transactions.subTitleSearchMetadata", { query: params.query }); + + // Everything else does not show a sub title + default: return undefined; } } @@ -88,15 +130,53 @@ return includeMined ? node.lastTransactionID : node.lastNonMinedTransactionID; + // No auto-refresh for searches + case ListingType.SEARCH_ADDRESS: + case ListingType.SEARCH_NAME: + case ListingType.SEARCH_METADATA: + return 0; + } +} + +/** + * Returns the lookup parameters based on the URL. Uses 'address' and 'name' + * from the params unless this is a search, in which case they are derived from + * the search query parameter `?q`. + */ +function getParams( + listingType: ListingType, + urlParams: ParamTypes, + location: Location +): ParamTypes { + // Parse the query parameters + const qs = new URLSearchParams(location.search); + + switch (listingType) { + // For the search lookups, get the params from the URL + case ListingType.SEARCH_ADDRESS: + return { address: qs.get("q") || "" }; + case ListingType.SEARCH_NAME: + return { name: qs.get("q") || "" }; + case ListingType.SEARCH_METADATA: + return { query: qs.get("q") || "" }; + // For everything else, return what we already have + default: + return urlParams; } } export function TransactionsPage({ listingType }: Props): JSX.Element { const { t } = useTranslation(); - const { address, name } = useParams(); - const alwaysIncludeMined = useBooleanSetting("alwaysIncludeMined"); + // Derive the lookup parameters from the URL + const urlParams = useParams(); + const location = useLocation(); + const { address, name, query } = getParams(listingType, urlParams, location); + + // Whether or not to show mined transactions + const alwaysIncludeMined = useBooleanSetting("alwaysIncludeMined"); const [includeMined, setIncludeMined] = useState(alwaysIncludeMined); + // If there is an error (e.g. the lookup rejected the address list due to an // invalid address), the table will bubble it up to here const [error, setError] = useState(); @@ -127,30 +207,32 @@ addresses={usedAddresses?.split(",")} name={name} + query={query} includeMined={includeMined} setError={setError} setPagination={setPagination} /> - ), [listingType, usedAddresses, name, usedRefreshID, includeMined, setError, setPagination]); + ), [ + listingType, + usedAddresses, name, query, + usedRefreshID, + includeMined, + setError, setPagination + ]); + // Alter the page titles depending on the listing type + const titleKey = LISTING_TYPE_TITLES[listingType]; const siteTitle = getSiteTitle(t, listingType, address); - const subTitle = name - ? - : (listingType === ListingType.NETWORK_ADDRESS - ? address - : undefined); + const subTitle = getSubTitle(t, listingType, { address, name, query }); return - - {t("transactions.includeMined")} - } + {listingType !== ListingType.SEARCH_METADATA && !name && ( +
+ + {t("transactions.includeMined")} +
+ )} }
; } diff --git a/src/pages/transactions/TransactionsTable.tsx b/src/pages/transactions/TransactionsTable.tsx index 3a05500..be0714d 100644 --- a/src/pages/transactions/TransactionsTable.tsx +++ b/src/pages/transactions/TransactionsTable.tsx @@ -31,7 +31,10 @@ [1]: LookupTransactionType.TRANSACTIONS, [2]: LookupTransactionType.TRANSACTIONS, [3]: LookupTransactionType.NAME_HISTORY, - [4]: LookupTransactionType.NAME_TRANSACTIONS + [4]: LookupTransactionType.NAME_TRANSACTIONS, + [5]: LookupTransactionType.SEARCH, + [6]: LookupTransactionType.SEARCH, + [7]: LookupTransactionType.SEARCH, }; interface Props { @@ -42,6 +45,7 @@ addresses?: string[]; name?: string; + query?: string; includeMined?: boolean; @@ -49,10 +53,23 @@ setPagination?: Dispatch>; } +/** Map the search listing types to their API endpoint name */ +function getLookupSearchType(listingType: ListingType): "address" | "name" | "metadata" | undefined { + switch (listingType) { + case ListingType.SEARCH_ADDRESS: + return "address"; + case ListingType.SEARCH_NAME: + return "name"; + case ListingType.SEARCH_METADATA: + return "metadata"; + default: return undefined; + } +} + export function TransactionsTable({ listingType, refreshingID, - addresses, name, + addresses, name, query, includeMined, setError, setPagination }: Props): JSX.Element { @@ -80,15 +97,20 @@ debug("looking up transactions (type: %d mapped: %d) for %s", listingType, LISTING_TYPE_MAP[listingType], name || (addresses ? addresses.join(",") : "network")); setLoading(true); - lookupTransactions(name ? [name] : addresses, { + const lookupQuery = query + ? [query] + : (name ? [name] : addresses); + + lookupTransactions(lookupQuery, { ...options, includeMined, - type: LISTING_TYPE_MAP[listingType] + type: LISTING_TYPE_MAP[listingType], + searchType: getLookupSearchType(listingType) }) .then(setRes) .catch(setError) .finally(() => setLoading(false)); - }, [listingType, refreshingID, addresses, name, setError, options, includeMined]); + }, [listingType, refreshingID, addresses, name, query, setError, options, includeMined]); debug("results? %b res.transactions.length: %d res.count: %d res.total: %d", !!res, res?.transactions?.length, res?.count, res?.total);