4 - Explorar el código de búsqueda JavaScript
En las lecciones anteriores, agregó la búsqueda a una aplicación web estática. En esta lección se resaltan los pasos esenciales que establecen la integración. Si busca una hoja de características clave sobre cómo integrar la búsqueda en la aplicación de JavaScript, en este artículo se explica lo que necesita saber.
El código fuente está disponible en el repositorio de GitHub azure-search-javascript-samples.
@azure/search-documents de SDK de Azure
La aplicación de funciones usa el SDK de Azure AI Search:
- NPM: @azure/search-documents
- Documentación de referencia: Biblioteca cliente
La aplicación de funciones se autentica a través del SDK para la API de Azure AI Search basada en la nube con el nombre del recurso, la clave de API y el nombre del índice. Los secretos se almacenan en la configuración estática de la aplicación web y se extraen a la función como variables de entorno.
Configuración de secretos en un archivo de configuración
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 Function: búsqueda en el catálogo
La API de búsqueda toma un término de búsqueda y busca en los documentos del índice de búsqueda, y devuelve una lista de coincidencias.
Azure Functions extrae la información de configuración de búsqueda y satisface la consulta.
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
}
}
}
}
});
Cliente: búsqueda en el catálogo
Llame a la función de Azure en el cliente de React con el código siguiente.
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>
</>
);
}
Cliente: facetas del catálogo
Este componente React incluye el cuadro de texto de búsqueda y las facetas asociadas a los resultados de la búsqueda. Las facetas deben considerarse y diseñarse como parte del esquema de búsqueda cuando se cargan los datos de búsqueda. A continuación, las facetas se usan en la consulta de búsqueda, junto con el texto de búsqueda, para proporcionar la experiencia de navegación por facetas.
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>
);
}
Cliente: paginación desde el catálogo
Cuando los resultados de la búsqueda se expanden más allá de unos pocos triviales (8), el componente @mui/material/TablePagination
proporciona paginación entre los resultados.
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`
}
/>
);
}
Cuando el usuario cambia la página, ese valor se envía a la página primaria Search.js
desde la función handleChangePage
. La función envía una nueva solicitud a la API de búsqueda para la misma consulta y la nueva página. La respuesta de la API actualiza las facetas, los resultados y los componentes de paginación.
Azure Function: sugerencias del catálogo
La API de sugerencias toma un término de búsqueda mientras un usuario está escribiendo y sugiere términos de búsqueda como títulos de libros y autores en los documentos en el índice de búsqueda, y devuelve una pequeña lista de coincidencias.
El proveedor de sugerencias de búsqueda, sg
, se define en el archivo de esquema usado durante la carga masiva.
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
}
}
}
}
});
Cliente: sugerencias del catálogo
Se llama a la función de Suggest API en la aplicación React en \src\components\SearchBar\SearchBar.js
como parte de la inicialización de componentes:
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>
);
}
Este componente React usa el componente @mui/material/Autocomplete
para proporcionar un cuadro de texto de búsqueda, que también admite la visualización de sugerencias (mediante la función renderInput
). La función Autocompletar se inicia después de escribir los primeros caracteres. A medida que se escribe cada carácter nuevo, se envía como una consulta al motor de búsqueda. Los resultados se muestran como una breve lista de sugerencias.
Esta funcionalidad de autocompletar es una característica común, pero esta implementación específica tiene un caso de uso adicional. El cliente puede escribir texto y seleccionar entre las sugerencias o enviar su texto escrito. Se debe realizar un seguimiento de los cambios tanto en la entrada de la lista de sugerencias, así como en la entrada del cuadro de texto, lo que afecta a cómo se representa el formulario y qué se envía a la API de búsqueda cuando se envía el formulario.
Si el caso de uso de la búsqueda permite al usuario seleccionar solo entre las sugerencias, se reducirá el ámbito de complejidad del control, pero se limitará la experiencia del usuario.
Función de Azure: obtención de un documento específico
La API de visualización toma un identificador y devuelve el objeto de documento del índice de búsqueda.
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
}
}
}
}
});
Cliente: obtención de un documento específico
Se llama a esta API de función en la aplicación React en \src\pages\Details\Detail.js
como parte de la inicialización de componentes:
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>
);
}
Si la aplicación cliente puede usar contenido generado previamente, esta página es una buena candidata para la autogeneración porque el contenido es estático, extraído directamente del índice de búsqueda.
Pasos siguientes
En esta serie de tutoriales, ha aprendido a crear y cargar un índice de búsqueda en JavaScript, y ha creado una aplicación web que proporciona una experiencia de búsqueda que incluye una barra de búsqueda, navegación por facetas y filtros, sugerencias, paginación y búsqueda de documentos.
Como paso siguiente, puede ampliar este ejemplo en varias direcciones:
- Agregue autocompletar para sugerencias más rápidas.
- Agregar o modificar facetas y filtros.
- Cambie el modelo de autenticación y autorización mediante Microsoft Entra ID en lugar de la autenticación basada en claves.
- Cambie la metodología de indexación. En lugar de insertar JSON en un índice de búsqueda, cargue previamente un contenedor de blobs con el conjunto de datos de libros buenos y configure un indexador de blobs para ingerir los datos. Saber cómo trabajar con indexadores ofrece más opciones para la ingesta de datos y el enriquecimiento de contenido durante la indexación.