Share via


4 – Utforska JavaScript-sökkoden

I föregående lektioner lade du till sökning i en statisk webbapp. Den här lektionen belyser de viktigaste stegen för att upprätta integrering. Om du letar efter ett fuskark om hur du integrerar sökning i din JavaScript-app förklarar den här artikeln vad du behöver veta.

Källkoden är tillgänglig på GitHub-lagringsplatsen azure-search-javascript-samples .

Azure SDK @azure/search-documents

Funktionsappen använder Azure SDK för Azure AI Search:

Funktionsappen autentiserar via SDK:n till det molnbaserade Azure AI Search-API:et med hjälp av resursnamnet, API-nyckeln och indexnamnet. Hemligheterna lagras i inställningarna för den statiska webbappen och hämtas till funktionen som miljövariabler.

Konfigurera hemligheter i en konfigurationsfil

const CONFIG = {
    SearchIndexName: process.env["SearchIndexName"] || "good-books",
    SearchApiQueryKey: process.env["SearchApiKey"] || "",
    SearchServiceName: process.env["SearchServiceName"] || "",
    SearchFacets: process.env["SearchFacets"] || "authors*,language_code", 
}
console.log(CONFIG);
if (!CONFIG.SearchIndexName || !CONFIG.SearchApiQueryKey || !CONFIG.SearchServiceName) throw Error("./config.js::Cognitive Services key is missing");

module.exports = { CONFIG };

Azure-funktion: Sök i katalogen

Sök-API:et tar en sökterm och söker i dokumenten i sökindexet och returnerar en lista med matchningar.

Azure-funktionen hämtar sökkonfigurationsinformationen och uppfyller frågan.

const { app } = require('@azure/functions');
const { CONFIG } = require("../lib/config");
const { readFacets, createFilterExpression } = require('../lib/azure-cognitive-search');
const { SearchClient, AzureKeyCredential } = require("@azure/search-documents");

// Create a SearchClient to send queries
const client = new SearchClient(
    `https://` + CONFIG.SearchServiceName + `.search.windows.net/`,
    CONFIG.SearchIndexName,
    new AzureKeyCredential(CONFIG.SearchApiQueryKey)
);

app.http('search', {
    methods: ['POST'],
    authLevel: 'anonymous',
    handler: async (request, context) => {

        context.log(`Search request for url "${request.url}"`);

        try {

            const body = await request.json();
            console.log(body);

            let q = body.q || "*";
            const top = body.top || 5;
            const skip = parseInt(body.skip || 0);
            const filters = body.filters || undefined;
            const facets = readFacets(CONFIG.SearchFacets);

            const facetNames = Object.keys(facets);
            console.log(facetNames);

            const filtersExpression = (filters && facets) ? createFilterExpression(filters, facets) : undefined;
            console.log(filtersExpression)

            // Creating SearchOptions for query
            let searchOptions = {
                top: top,
                skip: skip,
                includeTotalCount: true,
                facets: facetNames,
                filter: filtersExpression
            };
            console.log(searchOptions);

            // Sending the search request
            const searchResults = await client.search(q, searchOptions);
            console.log(searchResults);

            // Getting results for output
            const output = [];
            for await (const result of searchResults.results) {
                output.push(result);
            }
            console.log(searchResults)

            // Logging search results
            context.log(searchResults.count);

            return {
                headers: {
                    "Content-type": "application/json"
                },
                jsonBody: {
                    count: searchResults.count,
                    results: output,
                    resultsCount: output.length,
                    facets: searchResults.facets,
                    q,
                    top,
                    skip,
                    filters: filters || ''
                }
            };

        } catch (error) {
            return {
                status: 500,
                jsonBody: {
                    innerStatusCode: error.statusCode || error.code,
                    error: error.details || error.message,
                    stack: error.stack
                }
            }
        }
    }
});

Klient: Sök från katalogen

Anropa Azure-funktionen i React-klienten med följande kod.

import React, { useState } from "react";
import request from "../api";
import CircularProgress from "@mui/material/CircularProgress";
import { useLocation, useNavigate } from "react-router-dom";
import styled from "@emotion/styled";
import Grid from "@mui/material/Grid";
import Stack from "@mui/material/Stack";
import Container from "@mui/material/Container";

import Results from "../components/Results";
import Pager from "../components/Pager";
import Facets from "../components/Facets/Facets";
import SearchBar from "../components/SearchBar";

import { useQuery } from "@tanstack/react-query";

const StyledPager = styled(Pager)({
  marginLeft: "auto",
  marginRight: "auto",
  maxWidth: "fit-content",
});
const StyledContainer = styled.div`
  // Uncomment to debug
  // border: 1px solid red;

  // Center body with space around
  margin: 1rem auto;
  margin-top: 5rem;

  min-height: 30em;
  padding-left: 0px;
  padding-right: 0px;
  max-width: 98%;
  outline: 0px;

  display: flex;
`;
const StyledSearchBar = styled(SearchBar)({});
const LeftColumn = styled(Stack)`
  width: 30%;
  border-right: 1px solid #f0f0f0;
  padding: 0 16px 0 16px;
`;

const RightColumn = styled(Container)``;

export default function Search() {
  const location = useLocation();
  const navigate = useNavigate();

  const [currentPage, setCurrentPage] = useState(
    new URLSearchParams(location.search).get("p") ?? 1
  );
  const [searchTerm, setSearchTerm] = useState(
    new URLSearchParams(location.search).get("q") ?? "*"
  );
  const [top] = useState(new URLSearchParams(location.search).get("top") ?? 8);
  const [skip, setSkip] = useState(
    new URLSearchParams(location.search).get("skip") ?? 0
  );
  const [filters, setFilters] = useState([]);
  const [facets, setFacets] = useState({});

  let resultsPerPage = top;

  function setNavigation(q, p) {
    navigate(`/search?q=${q}&p=${p}`);
  }

  function changeCurrentPage(newPage) {
    const newSkip = (newPage - 1) * top;
    setNavigation(searchTerm, newPage);
    setCurrentPage(newPage);
    setSkip(newSkip);
  }

  const fiveMinutes = 1000 * 60 * 5;

  /* eslint-disable no-unused-vars */
  const { data, isLoading, dataUpdatedAt, error } = useQuery({
    queryKey: ["search", searchTerm, top, skip, currentPage, filters, facets],
    //refetchOnMount: false,
    //refetchOnWindowFocus: false,
    //refetchOnReconnect: false,
    enabled: searchTerm !== undefined,
    staleTime: fiveMinutes, // time in milliseconds
    cacheTime: fiveMinutes,
    queryFn: async () => {
      setSkip((currentPage - 1) * top);
      return request("/api/search", "POST", {
        q: searchTerm,
        top: top,
        skip: (currentPage - 1) * top,
        filters: filters,
      }).then((response) => {
        setFacets(response.facets);
        setFilters(response.filters);
        return response;
      });
    },
  });

  const postSearchHandler = (searchTerm) => {
    setNavigation(searchTerm, 1);
    setSearchTerm(searchTerm);
    setCurrentPage(1);
    setSkip(0);
  };

  const updateFilters = (filters) => {
    setFilters(filters);
  };
  return (
    <>
      <StyledContainer>
        <LeftColumn>
          <StyledSearchBar
            navigateToSearchPage={postSearchHandler}
            defaultTerm={searchTerm}
          ></StyledSearchBar>
          <Facets
            facets={facets}
            filters={filters}
            setFilters={updateFilters}
          ></Facets>
        </LeftColumn>
        <RightColumn>
          {isLoading ? (
            <CircularProgress />
          ) : (
            <Grid container>
              <Results
                q={searchTerm}
                documents={data.results}
                top={top}
                skip={skip}
                count={data.count}
              ></Results>
              <StyledPager
                className="pager-style"
                currentPage={currentPage}
                resultCount={data.count}
                resultsPerPage={resultsPerPage}
                setCurrentPage={changeCurrentPage}
              ></StyledPager>
            </Grid>
          )}
        </RightColumn>
      </StyledContainer>
    </>
  );
}

Klient: Fasetter från katalogen

Den här React-komponenten innehåller söktextrutan och de fasetter som är associerade med sökresultaten. Fasetter måste tänkas ut och utformas som en del av sökschemat när sökdata läses in. Sedan används fasetter i sökfrågan, tillsammans med söktexten, för att tillhandahålla den fasetterade navigeringsupplevelsen.

import React, { useEffect, useState } from "react";
import { List, Chip } from "@mui/material";
import CheckboxFacet from "./CheckboxFacet";
import styled from "@emotion/styled";

const StyledFacetComponent = styled.div`
  border-right: "1px solid #f0f0f0";
  height: "100%";
`;
const StyledSelectedFacets = styled.div``;
const StyledFacetList = styled(List)`
  margin: "0.25em";
  margin-top: "32px !important";
  padding-left: "36px !important";
`;
export default function Facets(props) {
  const [filters, setFilters] = useState([]);
  const [facets, setFacets] = useState({});

  useEffect(() => {
    setFilters(props.filters);
    setFacets(props.facets);
  }, [props.filters, props.facets]);

  // Change facet name to be more readable
  // e.g. "author" -> "Author"
  // e.g. "publication_year" -> "Publication Year"
  function mapFacetName(facetName) {
    facetName =
      `${facetName[0].toUpperCase()}${facetName.substring(1)}`.replace(
        "_",
        " "
      ) || ``;
    return facetName;
  }

  function addFilter(name, value) {
    const newFilters = filters.concat({ field: name, value: value });
    props.setFilters(newFilters);
  }

  function removeFilter(filter) {
    const newFilters = filters.filter((item) => item.value !== filter.value);
    props.setFilters(newFilters);
  }

  return (
    <StyledFacetComponent>
      <StyledSelectedFacets>
        <List>
          {filters.map((filter, index) => {
            return (
              <Chip
                key={index}
                label={`${mapFacetName(filter.field)}: ${filter.value}`}
                onDelete={() => removeFilter(filter)}
              />
            );
          })}
        </List>
      </StyledSelectedFacets>
      <StyledFacetList>
        {Object.keys(facets).map((key) => {
          return (
            <CheckboxFacet
              key={key}
              name={key}
              values={facets[key]}
              addFilter={addFilter}
              removeFilter={removeFilter}
              mapFacetName={mapFacetName}
              selectedFacets={filters.filter((f) => f.field === key)}
            />
          );
        })}
      </StyledFacetList>
    </StyledFacetComponent>
  );
}

Klient: Sidnumrering från katalogen

När sökresultaten expanderar bortom några få (8) ger komponenten @mui/material/TablePagination sidnumrering över resultaten.

import React from "react";
import TablePagination from "@mui/material/TablePagination";
import styled from "@emotion/styled";

const StyledTablePagination = styled(TablePagination)`
margin: auto;
max-width: fit-content;
border-bottom: 0;
`;

export default function Pager(props) {
  const pagesInResultSet = Math.round(props.resultCount / props.resultsPerPage);
  const moreThanOnePage = pagesInResultSet > 1;

  if (!moreThanOnePage) return <></>;

  const handleChangePage = (event, newPage) => {
    props.setCurrentPage(newPage + 1);
  };
  return (
    <StyledTablePagination
      className="pager"
      align="center"
      component="div"
      count={props.resultCount}
      page={props.currentPage - 1} // zero-based control from material ui
      onPageChange={handleChangePage}
      rowsPerPage={props.resultsPerPage}
      rowsPerPageOptions={[props.resultsPerPage]} // don't display b/c there is a single value
      showFirstButton={true}
      showLastButton={true}
      labelDisplayedRows={({ from, to, count }) =>
        `${props.currentPage} of ${Math.round(
          props.resultCount / props.resultsPerPage
        )} pages`
      }
    />
  );
}

När användaren ändrar sidan skickas det värdet till den överordnade Search.js sidan från handleChangePage funktionen. Funktionen skickar en ny begäran till sök-API:et för samma fråga och den nya sidan. API-svaret uppdaterar fasetter, resultat och pager-komponenter.

Azure-funktion: Förslag från katalogen

Api:et Föreslå tar en sökterm medan en användare skriver och föreslår söktermer som boktitlar och författare i dokumenten i sökindexet, vilket returnerar en liten lista med matchningar.

Sökförslagsverktygetsg, , definieras i schemafilen som används vid massuppladdning.

const { app } = require('@azure/functions');
const { SearchClient, AzureKeyCredential } = require("@azure/search-documents");
const { CONFIG } = require("../lib/config");

// Create a SearchClient to send queries
const client = new SearchClient(
    `https://` + CONFIG.SearchServiceName + `.search.windows.net/`,
    CONFIG.SearchIndexName,
    new AzureKeyCredential(CONFIG.SearchApiQueryKey)
);

app.http('suggest', {
    methods: ['POST'],
    authLevel: 'anonymous',
    handler: async (request, context) => {
        context.log(`Suggester request for url "${request.url}"`);
        try {
            const body = await request.json();
            console.log(`suggest body ${body}`);

            let q = body.q;
            console.log(`suggest q ${q}`)

            const top = body.top;
            console.log(`suggest top ${top}`)

            const suggester = body.suggester;
            console.log(`suggest suggester ${suggester}`)

            if(!body || !q || !top || !suggester){
                console.log(`No suggester found in body`)
                return {
                    status: 404,
                    body: "No suggester found"
                }
            }

            // Let's get the top 5 suggestions for that search term
            const suggestions = await client.suggest(q, suggester, { top: parseInt(top) });
            //const suggestions = await client.autocomplete(q, suggester, {top: parseInt(top)});

            context.log(suggestions);

            return {
                headers: {
                    "Content-type": "application/json"
                },
                jsonBody: { 
                    suggestions: suggestions.results,
                    q, 
                    top,
                    suggester 

                }
            }
        } catch (error) {
            return {
                status: 400,
                jsonBody: {
                    innerStatusCode: error.statusCode || error.code,
                    error: error.details || error.message
                }
            }
        }
    }
});

Klient: Förslag från katalogen

Api:et för funktionen Suggest anropas i React-appen som \src\components\SearchBar\SearchBar.js en del av komponentinitiering:

import React, { useState } from "react";
import request from "../api";
import Button from "@mui/material/Button";
import TextField from "@mui/material/TextField";
import Autocomplete from "@mui/material/Autocomplete";
import styled from "@emotion/styled";

import { useQuery } from "@tanstack/react-query";

const StyledContainer = styled.div`
  display: flex;
  flex-flow: row wrap;
  justify-content: space-evenly;
  align-items: center;
  gap: 1em;
  margin-top: 1em;
`;

const StyledAutoComplete = styled(Autocomplete)`
  width: 80%;
`;

const StyledButton = styled(Button)`
  max-height: 40px;
`;

export default function SearchBar({ navigateToSearchPage, defaultTerm = "" }) {

  const [q, setQ] = useState(defaultTerm);
  const [suggestions, setSuggestions] = useState([]);

  const top = 5;
  const suggester = "sg";

  /* eslint-disable no-unused-vars */
  const { data, isLoading, error } = useQuery({
    queryKey: ["suggest", q, top, suggester],
    refetchOnMount: true,
    enabled: q.length > 0,
    queryFn: async () => {
      if (q.length > 0) {
        return request("/api/suggest", "POST", {
          q,
          top,
          suggester,
        }).then((response) => {
          let i = 0;
          const autoCompleteOptions = response.suggestions.map(
            (suggestion) => ({ id: i++, label: suggestion.text })
          );
          setSuggestions(autoCompleteOptions);
          return response;
        });
      }
    },
  });

  const onFormSubmit = () => {
    if (navigateToSearchPage) {
      navigateToSearchPage(q);
    }
  };

  function hasLabelValue (option){
    return (
      option !== undefined &&
      option !== null &&
      option.label !== undefined &&
      option.label !== null
    );
  }

  return (
    <StyledContainer onSubmit={onFormSubmit}>
      <StyledAutoComplete
        key="autocomplete"
        freeSolo // accepts both entered text or selected suggestion
        autoSelect // text in box selected
        autoFocus="autoFocus"
        filterOptions={(x) => x}
        options={suggestions}
        value={q}
        noOptionsText="What are you looking for?"
        onChange={
          (e, value, reason) => setQ(value?.label || "")
        }
        onInputChange={(e, newValue, reason) => {
          if (newValue) {
            setQ(newValue);
          }
        }}
        getOptionLabel={(option) => hasLabelValue(option) ? option.label : q}
        // set key to force re-render when q changes
        renderOption={(props, option) => {
          return hasLabelValue(option) ? (
            <li {...props} key={option.id}>
              {option.label}
            </li>
          ) :  (
            <li {...props} key={q}>
              {q}
            </li>
          )
        }}
        renderInput={(params) => (
          <TextField
            {...params}
            variant="outlined"
            label="What are you looking for?"
            onKeyDown={(e) => {
              if (e.code.toLowerCase() === "enter" && e.target.value) {
                onFormSubmit(e.target.value);
              }
            }}
          />
        )}
      />
      <StyledButton
        key="styledbutton"
        variant="contained"
        onClick={onFormSubmit}
      >
        Search
      </StyledButton>
    </StyledContainer>
  );
}

Den här React-komponenten använder komponenten @mui/material/Autocomplete för att tillhandahålla en söktextruta, som också stöder visningsförslag (med hjälp av renderInput funktionen). Automatisk komplettering startar när de första tecknen har angetts. När varje nytt tecken anges skickas det som en fråga till sökmotorn. Resultatet visas som en kort lista med förslag.

Den här funktionen för automatisk komplettering är en vanlig funktion, men den här specifika implementeringen har ytterligare ett användningsfall. Kunden kan ange text och välja bland förslagen eller skicka sin angivna text. Indata från förslagslistan samt indata från textrutan måste spåras efter ändringar, vilket påverkar hur formuläret återges och vad som skickas till sök-API:et när formuläret skickas.

Om ditt användningsfall för sökning tillåter att användaren bara väljer bland förslagen, minskar det omfattningen av kontrollens komplexitet men begränsar användarupplevelsen.

Azure-funktion: Hämta specifikt dokument

Uppslags-API:et tar ett ID och returnerar dokumentobjektet från sökindexet.

const { SearchClient, AzureKeyCredential } = require("@azure/search-documents");
const { app } = require('@azure/functions');
const { CONFIG } = require("../lib/config");

// Create a SearchClient to send queries
const client = new SearchClient(
    `https://` + CONFIG.SearchServiceName + `.search.windows.net/`,
    CONFIG.SearchIndexName,
    new AzureKeyCredential(CONFIG.SearchApiQueryKey)
);

app.http('lookup', {
    methods: ['GET'],
    authLevel: 'anonymous',
    handler: async (request, context) => {
        context.log(`Lookup processed request for url "${request.url}"`);

        try {
            const id = request.query.get('id');
            console.log(id);

            if (!id) {
                return {
                    status: 404
                }
            }

            const document = await client.getDocument(id);

            return { jsonBody: { document: document } };

        } catch (error) {
            return {
                status: 400,
                jsonBody: {
                    innerStatusCode: error.statusCode || error.code,
                    error: error.details || error.message
                }
            }
        }
    }
});

Klient: Hämta specifikt dokument

Det här funktions-API:et anropas i React-appen som \src\pages\Details\Detail.js en del av komponentinitiering:

import React, { useState } from "react";
import { useParams } from 'react-router-dom';
import CircularProgress from '@mui/material/CircularProgress';
import Container from '@mui/material/Container';
import request from '../api';
import BookDetailsTab from "../components/BookDetail/DetailPage/booktab";

import {
  useQuery,
} from '@tanstack/react-query'

export default function Details() {

  let { id } = useParams();
  const [document, setDocument] = useState({});

  const lookupRequest = () => request('/api/lookup?id=' + id, "GET").then(response => {
    const doc = response.document;
    setDocument(doc);
    return response
  });

  const fiveMinutes = 1000 * 60 * 5;

    /* eslint-disable no-unused-vars */
  const { data, isLoading, error } = useQuery({
    queryKey: ["lookup", id], 
    queryFn: async() =>  lookupRequest(),
    enabled: id !== undefined,
    staleTime: fiveMinutes, // time in milliseconds
    cacheTime: fiveMinutes,
    //refetchOnMount: false,
    //refetchOnWindowFocus: false,
    //refetchOnReconnect: false,
  });

  return (
    <Container sx = {{
      padding: 4
    }}>
      { isLoading ? <CircularProgress /> : <BookDetailsTab document={document}/>}
      { error && <div>{error}</div> }
    </Container>
   
  );
}

Om klientappen kan använda förgenererat innehåll är den här sidan en bra kandidat för autogenerering eftersom innehållet är statiskt och hämtas direkt från sökindexet.

Nästa steg

I den här självstudieserien har du lärt dig hur du skapar och läser in ett sökindex i JavaScript, och du har skapat en webbapp som ger en sökupplevelse som innehåller ett sökfält, fasetterad navigering och filter, förslag, sidnumrering och dokumentsökning.

Som ett nästa steg kan du utöka det här exemplet i flera riktningar:

  • Lägg till automatisk komplettering för mer typeahead.
  • Lägg till eller ändra fasetter och filter.
  • Ändra autentiserings- och auktoriseringsmodellen med hjälp av Microsoft Entra-ID i stället för nyckelbaserad autentisering.
  • Ändra indexeringsmetodiken. I stället för att skicka JSON till ett sökindex läser du in en blobcontainer med datauppsättningen good-books och konfigurerar en blobindexerare för att mata in data. Om du vet hur du arbetar med indexerare får du fler alternativ för datainmatning och innehållsberikning under indexering.