Skip to content

Commit

Permalink
feat(hooks): add remix example (#404)
Browse files Browse the repository at this point in the history
  • Loading branch information
dhayab authored Aug 29, 2022
1 parent abf6a73 commit fc2a278
Show file tree
Hide file tree
Showing 19 changed files with 500 additions and 0 deletions.
7 changes: 7 additions & 0 deletions react-instantsearch-hooks/remix/.eslintrc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": ["@remix-run/eslint-config", "@remix-run/eslint-config/node"],
"rules": {
"@typescript-eslint/naming-convention": "off",
"spaced-comment": ["error", "always", { "markers": ["/"] }]
}
}
8 changes: 8 additions & 0 deletions react-instantsearch-hooks/remix/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
node_modules

/.cache
/build
/public/build
.env

/app/tailwind.css
53 changes: 53 additions & 0 deletions react-instantsearch-hooks/remix/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Welcome to Remix!

- [Remix Docs](https://remix.run/docs)

## Development

From your terminal:

```sh
npm run dev
```

This starts your app in development mode, rebuilding assets on file changes.

## Deployment

First, build your app for production:

```sh
npm run build
```

Then run the app in production mode:

```sh
npm start
```

Now you'll need to pick a host to deploy it to.

### DIY

If you're familiar with deploying node applications, the built-in Remix app server is production-ready.

Make sure to deploy the output of `remix build`

- `build/`
- `public/build/`

### Using a Template

When you ran `npx create-remix@latest` there were a few choices for hosting. You can run that again to create a new project, then copy over your `app/` folder to the new project that's pre-configured for your target server.

```sh
cd ..
# create a new project, and pick a pre-configured host
npx create-remix@latest
cd my-new-remix-app
# remove the new project's app (not the old one!)
rm -rf app
# copy your app over
cp -R ../my-old-remix-app/app app
```
4 changes: 4 additions & 0 deletions react-instantsearch-hooks/remix/app/entry.client.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
import { RemixBrowser } from '@remix-run/react';
import { hydrateRoot } from 'react-dom/client';

hydrateRoot(document, <RemixBrowser />);
21 changes: 21 additions & 0 deletions react-instantsearch-hooks/remix/app/entry.server.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { EntryContext } from '@remix-run/node';
import { RemixServer } from '@remix-run/react';
import { renderToString } from 'react-dom/server';

export default function handleRequest(
request: Request,
responseStatusCode: number,
responseHeaders: Headers,
remixContext: EntryContext
) {
const markup = renderToString(
<RemixServer context={remixContext} url={request.url} />
);

responseHeaders.set('Content-Type', 'text/html');

return new Response(`<!DOCTYPE html>${markup}`, {
status: responseStatusCode,
headers: responseHeaders,
});
}
32 changes: 32 additions & 0 deletions react-instantsearch-hooks/remix/app/root.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { MetaFunction } from '@remix-run/node';
import {
Links,
LiveReload,
Meta,
Outlet,
Scripts,
ScrollRestoration,
} from '@remix-run/react';

export const meta: MetaFunction = () => ({
charset: 'utf-8',
title: 'React InstantSearch Hooks - Remix',
viewport: 'width=device-width,initial-scale=1',
});

export default function App() {
return (
<html lang="en">
<head>
<Meta />
<Links />
</head>
<body>
<Outlet />
<ScrollRestoration />
<Scripts />
<LiveReload />
</body>
</html>
);
}
122 changes: 122 additions & 0 deletions react-instantsearch-hooks/remix/app/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import algoliasearch from 'algoliasearch/lite';
import type { InstantSearchServerState } from 'react-instantsearch-hooks-web';
import {
DynamicWidgets,
Hits,
InstantSearch,
InstantSearchSSRProvider,
Pagination,
RefinementList,
SearchBox,
useInstantSearch,
} from 'react-instantsearch-hooks-web';
import { getServerState } from 'react-instantsearch-hooks-server';
import { history } from 'instantsearch.js/cjs/lib/routers/index.js';
import instantSearchStyles from 'instantsearch.css/themes/satellite-min.css';

import type { LinksFunction, LoaderFunction } from '@remix-run/node';
import { json } from '@remix-run/node';
import { useLoaderData } from '@remix-run/react';

import { Hit } from '../../components/Hit';
import { Panel } from '../../components/Panel';
import { ScrollTo } from '../../components/ScrollTo';
import { NoResultsBoundary } from '../../components/NoResultsBoundary';
import { SearchErrorToast } from '../../components/SearchErrorToast';

import tailwindStyles from '../tailwind.css';

const searchClient = algoliasearch(
'latency',
'6be0576ff61c053d5f9a3225e2a90f76'
);

export const links: LinksFunction = () => [
{ rel: 'stylesheet', href: instantSearchStyles },
{ rel: 'stylesheet', href: tailwindStyles },
];

export const loader: LoaderFunction = async ({ request }) => {
const serverUrl = request.url;
const serverState = await getServerState(<Search serverUrl={serverUrl} />);

return json({
serverState,
serverUrl,
});
};

type SearchProps = {
serverState?: InstantSearchServerState;
serverUrl?: string;
};

function Search({ serverState, serverUrl }: SearchProps) {
return (
<InstantSearchSSRProvider {...serverState}>
<InstantSearch
searchClient={searchClient}
indexName="instant_search"
routing={{
router: history({
getLocation() {
if (typeof window === 'undefined') {
return new URL(serverUrl!) as unknown as Location;
}

return window.location;
},
}),
}}
>
<SearchErrorToast />

<ScrollTo className="max-w-6xl p-4 flex gap-4 m-auto">
<div>
<DynamicWidgets fallbackComponent={FallbackComponent} />
</div>

<div className="flex flex-col w-full gap-8">
<SearchBox />
<NoResultsBoundary fallback={<NoResults />}>
<Hits
hitComponent={Hit}
classNames={{
list: 'grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4',
item: 'p-2 w-full',
}}
/>
<Pagination className="flex self-center" />
</NoResultsBoundary>
</div>
</ScrollTo>
</InstantSearch>
</InstantSearchSSRProvider>
);
}

function FallbackComponent({ attribute }: { attribute: string }) {
return (
<Panel header={attribute}>
<RefinementList attribute={attribute} />
</Panel>
);
}

function NoResults() {
const { indexUiState } = useInstantSearch();

return (
<div>
<p>
No results for <q>{indexUiState.query}</q>.
</p>
</div>
);
}

export default function HomePage() {
const { serverState, serverUrl } = useLoaderData();

return <Search serverState={serverState} serverUrl={serverUrl} />;
}
31 changes: 31 additions & 0 deletions react-instantsearch-hooks/remix/components/Hit.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import type { Hit as AlgoliaHit } from 'instantsearch.js';
import { Highlight } from 'react-instantsearch-hooks-web';

type HitProps = {
hit: AlgoliaHit<{
name: string;
price: number;
image: string;
brand: string;
}>;
};

export function Hit({ hit }: HitProps) {
return (
<div className="group relative">
<div className="flex justify-center overflow-hidden">
<img
src={hit.image}
alt={hit.name}
className="object-center object-cover"
/>
</div>
<h3 className="mt-4 text-sm text-gray-700">
<span className="absolute inset-0" />
<Highlight hit={hit} attribute="name" />
</h3>
<p className="mt-1 text-sm text-gray-500">{hit.brand}</p>
<p className="mt-1 text-sm font-medium text-gray-900">${hit.price}</p>
</div>
);
}
27 changes: 27 additions & 0 deletions react-instantsearch-hooks/remix/components/NoResultsBoundary.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { ReactNode } from 'react';
import { useInstantSearch } from 'react-instantsearch-hooks-web';

type NoResultsBoundaryProps = {
children: ReactNode;
fallback: ReactNode;
};

export function NoResultsBoundary({
children,
fallback,
}: NoResultsBoundaryProps) {
const { results } = useInstantSearch();

// The `__isArtificial` flag makes sure to not display the No Results message
// when no hits have been returned yet.
if (!results.__isArtificial && results.nbHits === 0) {
return (
<>
{fallback}
<div hidden>{children}</div>
</>
);
}

return <>{children}</>;
}
17 changes: 17 additions & 0 deletions react-instantsearch-hooks/remix/components/Panel.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export function Panel({
children,
header,
footer,
}: {
children: React.ReactNode;
header?: React.ReactNode;
footer?: React.ReactNode;
}) {
return (
<div className="ais-Panel">
{header && <div className="ais-Panel-header">{header}</div>}
<div className="ais-Panel-body">{children}</div>
{footer && <div className="ais-Panel-footer">{footer}</div>}
</div>
);
}
37 changes: 37 additions & 0 deletions react-instantsearch-hooks/remix/components/ScrollTo.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import type { ComponentProps, ReactNode } from 'react';
import React, { useEffect, useRef } from 'react';
import { useInstantSearch } from 'react-instantsearch-hooks-web';

type ScrollToProps = ComponentProps<'div'> & {
children: ReactNode;
};

export function ScrollTo({ children, ...props }: ScrollToProps) {
const { use } = useInstantSearch();
const containerRef = useRef<HTMLDivElement>(null);

useEffect(() => {
return use(() => {
return {
onStateChange() {
const isFiltering = document.body.classList.contains('filtering');
const isTyping =
document.activeElement?.tagName === 'INPUT' &&
document.activeElement?.getAttribute('type') === 'search';

if (isFiltering || isTyping) {
return;
}

containerRef.current!.scrollIntoView();
},
};
});
}, [use]);

return (
<div {...props} ref={containerRef}>
{children}
</div>
);
}
Loading

0 comments on commit fc2a278

Please sign in to comment.