algolia / react-instantsearch

⚡️ Lightning-fast search for React and React Native applications, by Algolia.
https://www.algolia.com/doc/guides/building-search-ui/what-is-instantsearch/react/
MIT License
1.97k stars 389 forks source link

Add a widget to retrieve a single hit #16

Closed mthuret closed 1 year ago

mthuret commented 7 years ago

Use case

You have an instant search page with results and wants to add a link on each one of them to redirect to a detailed page with only one result.

Need

We need a new widget that will be able to fetch one result (through getObject).

Proposal

TBD

shalomvolchok commented 7 years ago

Just browsing through issues to see if anyone was asking for this. This would be a great feature to have. What I actually came looking for was a connector that would allow retrieving a list of objects. My use case is to be able to render a list of featured products and use algolia as the source of truth to pull out product data. Something like this:

const Hits = ({ hits }) => (
    <div>
        {hits.map(hit => <div>{hit.name}</div>)}
    </div>
);

const RecommendedProducts = connectGetObjects(Hits);

const HomePage = () => (
    <div>
        <RecommendedProducts
            ids={["myObjectID1", "myObjectID2", "myObjectID3"]}
        />
    </div>
);

I see that we can do it currently with the algoliasearch sdk. Is there a way to get that functionality through createConnector? Or some other means?

mthuret commented 7 years ago

Hi @shalomvolchok,

Why not using another <InstantSearch/> to display those records? You'll need to add a featured attribute on them and configure your second instance to actually retrieve only items that are featured.

Otherwise you could use directly algoliasearch to fetch those records using getObject. You can also pass this client to <InstantSearch/> for being used by the instance.

Haroenv commented 7 years ago

Getting single objects can now be done by doing this until we provide a connector for retrieving specific hits:

import PropTypes from 'prop-types';
import React, { Component } from 'react';
import algoliasearch from 'algoliasearch/lite';

const client = algoliasearch('your_app_id', 'your_search_api_key');
const index = client.initIndex('your_index_name');

const schema = {
  color: 'blue',
  /* default values for the Hit component */
};

class Hit extends Component {
  constructor(props) {
    super(props);
    this.state = {
      ...schema,
      loaded: false,
    };
  }

  static propTypes = {
    objectID: PropTypes.string.isRequired,
  };

  componentWillMount() {
    index.getObject(this.props.objectID).then(content =>
      this.setState(prevState => ({
        ...prevState,
        ...content,
        loaded: true,
      }))
    );
  }

  render() {
    const { color } = this.state;

    return (
      <div>
        {color}
      </div>
    );
  }
}

Then you can map over Hit components.

However, to get multiple results, it might be best to find the criterium for those results, and simply query for that. You can also do that with the Configure widget for getting a very specific refinement.

shalomvolchok commented 7 years ago

Thanks for the quick replies!

@mthuret, I want to be able to pass in a dynamic list that could include any of the products in Algolia. If I understand your suggestion, you're saying to tag the results in Algolia but that seems like it would be a static solution?

@Haroenv This could work for now, I have something similar that I started testing.

@mthuret @Haroenv Right now I have a template that is meant to serve multiple projects, so the algolia index is dynamic. I do this on a root component and it works great for using search across the app. I was hoping to be able to hook into this already instantiated client/index. As I understand the sdk is doing caching and other optimizations. If I instantiate algoliasearch again, I'm assuming I'll get another non-shared instance? I can do that for now, but just wanted to see if there are any other options.

@mthuret I see what you are saying that I can pass algoliasearch into <InstantSearch algoliaClient={client}/>, this could be useful to allow for a shared instance. But then I'd still need to pass that instantiated client around my app. I looked at what algolia already has in the context (ais), but doesn't seem to be a usable client. Or is there a way for me to pull the client out of the context?

Unless there are some other options it seems the best workaround for now would be to pass algoliasearch in <InstantSearch/> and then add the instantiated client to the context myself. Does that seem reasonable for now?

mthuret commented 7 years ago

How often those featured items are changing? To me it can be dynamic, the same way you'll have to know the objectId on the front-end side, you can actually update those records on the server side.

Regarding the client you'll instantiate it once, and then InstantSearch will use it. Then I don't think there's a need to play with the context. Just pass it along as a prop.

Obviously when the single hit feature will be developed there'll no need to play with an extra client.

shalomvolchok commented 7 years ago

Well I'd like to have the functionality to change them independently from the index. For example, so that we could a/b test one list of featured items vs another. Or to show a list of recently viewed products to a specific user.

I'll pass the client into InstantSearch and down as props for now.

Thanks for the help :) And love the way this library and Algolia work. Great service and great pattern, fun to work with.

Haroenv commented 7 years ago

I’m not near to a computer now, but I think you can do something like this:

<Configure
  filters={featuredIDS.reduce(
    (filters, id) => `${filters} OR objectID:${id}`,
    ''
  )}
/>

And then use that configure in a separate <Index> for featured items.

shalomvolchok commented 7 years ago

@Haroenv I tested your suggestion with the code below and it totally seems to do what I need. I assume something like this is what you had in mind? Thanks!

import { connectHits } from "react-instantsearch/connectors";
import { Configure, Index, Hits } from "react-instantsearch/dom";

const RenderHits = ({ hits }) => (
    <div>
        {hits.map(hit => <div>{hit.name}</div>)}
    </div>
);

const ConnectedRenderHits = connectHits(RenderHits);

const RecommendedProducts = ({ ids }) => (
    <Index indexName="index_name">
        <Configure
            filters={ids
                .map(id => `objectID:${id}`)
                .reduce((filters, id) => `${filters} OR ${id}`)}
        />
        <ConnectedRenderHits />
    </Index>
);

const HomePage = () => (
    <div>
        <RecommendedProducts
            ids={["myObjectID1", "myObjectID2", "myObjectID3"]}
        />
    </div>
);
Haroenv commented 7 years ago

Indeed, that’s what I was suggesting! I’d write

  <Configure
    filters={ids.reduce((filters, id) => `${filters} OR objectID:${id}`)}
  />

instead though, a bit more obvious 🎉

shalomvolchok commented 7 years ago

I tried that first, but I got an error from Algolia because in the first reduce iteration filters is just an ID and it doesn't get objectID: in front of it. Maybe a simple way to fix that, but first thing to my mind was just add the map...

Haroenv commented 7 years ago

Ah interesting, could you share that in a codepen, seems like a bug 👍

edit: I get it now, your version seems good

mthuret commented 7 years ago

@Haroenv I didn't know that we could filter by objectID, I thought it was not working. That's cool!

Then I think @shalomvolchok that you can also skip the Configure + map/filter step by just using a VirtualRefinementList or a VirtualMenu. Something like:

const FilterByIds = connectRefinementList(() => null);

<FilterByIds
        attributeName="objectID"
        defaultRefinement={['xxx', 'xxx']}
      /> //use Menu for a list of items

It even seems that directly using a Menu or RefinementList is possible as nothing is displayed.

That's a nice use case using the multi indices feature 👍

shalomvolchok commented 7 years ago

@Haroenv https://codepen.io/anon/pen/MoWYPG?editors=0010#0

@mthuret ok, let me have a try with that as well...

Haroenv commented 7 years ago

not that it’s a big improvement, but

ids.map(id => `objectID:${id}`).join(' OR ')

also works. Whatever you find more legible

shalomvolchok commented 7 years ago

OK, pulled all that into a HOC and ended up with this. Seems to satisfy all my use cases.

import React, { Component } from "react";
import {
    connectHits,
    connectRefinementList
} from "react-instantsearch/connectors";
import { Index } from "react-instantsearch/dom";

const FilterByIds = connectRefinementList(() => null);

export default WrappedComponent => {
    const ListFromSearch = connectHits(props => {
        const { ids, hits } = props;

        // order the hits by original list order
        const orderedHits = hits
            .sort(
                (a, b) =>
                    ids.findIndex(id => id === a.objectID) -
                    ids.findIndex(id => id === b.objectID)
            )
            .filter(Boolean);

        return <WrappedComponent {...props} hits={orderedHits} />;
    });

    return props => {
        const { ids, indexName } = props;
        return (
            <Index indexName={indexName}>
                <FilterByIds attributeName="objectID" defaultRefinement={ids} />
                <ListFromSearch {...props} />
            </Index>
        );
    };
};

I use the HOC like this:

const RecommendedProducts = getObjectsConnector(({ hits }) => (
    <div>
        {hits.map(hit => <div>{hit.name}</div>)}
    </div>
));

const HomePage = () => (
    <RecommendedProducts 
        indexName="index_name" 
        ids={["myObjectID1", "myObjectID2", "myObjectID3"]} 
    />
);

How does that seem?

shalomvolchok commented 7 years ago

Hmmm... Maybe I misunderstood. I was thinking that <Index> could give me another instance of the same index to play with. But do I need a completely different index_name? Also, it seems that I can only have one list, a second list seems to refine the ids from the first. Also, my search seems to refine my featured products list... Have I really missed something here and this just needs a little fine tuning? Or should I go back to using algoliasearch and getObject directly?

mthuret commented 7 years ago

I think I was a little too fast here... indeed you can't use the multi indices API because its made to target different indices. In your case you should use another <InstantSearch/> instance.

Like this:

<InstantSearch>
      <Hits /> 
      <InstantSearch>
           <FilterByIds />
           <Hits />
       </InstantSearch>
</InstantSearch>
shalomvolchok commented 7 years ago

OK, thanks for the reply. I'm out of the office today, but I will give it a try in the morning and confirm I've got it working.

shalomvolchok commented 7 years ago

Yes, I can confirm that got everything working. Appreciate the help :+1:

mthuret commented 7 years ago

That's cool @shalomvolchok! Happy coding :)

Kikobeats commented 7 years ago

I'm doing a hacky workaround similar to yours approach, but in my case I want to be possible display different widget based on a search filter.

HOC looks like:

<HomeProducts
      title='Feature Sails'
      subtitle='View more sails →'
      filters='category:sails AND provider:lpwind'
      hitsPerPage={3}
    />

    <HomeProducts
      title='Feature Boards'
      subtitle='View more Boards →'
      filters='category:boards AND provider:wewind'
      hitsPerPage={3}
    />

source code

so I defined a HomeProducts components. It's encapsulating a Configure and connectHits from Algolia, using a custom markup.

The only problem I found is, even I have two instances of the components using a different filter, the component render the same results.

screen shot 2017-06-11 at 13 42 23

Looks like the last Configure is being apply for all HomeProducts instances.

Also using Index as external wrapper inside the component didnt' works as I expected, probably I need to apply InstantSearch as well.

Related? https://github.com/algolia/react-instantsearch/issues/28

any idea how to do that?

edit: yes, apply InstantSearch wrappers works like a charm:

export default ({title, filters, hitsPerPage}) => (
  <InstantSearch
    appId='LIRIRIRLRI'
    apiKey='LERERLERE'
    indexName='windsurf'
    >
    <Configure filters={filters} hitsPerPage={hitsPerPage} />
    <HomeProducts title={title} />
  </InstantSearch>
)

Now I have to find an strategy for don't populate appId and apiKey around all the code 🤔

shalomvolchok commented 7 years ago

@Kikobeats the example HOC I posted in full didn't end up actually working as expected. What @mthuret suggested, that did work, was to wrap every different instance of search results with its own <InstantSearch/>. Without this I got the same results as you are seeing and other behavior I didn't want. So basically, I think something like this should work for you:

<div>
    <InstantSearch appId="app_id" apiKey="api_key" indexName="index">
        <HomeProducts
            title="Feature Sails"
            subtitle="View more sails →"
            filters="category:sails AND provider:lpwind"
            hitsPerPage={3}
        />
    </InstantSearch>

    <InstantSearch appId="app_id" apiKey="api_key" indexName="index">
        <HomeProducts
            title="Feature Boards"
            subtitle="View more Boards →"
            filters="category:boards AND provider:wewind"
            hitsPerPage={3}
        />
    </InstantSearch>
</div>
Kikobeats commented 6 years ago

Hey guys, are you tracking this issue?

I saw new 4.1.0 beta version is shipping SSR (that's awesome, thanks folks 🎉 )

I think that this issue is higly related with create unique URL per each database record and expose it using SSR to be possible optimize content for SEO.

Kikobeats commented 6 years ago

Hey,

I build a simple solution that works like a charm:

Live demo at next.windtoday.co

Basically, I'm injecting the ObjectID into filters field at <Configure/>: https://github.com/windtoday/windtoday-app/blob/v3/components/App.js#L116

Then, if the objectID is present I determinate render a Single Hit view component: https://github.com/windtoday/windtoday-app/blob/v3/components/App.js#L126

This single hit view component needs to use <connectHits/> just for get the hits (actually just get the first element) as prop: https://github.com/windtoday/windtoday-app/blob/v3/components/SingleHit/index.js#L119

and that's it. It was more easy than I thought at first time.

mthuret commented 6 years ago

Awesome @Kikobeats! Great example that others can follow!

We might still want to add a widget that will hide a bit this :)

romafederico commented 6 years ago

Hi @Kikobeats ... you have no idea how much reading through this has helped me... quick question regarding

filters="category:boards AND provider:wewind"

How should I do to look for nested attributes? Would it be ok to do it like this?

<Configure filters='line_items[0].shipped_status: pending'/>

Haroenv commented 6 years ago

normally that should work @romafederico 👍

temitope commented 6 years ago

@romafederico i agree, just following this discussion helped me understand algolia from react perspective better. continue to be impressed by the flexibility. thanks @mthuret for planting the seed of idea and @Haroenv for that very nice last bit that exchanged reduce for a simple mapping. brilliant.

calabr93 commented 3 years ago

Getting single objects can now be done by doing this until we provide a connector for retrieving specific hits:

import PropTypes from 'prop-types';
import React, { Component } from 'react';
import algoliasearch from 'algoliasearch/lite';

const client = algoliasearch('your_app_id', 'your_search_api_key');
const index = client.initIndex('your_index_name');

const schema = {
  color: 'blue',
  /* default values for the Hit component */
};

class Hit extends Component {
  constructor(props) {
    super(props);
    this.state = {
      ...schema,
      loaded: false,
    };
  }

  static propTypes = {
    objectID: PropTypes.string.isRequired,
  };

  componentWillMount() {
    index.getObject(this.props.objectID).then(content =>
      this.setState(prevState => ({
        ...prevState,
        ...content,
        loaded: true,
      }))
    );
  }

  render() {
    const { color } = this.state;

    return (
      <div>
        {color}
      </div>
    );
  }
}

Then you can map over Hit components.

However, to get multiple results, it might be best to find the criterium for those results, and simply query for that. You can also do that with the Configure widget for getting a very specific refinement.

I know that this is an old post, but the import at the top is wrong. "getObject" seems to work only with import algoliasearch from "algoliasearch"; and not with import algoliasearch from "algoliasearch/lite";

Haroenv commented 3 years ago

that code sample was written a long time ago, when algoliasearch/lite still shipped with getObject. Since version 4 it no longer does that. I'd recommend you go with the objectID filter (maybe in an index widget) though, since that way, if you're also searching on the same page, it will not cost you a new network request

calabr93 commented 3 years ago

Oh, ok! I just wrote the previous comment since maybe could be helpful for others ;) In my case I have an Instantsearch instance in the homepage and if I click on one product I pass all of the props in the new page (“details-page”). But if I reach the details-page from a link I will call the getObject method to get the data.

sarahdayan commented 1 year ago

Hey!

We're doing a round of clean up before migrating this repository to the new InstantSearch monorepo. This issue seems not to have generated much activity lately and to be mostly solved, so we're going to close it.

Here's an up-to-date sandbox using React InstantSearch Hooks: https://codesandbox.io/s/magical-merkle-6e08lg?file=/src/App.tsx