Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions packages/admin-ui/src/components/primitives/search.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import * as React from "react";
import { CornerDownLeft, Search } from "lucide-react";
import { cn } from "lib/utils";
import { Input } from "./input";

interface SearchBarProps extends Omit<React.ComponentProps<"div">, "onChange"> {
value?: string;
onChange?: React.ChangeEventHandler<HTMLInputElement>;
onEnter?: () => void;
placeholder?: string;
showEnterHint?: boolean;
}

function SearchBar({
value,
onChange,
onEnter,
placeholder,
showEnterHint,
className,
...props
}: SearchBarProps) {
return (
<div role="search" data-slot="search" className={cn("tw:relative", className)} {...props}>
<Search aria-hidden="true" className="tw:pointer-events-none tw:absolute tw:left-3 tw:top-1/2 tw:-translate-y-1/2 tw:size-4 tw:text-muted-foreground" />
<Input
value={value}
onChange={onChange}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onEnter?.();
}
}}
placeholder={placeholder}
aria-keyshortcuts={showEnterHint ? "Enter" : undefined}
className={cn(
"tw:h-10 tw:bg-background tw:pl-9 tw:shadow-sm tw:dark:bg-input/50",
showEnterHint && "tw:pr-28"
)}
/>
{showEnterHint && (
<span
aria-hidden="true"
className="tw:absolute tw:right-3 tw:top-1/2 tw:-translate-y-1/2 tw:flex tw:items-center tw:gap-1 tw:text-xs tw:text-muted-foreground tw:pointer-events-none"
>
Press <CornerDownLeft aria-hidden="true" className="tw:size-3" /> to open
</span>
)}
</div>
);
}

export { SearchBar };
64 changes: 51 additions & 13 deletions packages/admin-ui/src/pages-new/packages/store/StorePage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React, { useEffect, useMemo } from "react";
import React, { useEffect, useMemo, useState } from "react";
import { useSelector, useDispatch } from "react-redux";
import { useNavigate } from "react-router-dom";
import { DirectoryItemOk } from "@dappnode/types";
Expand All @@ -7,7 +7,11 @@ import { fetchDnpDirectory } from "services/dnpDirectory/actions";
import { PageContainer, PageHeader } from "components/primitives/page";
import { Alert, AlertTitle, AlertDescription } from "components/primitives/alert";
import { Empty, EmptyHeader, EmptyMedia, EmptyTitle, EmptyDescription } from "components/primitives/empty";
import { SearchBar } from "components/primitives/search";
import { PackageOpen, TriangleAlert } from "lucide-react";
import isIpfsHash from "utils/isIpfsHash";
import isDnpDomain from "utils/isDnpDomain";
import { correctPackageName } from "pages/installer/utils";
import { StoreGrid } from "./StoreGrid";
import { StoreGridSkeleton } from "./StoreGridSkeleton";
import { PackagesConfig, matchesDirectoryFilter } from "../config";
Expand All @@ -16,27 +20,40 @@ interface StorePageProps {
config: PackagesConfig;
}

/**
* Shared Store page — displays a grid of DNP packages from the on-chain
* directory, filtered by the supplied category configuration.
*/
export function StorePage({ config }: StorePageProps) {
const dispatch = useDispatch();
const navigate = useNavigate();
const directory = useSelector(getDnpDirectory);
const requestStatus = useSelector(getDirectoryRequestStatus);

const [search, setSearch] = useState("");
const trimmedSearch = search.trim();

useEffect(() => {
dispatch(fetchDnpDirectory());
}, [dispatch]);

/** Filter directory items by the section's category config. */
const filteredPackages = useMemo<DirectoryItemOk[]>(
() =>
directory.filter(
(item): item is DirectoryItemOk => item.status === "ok" && matchesDirectoryFilter(item, config.categoryFilter)
),
[directory, config.categoryFilter]
const filteredPackages = useMemo<DirectoryItemOk[]>(() => {
const sectionPackages = directory.filter(
(item): item is DirectoryItemOk => item.status === "ok" && matchesDirectoryFilter(item, config.categoryFilter)
);

const query = trimmedSearch.toLowerCase();
if (!query) return sectionPackages;

return sectionPackages.filter(
(item) =>
item.name.toLowerCase().includes(query) ||
(item.description ?? "").toLowerCase().includes(query) ||
item.categories.some((cat) => cat.toLowerCase().includes(query))
);
}, [directory, config.categoryFilter, trimmedSearch]);

const cleanedQuery = useMemo(() => correctPackageName(trimmedSearch), [trimmedSearch]);

const enterHint = useMemo(
() => cleanedQuery && (isIpfsHash(cleanedQuery) || isDnpDomain(cleanedQuery) || filteredPackages.length === 1),
[cleanedQuery, filteredPackages.length]
);

function handlePackageClick(item: DirectoryItemOk) {
Expand All @@ -48,13 +65,32 @@ export function StorePage({ config }: StorePageProps) {
}
}

function runQuery() {
if (!cleanedQuery) return;
if (isIpfsHash(cleanedQuery) || isDnpDomain(cleanedQuery)) {
navigate(`${config.installerPath}/${encodeURIComponent(cleanedQuery)}`);
return;
}
if (filteredPackages.length === 1) {
handlePackageClick(filteredPackages[0]);
}
}

return (
<PageContainer>
<PageHeader
title={`${config.sectionLabel} Store`}
description={`Browse and install ${config.sectionLabel} packages on your Dappnode.`}
/>

<SearchBar
value={search}
onChange={(e) => setSearch(e.target.value)}
onEnter={runQuery}
placeholder="Search packages — or paste a DNP name / IPFS hash and press Enter"
showEnterHint={!!enterHint}
/>

{requestStatus.loading && !directory.length ? (
<StoreGridSkeleton />
) : requestStatus.error ? (
Expand All @@ -71,7 +107,9 @@ export function StorePage({ config }: StorePageProps) {
</EmptyMedia>
<EmptyTitle>No packages found</EmptyTitle>
<EmptyDescription>
There are no matching packages in the Dappnode directory yet. Check back soon!
{trimmedSearch
? `No packages match "${trimmedSearch}". Try a different search.`
: "There are no matching packages in the Dappnode directory yet. Check back soon!"}
</EmptyDescription>
</EmptyHeader>
</Empty>
Expand Down
Loading