Commit 52f4e019 authored by tom goriunov's avatar tom goriunov Committed by GitHub

Merge pull request #804 from blockscout/marketplace/search-params-in-url

marketplace: search params in url
parents 1606b9c9 4730abf2
......@@ -5,7 +5,7 @@ import React from 'react';
import MarketplaceApp from 'ui/pages/MarketplaceApp';
import Page from 'ui/shared/Page/Page';
const AppPage: NextPage = () => {
const MarketplaceAppPage: NextPage = () => {
return (
<Page wrapChildren={ false }>
<Head><title>Blockscout | Marketplace</title></Head>
......@@ -14,6 +14,6 @@ const AppPage: NextPage = () => {
);
};
export default AppPage;
export default MarketplaceAppPage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
......@@ -2,21 +2,21 @@ import type { NextPage } from 'next';
import Head from 'next/head';
import React from 'react';
import Apps from 'ui/pages/Apps';
import Marketplace from 'ui/pages/Marketplace';
import Page from 'ui/shared/Page/Page';
import PageTitle from 'ui/shared/Page/PageTitle';
const AppsPage: NextPage = () => {
const MarketplacePage: NextPage = () => {
return (
<Page>
<PageTitle text="Apps"/>
<PageTitle text="Marketplace"/>
<Head><title>Blockscout | Marketplace</title></Head>
<Apps/>
<Marketplace/>
</Page>
);
};
export default AppsPage;
export default MarketplacePage;
export { getServerSideProps } from 'lib/next/getServerSideProps';
export type AppItemPreview = {
export type MarketplaceAppPreview = {
id: string;
external?: boolean;
title: string;
logo: string;
logoDarkMode?: string;
shortDescription: string;
categories: Array<string>;
url: string;
}
export type AppItemOverview = AppItemPreview & {
export type MarketplaceAppOverview = MarketplaceAppPreview & {
author: string;
description: string;
site?: string;
......@@ -17,7 +18,7 @@ export type AppItemOverview = AppItemPreview & {
github?: string;
}
export enum AppCategory {
export enum MarketplaceCategory {
ALL = 'All apps',
FAVORITES = 'Favorites',
}
......@@ -2,26 +2,27 @@ import { Box, Heading, Icon, IconButton, Image, Link, LinkBox, Text, useColorMod
import type { MouseEvent } from 'react';
import React, { useCallback } from 'react';
import type { AppItemPreview } from 'types/client/apps';
import type { MarketplaceAppPreview } from 'types/client/marketplace';
import northEastIcon from 'icons/arrows/north-east.svg';
import starFilledIcon from 'icons/star_filled.svg';
import starOutlineIcon from 'icons/star_outline.svg';
import AppCardLink from './AppCardLink';
import MarketplaceAppCardLink from './MarketplaceAppCardLink';
interface Props extends AppItemPreview {
interface Props extends MarketplaceAppPreview {
onInfoClick: (id: string) => void;
isFavorite: boolean;
onFavoriteClick: (id: string, isFavorite: boolean) => void;
}
const AppCard = ({
const MarketplaceAppCard = ({
id,
url,
external,
title,
logo,
logoDarkMode,
shortDescription,
categories,
onInfoClick,
......@@ -39,6 +40,8 @@ const AppCard = ({
onFavoriteClick(id, isFavorite);
}, [ onFavoriteClick, id, isFavorite ]);
const logoUrl = useColorModeValue(logo, logoDarkMode || logo);
return (
<LinkBox
_hover={{
......@@ -73,7 +76,7 @@ const AppCard = ({
justifyContent="center"
>
<Image
src={ logo }
src={ logoUrl }
alt={ `${ title } app icon` }
/>
</Box>
......@@ -85,7 +88,7 @@ const AppCard = ({
size={{ base: 'xs', sm: 'sm' }}
fontWeight="semibold"
>
<AppCardLink
<MarketplaceAppCardLink
id={ id }
url={ url }
external={ external }
......@@ -158,4 +161,4 @@ const AppCard = ({
);
};
export default React.memo(AppCard);
export default React.memo(MarketplaceAppCard);
......@@ -9,7 +9,7 @@ type Props = {
title: string;
}
const AppLink = ({ url, external, id, title }: Props) => {
const MarketplaceAppCardLink = ({ url, external, id, title }: Props) => {
return external ? (
<LinkOverlay href={ url } isExternal={ true }>
{ title }
......@@ -23,4 +23,4 @@ const AppLink = ({ url, external, id, title }: Props) => {
);
};
export default AppLink;
export default MarketplaceAppCardLink;
import { Box, Heading, Skeleton, SkeletonCircle, useColorModeValue } from '@chakra-ui/react';
import React from 'react';
export const AppCardSkeleton = () => {
const MarketplaceAppCardSkeleton = () => {
return (
<Box
borderRadius="md"
......@@ -43,3 +43,5 @@ export const AppCardSkeleton = () => {
</Box>
);
};
export default MarketplaceAppCardSkeleton;
import {
Box, Flex, Heading, Icon, IconButton, Image, Link, List, Modal, ModalBody,
ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Tag, Text,
ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalOverlay, Tag, Text, useColorModeValue,
} from '@chakra-ui/react';
import React, { useCallback } from 'react';
import type { AppItemOverview } from 'types/client/apps';
import type { MarketplaceAppOverview } from 'types/client/marketplace';
import linkIcon from 'icons/link.svg';
import ghIcon from 'icons/social/git.svg';
......@@ -15,16 +15,16 @@ import starOutlineIcon from 'icons/star_outline.svg';
import useIsMobile from 'lib/hooks/useIsMobile';
import { nbsp } from 'lib/html-entities';
import AppModalLink from './AppModalLink';
import MarketplaceAppModalLink from './MarketplaceAppModalLink';
type Props = {
onClose: () => void;
isFavorite: boolean;
onFavoriteClick: (id: string, isFavorite: boolean) => void;
data: AppItemOverview;
data: MarketplaceAppOverview;
}
const AppModal = ({
const MarketplaceAppModal = ({
onClose,
isFavorite,
onFavoriteClick,
......@@ -41,6 +41,7 @@ const AppModal = ({
telegram,
twitter,
logo,
logoDarkMode,
categories,
} = data;
......@@ -64,6 +65,7 @@ const AppModal = ({
}, [ onFavoriteClick, data.id, isFavorite ]);
const isMobile = useIsMobile();
const logoUrl = useColorModeValue(logo, logoDarkMode || logo);
return (
<Modal
......@@ -89,7 +91,7 @@ const AppModal = ({
gridRow={{ base: '1 / 3', sm: '1 / 4' }}
>
<Image
src={ logo }
src={ logoUrl }
alt={ `${ title } app icon` }
/>
</Flex>
......@@ -120,7 +122,7 @@ const AppModal = ({
marginTop={{ base: 6, sm: 0 }}
>
<Box display="flex">
<AppModalLink
<MarketplaceAppModalLink
id={ data.id }
url={ url }
external={ external }
......@@ -241,4 +243,4 @@ const AppModal = ({
);
};
export default AppModal;
export default MarketplaceAppModal;
......@@ -9,7 +9,7 @@ type Props = {
title: string;
}
const AppModalLink = ({ url, external, id }: Props) => {
const MarketplaceAppModalLink = ({ url, external, id }: Props) => {
const buttonProps = {
size: 'sm',
marginRight: 2,
......@@ -36,4 +36,4 @@ const AppModalLink = ({ url, external, id }: Props) => {
);
};
export default AppModalLink;
export default MarketplaceAppModalLink;
import { Box, Button, Icon, Menu, MenuButton, MenuList } from '@chakra-ui/react';
import React from 'react';
import { AppCategory } from 'types/client/apps';
import { MarketplaceCategory } from 'types/client/marketplace';
import eastMiniArrowIcon from 'icons/arrows/east-mini.svg';
import CategoriesMenuItem from './CategoriesMenuItem';
import MarketplaceCategoriesMenuItem from './MarketplaceCategoriesMenuItem';
type Props = {
categories: Array<string>;
......@@ -13,10 +13,10 @@ type Props = {
onSelect: (category: string) => void;
}
const CategoriesMenu = ({ selectedCategoryId, onSelect, categories }: Props) => {
const MarketplaceCategoriesMenu = ({ selectedCategoryId, onSelect, categories }: Props) => {
const options = React.useMemo(() => ([
AppCategory.FAVORITES,
AppCategory.ALL,
MarketplaceCategory.FAVORITES,
MarketplaceCategory.ALL,
...categories,
]), [ categories ]);
......@@ -43,7 +43,7 @@ const CategoriesMenu = ({ selectedCategoryId, onSelect, categories }: Props) =>
<MenuList zIndex={ 3 }>
{ options.map((category: string) => (
<CategoriesMenuItem
<MarketplaceCategoriesMenuItem
key={ category }
id={ category }
onClick={ onSelect }
......@@ -54,4 +54,4 @@ const CategoriesMenu = ({ selectedCategoryId, onSelect, categories }: Props) =>
);
};
export default React.memo(CategoriesMenu);
export default React.memo(MarketplaceCategoriesMenu);
......@@ -2,7 +2,7 @@ import { Icon, MenuItem } from '@chakra-ui/react';
import type { FunctionComponent, SVGAttributes } from 'react';
import React, { useCallback } from 'react';
import { AppCategory } from 'types/client/apps';
import { MarketplaceCategory } from 'types/client/marketplace';
import starFilledIcon from 'icons/star_filled.svg';
......@@ -12,10 +12,10 @@ type Props = {
}
const ICONS: Record<string, FunctionComponent<SVGAttributes<SVGElement>>> = {
[AppCategory.FAVORITES]: starFilledIcon,
[MarketplaceCategory.FAVORITES]: starFilledIcon,
};
const CategoriesMenuItem = ({ id, onClick }: Props) => {
const MarketplaceCategoriesMenuItem = ({ id, onClick }: Props) => {
const handleSelection = useCallback(() => {
onClick(id);
}, [ id, onClick ]);
......@@ -33,4 +33,4 @@ const CategoriesMenuItem = ({ id, onClick }: Props) => {
);
};
export default CategoriesMenuItem;
export default MarketplaceCategoriesMenuItem;
import { Grid, GridItem } from '@chakra-ui/react';
import React from 'react';
import type { AppItemPreview } from 'types/client/apps';
import type { MarketplaceAppPreview } from 'types/client/marketplace';
import { apos } from 'lib/html-entities';
import AppCard from 'ui/apps/AppCard';
import EmptySearchResult from 'ui/apps/EmptySearchResult';
import EmptySearchResult from 'ui/shared/EmptySearchResult';
import MarketplaceAppCard from './MarketplaceAppCard';
type Props = {
apps: Array<AppItemPreview>;
apps: Array<MarketplaceAppPreview>;
onAppClick: (id: string) => void;
favoriteApps: Array<string>;
onFavoriteClick: (id: string, isFavorite: boolean) => void;
}
const AppList = ({ apps, onAppClick, favoriteApps, onFavoriteClick }: Props) => {
const MarketplaceList = ({ apps, onAppClick, favoriteApps, onFavoriteClick }: Props) => {
return apps.length > 0 ? (
<Grid
templateColumns={{
......@@ -28,13 +29,14 @@ const AppList = ({ apps, onAppClick, favoriteApps, onFavoriteClick }: Props) =>
<GridItem
key={ app.id }
>
<AppCard
<MarketplaceAppCard
onInfoClick={ onAppClick }
id={ app.id }
external={ app.external }
url={ app.url }
title={ app.title }
logo={ app.logo }
logoDarkMode={ app.logoDarkMode }
shortDescription={ app.shortDescription }
categories={ app.categories }
isFavorite={ favoriteApps.includes(app.id) }
......@@ -48,4 +50,4 @@ const AppList = ({ apps, onAppClick, favoriteApps, onFavoriteClick }: Props) =>
);
};
export default React.memo(AppList);
export default React.memo(MarketplaceList);
import { Grid, GridItem } from '@chakra-ui/react';
import React from 'react';
import { AppCardSkeleton } from 'ui/apps/AppCardSkeleton';
import MarketplaceAppCardSkeleton from './MarketplaceAppCardSkeleton';
const applicationStubs = [ ...Array(12) ];
const AppListSkeleton = () => {
const MarketplaceListSkeleton = () => {
return (
<Grid
templateColumns={{
......@@ -19,11 +19,11 @@ const AppListSkeleton = () => {
<GridItem
key={ index }
>
<AppCardSkeleton/>
<MarketplaceAppCardSkeleton/>
</GridItem>
)) }
</Grid>
);
};
export default AppListSkeleton;
export default MarketplaceListSkeleton;
import { useQuery } from '@tanstack/react-query';
import _pickBy from 'lodash/pickBy';
import _unique from 'lodash/uniq';
import React, { useCallback, useEffect, useState } from 'react';
import { useRouter } from 'next/router';
import React from 'react';
import type { AppItemOverview } from 'types/client/apps';
import { AppCategory } from 'types/client/apps';
import type { MarketplaceAppOverview } from 'types/client/marketplace';
import { MarketplaceCategory } from 'types/client/marketplace';
import appConfig from 'configs/app/config';
import type { ResourceError } from 'lib/api/resources';
import useDebounce from 'lib/hooks/useDebounce';
import useApiFetch from 'lib/hooks/useFetch';
import getQueryParamString from 'lib/router/getQueryParamString';
const favoriteAppsLocalStorageKey = 'favoriteApps';
......@@ -20,31 +23,36 @@ function getFavoriteApps() {
}
}
function isAppNameMatches(q: string, app: AppItemOverview) {
function isAppNameMatches(q: string, app: MarketplaceAppOverview) {
return app.title.toLowerCase().includes(q.toLowerCase());
}
function isAppCategoryMatches(category: string, app: AppItemOverview, favoriteApps: Array<string>) {
return category === AppCategory.ALL ||
(category === AppCategory.FAVORITES && favoriteApps.includes(app.id)) ||
function isAppCategoryMatches(category: string, app: MarketplaceAppOverview, favoriteApps: Array<string>) {
return category === MarketplaceCategory.ALL ||
(category === MarketplaceCategory.FAVORITES && favoriteApps.includes(app.id)) ||
app.categories.includes(category);
}
export default function useMarketplaceApps() {
const [ selectedAppId, setSelectedAppId ] = useState<string | null>(null);
const [ selectedCategoryId, setSelectedCategoryId ] = useState<string>(AppCategory.ALL);
const [ filterQuery, setFilterQuery ] = useState('');
const [ favoriteApps, setFavoriteApps ] = useState<Array<string>>([]);
export default function useMarketplace() {
const router = useRouter();
const defaultCategoryId = getQueryParamString(router.query.category);
const defaultFilterQuery = getQueryParamString(router.query.filter);
const [ selectedAppId, setSelectedAppId ] = React.useState<string | null>(null);
const [ selectedCategoryId, setSelectedCategoryId ] = React.useState<string>(MarketplaceCategory.ALL);
const [ filterQuery, setFilterQuery ] = React.useState(defaultFilterQuery);
const [ favoriteApps, setFavoriteApps ] = React.useState<Array<string>>([]);
const apiFetch = useApiFetch();
const { isLoading, isError, error, data } = useQuery<unknown, ResourceError<unknown>, Array<AppItemOverview>>(
const { isLoading, isError, error, data } = useQuery<unknown, ResourceError<unknown>, Array<MarketplaceAppOverview>>(
[ 'marketplace-apps' ],
async() => apiFetch(appConfig.marketplaceConfigUrl || ''),
{
select: (data) => (data as Array<AppItemOverview>).sort((a, b) => a.title.localeCompare(b.title)),
select: (data) => (data as Array<MarketplaceAppOverview>).sort((a, b) => a.title.localeCompare(b.title)),
staleTime: Infinity,
});
const handleFavoriteClick = useCallback((id: string, isFavorite: boolean) => {
const handleFavoriteClick = React.useCallback((id: string, isFavorite: boolean) => {
const favoriteApps = getFavoriteApps();
if (isFavorite) {
......@@ -58,14 +66,14 @@ export default function useMarketplaceApps() {
}
}, [ ]);
const showAppInfo = useCallback((id: string) => {
const showAppInfo = React.useCallback((id: string) => {
setSelectedAppId(id);
}, []);
const debouncedFilterQuery = useDebounce(filterQuery, 500);
const clearSelectedAppId = useCallback(() => setSelectedAppId(null), []);
const clearSelectedAppId = React.useCallback(() => setSelectedAppId(null), []);
const handleCategoryChange = useCallback((newCategory: string) => {
const handleCategoryChange = React.useCallback((newCategory: string) => {
setSelectedCategoryId(newCategory);
}, []);
......@@ -77,13 +85,38 @@ export default function useMarketplaceApps() {
return _unique(data?.map(app => app.categories).flat()) || [];
}, [ data ]);
useEffect(() => {
React.useEffect(() => {
setFavoriteApps(getFavoriteApps());
}, [ ]);
React.useEffect(() => {
if (!isLoading && !isError) {
const isValidDefaultCategory = categories.includes(defaultCategoryId);
isValidDefaultCategory && setSelectedCategoryId(defaultCategoryId);
}
// run only when data is loaded
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ isLoading ]);
React.useEffect(() => {
const query = _pickBy({
category: selectedCategoryId === MarketplaceCategory.ALL ? undefined : selectedCategoryId,
filter: debouncedFilterQuery,
}, Boolean);
router.replace(
{ pathname: '/apps', query },
undefined,
{ shallow: true },
);
// omit router in the deps because router.push() somehow modifies it
// and we get infinite re-renders then
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [ debouncedFilterQuery, selectedCategoryId ]);
return React.useMemo(() => ({
selectedCategoryId,
onCategoryChange: handleCategoryChange,
filterQuery: debouncedFilterQuery,
onSearchInputChange: setFilterQuery,
isLoading,
isError,
......@@ -108,5 +141,6 @@ export default function useMarketplaceApps() {
isError,
isLoading,
showAppInfo,
debouncedFilterQuery,
]);
}
......@@ -3,15 +3,15 @@ import React from 'react';
import config from 'configs/app/config';
import PlusIcon from 'icons/plus.svg';
import AppList from 'ui/apps/AppList';
import AppListSkeleton from 'ui/apps/AppListSkeleton';
import AppModal from 'ui/apps/AppModal';
import CategoriesMenu from 'ui/apps/CategoriesMenu';
import MarketplaceAppModal from 'ui/marketplace/MarketplaceAppModal';
import MarketplaceCategoriesMenu from 'ui/marketplace/MarketplaceCategoriesMenu';
import MarketplaceList from 'ui/marketplace/MarketplaceList';
import MarketplaceListSkeleton from 'ui/marketplace/MarketplaceListSkeleton';
import FilterInput from 'ui/shared/filters/FilterInput';
import useMarketplaceApps from '../apps/useMarketplaceApps';
import useMarketplace from '../marketplace/useMarketplace';
const Apps = () => {
const Marketplace = () => {
const {
isLoading,
isError,
......@@ -19,6 +19,7 @@ const Apps = () => {
selectedCategoryId,
categories,
onCategoryChange,
filterQuery,
onSearchInputChange,
showAppInfo,
displayedApps,
......@@ -26,7 +27,7 @@ const Apps = () => {
clearSelectedAppId,
favoriteApps,
onFavoriteClick,
} = useMarketplaceApps();
} = useMarketplace();
if (isError) {
throw new Error('Unable to get apps list', { cause: error });
......@@ -40,17 +41,22 @@ const Apps = () => {
display="flex"
flexDirection={{ base: 'column', sm: 'row' }}
>
<CategoriesMenu
<MarketplaceCategoriesMenu
categories={ categories }
selectedCategoryId={ selectedCategoryId }
onSelect={ onCategoryChange }
/>
<FilterInput onChange={ onSearchInputChange } marginBottom={{ base: '4', lg: '6' }} placeholder="Find app"/>
<FilterInput
initialValue={ filterQuery }
onChange={ onSearchInputChange }
marginBottom={{ base: '4', lg: '6' }}
placeholder="Find app"
/>
</Box>
{ isLoading ? <AppListSkeleton/> : (
<AppList
{ isLoading ? <MarketplaceListSkeleton/> : (
<MarketplaceList
apps={ displayedApps }
onAppClick={ showAppInfo }
favoriteApps={ favoriteApps }
......@@ -59,7 +65,7 @@ const Apps = () => {
) }
{ selectedApp && (
<AppModal
<MarketplaceAppModal
onClose={ clearSelectedAppId }
isFavorite={ favoriteApps.includes(selectedApp.id) }
onFavoriteClick={ onFavoriteClick }
......@@ -90,4 +96,4 @@ const Apps = () => {
);
};
export default Apps;
export default Marketplace;
......@@ -4,7 +4,7 @@ import { useRouter } from 'next/router';
import { route } from 'nextjs-routes';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import type { AppItemOverview } from 'types/client/apps';
import type { MarketplaceAppOverview } from 'types/client/marketplace';
import appConfig from 'configs/app/config';
import type { ResourceError } from 'lib/api/resources';
......@@ -26,15 +26,15 @@ const MarketplaceApp = () => {
const router = useRouter();
const id = getQueryParamString(router.query.id);
const { isLoading, isError, error, data } = useQuery<unknown, ResourceError<unknown>, AppItemOverview>(
const { isLoading, isError, error, data } = useQuery<unknown, ResourceError<unknown>, MarketplaceAppOverview>(
[ 'marketplace-apps', id ],
async() => {
const result = await apiFetch<Array<AppItemOverview>, unknown>(appConfig.marketplaceConfigUrl || '');
const result = await apiFetch<Array<MarketplaceAppOverview>, unknown>(appConfig.marketplaceConfigUrl || '');
if (!Array.isArray(result)) {
throw result;
}
const item = result.find((app: AppItemOverview) => app.id === id);
const item = result.find((app: MarketplaceAppOverview) => app.id === id);
if (!item) {
throw { status: 404 };
}
......
import { Box, Text, chakra } from '@chakra-ui/react';
import React from 'react';
import EmptySearchResult from 'ui/apps/EmptySearchResult';
import EmptySearchResult from 'ui/shared/EmptySearchResult';
import DataFetchAlert from './DataFetchAlert';
import SkeletonList from './skeletons/SkeletonList';
......
......@@ -5,9 +5,9 @@ import type { StatsChartsSection } from 'types/api/stats';
import type { StatsIntervalIds } from 'types/client/stats';
import { apos } from 'lib/html-entities';
import ChartWidgetSkeleton from 'ui/shared/chart/ChartWidgetSkeleton';
import EmptySearchResult from 'ui/shared/EmptySearchResult';
import EmptySearchResult from '../apps/EmptySearchResult';
import ChartWidgetSkeleton from '../shared/chart/ChartWidgetSkeleton';
import ChartsLoadingErrorAlert from './ChartsLoadingErrorAlert';
import ChartWidgetContainer from './ChartWidgetContainer';
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment