Blog

Späť na všetky články

Dynamické zostavovanie GraphQL queries pre filter preview z URL parametrov

Ako udržiavame URL adresy krátke, backendové queries optimálne a používateľské rozhranie bohaté - všetko naraz.

Úvod

Ahoj! Dnes sa pozrieme na pokročilé backendové vyhľadávanie v našej aplikácii ProGrocery a rozvinieme myšlienku, ktorá by vám mohla tiež pomôcť: ukladanie parametrov vyhľadávania do URL a, čo je dôležitejšie, dynamické zostavovanie GraphQL queries pre filter preview UI.

Keď naša aplikácia rástla a klient požadoval lepšie funkcie - čoraz komplexnejšie spôsoby filtrovania a vyhľadávania v rôznych zoznamoch dát - potrebovali sme implementovať pokročilé backendové vyhľadávanie s množstvom atribútov na výber.

Používatelia si môžu vybrať zo širokej škály filtrovacích atribútov - značky, produkty, stavy, predajcovia a ďalšie.

Používatelia si môžu vybrať zo širokej škály filtrovacích atribútov - značky, produkty, stavy, predajcovia a ďalšie.

Každý atribút vyhľadávania má vlastnú sadu možností. Podľa potreby sa tieto možnosti lazy-loadujú z backendu s debounce pri písaní do vstupného poľa, čo udržiava autocomplete svižný bez zahlcovania servera.

Možnosti sa načítavajú na požiadanie počas písania, s debounce queries pre lepší výkon.

Možnosti sa načítavajú na požiadanie počas písania, s debounce queries pre lepší výkon.

Samozrejme, dodali sme to s vyladeným UI. Zatiaľ nič prevratné - každá slušná aplikácia má filtre vyhľadávania.

Aktívne filtre sa zobrazujú ako odstrániteľné chips s čitateľnými popiskami namiesto surových ID.

Aktívne filtre sa zobrazujú ako odstrániteľné chips s čitateľnými popiskami namiesto surových ID.

Výzva: Zdieľateľné URL adresy

Našu aplikáciu denne používajú ľudia rôznej počítačovej gramotnosti. Vždy sa snažíme, aby bolo UI čo najpríjemnejšie pre všetkých. Opakujúcou sa požiadavkou bola možnosť zdieľať filtrovaný pohľad cez odkaz - či už kolega zdieľa predfiltrovaný zoznam, alebo newsletter odkazuje na konkrétnu sadu ponúk.

A tu to začína byť zaujímavé, pretože existuje niekoľko dobrých praktík, o ktorých sa oplatí hovoriť, ak implementujete podobnú funkcionalitu.

Zásady pre vyhľadávacie parametre v URL

  1. URL adresy by mali byť "pekné." Keď používatelia kopírujú a zdieľajú odkazy, alebo len nazrú do adresného riadka, URL by ich nemala odstrašiť. Mala by dávať sémantický zmysel, aby používatelia vedeli, kde sa v hierarchii aplikácie nachádzajú. Ukladanie parametrov vyhľadávania za ? je zaužívaný štandard.

  2. URL adresy majú obmedzenú dĺžku. Hoci moderné prehliadače zvládnu asi 100 000 znakov v URL (závisí od prehliadača), stále ide o konečný zdroj. Ukladajte čo najmenej informácií.

  3. UI potrebuje viac dát, ako URL uchováva. Na filtrovanie produktov na backende potrebujete len ich ID, ale v UI chcete zobrazovať ich názvy, UPC kódy alebo iné čitateľné popisky.

Postup je teda jasný: ukladajte do URL len nevyhnutné ID , aby ostala krátka a prehľadná, a potom nejakým spôsobom získajte ďalšie dáta pre bohaté UI.

Kľúčová myšlienka: Nedá sa predpokladať, akú kombináciu filtrov používateľ nastaví. Ak napevno zakódujete jediné "preview" query, buď načíta príliš málo dát (a UI sa pokazí), alebo príliš veľa (plytvanie dátovým tokom a serverovými zdrojmi na modely, ktoré pre dané vyhľadávanie nepotrebujete). Riešenie? Zostavte filter preview query dynamicky na základe toho, čo sa skutočne nachádza v URL.

Prehľad architektúry

Než sa vrhneme do kódu, prejdime si celý tok dát.

URL parametre -> Spracovanie a deserializácia -> Zostavenie dynamického query -> Jeden GraphQL request -> Bohaté UI chips

Stav filtra je serializovaný ako JSON a uložený pod jedným parametrom search v URL. Po načítaní stránky ho deserializujeme, zistíme, ktoré kľúče filtrov sú aktívne, a zostavíme GraphQL query obsahujúce len sub-queries potrebné pre tieto kľúče. Výsledok naplní preview chips, ktoré vidíte na snímkach vyššie.

Tento vzor používame naprieč štyrmi rôznymi obrazovkami vyhľadávania v ProGrocery - Offer Review, All Offers, Surveys a Stores - každá s vlastnou sadou filtrovacích atribútov, no všetky zdieľajú rovnaký znovupoužiteľný komponent FilterInput a stratégiu dynamického query.

Krok 1: Serializácia stavu filtra do URL

Prvým stavebným kameňom je jednoduchý vlastný hook, ktorý číta a zapisuje stav filtra do URL. Tu je podstata useUrlParams:

// useUrlParams.ts

const useUrlParams = () => {
  const history = useHistory();
  const location = useLocation();
  const urlParams = new URLSearchParams(location.search);

  const updateUrlParams = (key: string, value: any) => {
    if (!value || keys(value).length === 0) {
      urlParams.delete(key);
    } else {
      const serializedValue = JSON.stringify(value);
      urlParams.set(key, serializedValue);
    }

    history.replace(`${location.pathname}?${urlParams.toString()}`, location.state);
  };

  return updateUrlParams;
};

Všimnite si, že používame history.replace namiesto history.push. Tým sa zabráni tomu, aby každá zmena filtra vytvárala nový záznam v histórii prehliadača - používatelia môžu stále kliknúť na Späť a opustiť stránku bez toho, aby museli vrátiť každú jednotlivú zmenu filtra.

Výsledná URL vyzerá nejako takto:

/review/offers?search={"brand_slug":["acme"],"status":["PENDING"]}

Čistá, čitateľná, a čo je najdôležitejšie - len ID a minimálne hodnoty. Žiadne zobrazovacie názvy, žiadne nadbytočné metadáta zahlcujúce URL.

Čítanie naspäť

Na druhej strane, useSearchFilterValue parsuje URL pri načítaní stránky:

// useSearchFilterValue.ts

const useSearchFilterValue = () => {
  const urlParams = new URLSearchParams(location.search);
  const serializedFilterValue = urlParams.get("search");

  const value = useMemo(() => {
    if (!serializedFilterValue) return undefined;

    try {
      return JSON.parse(serializedFilterValue);
    } catch (error) {
      console.error("Failed to parse URL search params");
      return null;
    }
  }, [serializedFilterValue]);

  return value;
};

Jednoduché JSON.parse so spracovaním chýb. Ak je URL pozmenená alebo poškodená, aplikácia degraduje elegantne namiesto pádu.

Krok 2: Znovupoužiteľný komponent FilterInput

Jadrom systému je generický, znovupoužiteľný komponent FilterInput, ktorý poháňa všetky štyri obrazovky vyhľadávania. Prijíma dve kľúčové callback props:

// FilterInput — key props

interface IProps<TFilterQueryVariables, T> {
  hints: T; // available filter attributes
  filter: TFilterQueryVariables; // current filter state
  onFilterChange?: (filter: TFilterQueryVariables) => void;

  // Converts UI suggestion objects → minimal filter values (IDs)
  buildOutFilter: (filter: TFilter) => TFilterQueryVariables;

  // Converts minimal filter values → human-readable labels
  buildOutFilterPreview: (filter: TFilterQueryVariables) => Promise<{ data: TFilterPreview, error: any }>;
}

Funkcia buildOutFilter transformuje bohaté objekty návrhov (s popiskami, renderermi atď.) na minimálne hodnoty založené na ID, ktoré sa ukladajú do URL. Funkcia buildOutFilterPreview robí opak - berie tieto minimálne hodnoty a načítava čitateľné zobrazovacie dáta. Tu sa deje kúzlo dynamického query.

Takto FilterInput zapája preview:

//FilterInput — triggering the preview build

// Whenever the filter changes, rebuild the preview
React.useEffect(() => {
  setFilterPreviewLoading(true);
  buildOutFilterPreview(filter)
    .then((res) => {
      setFilterPreviewData(res.data);
      setFilterPreviewError(res.error);
    })
    .finally(() => setFilterPreviewLoading(false));
}, [filter]);

Krok 3: Hviezda show - Dynamické zostavovanie query

Tu sa to všetko spojí. Pozrime sa na hook useFilterPreviewFromUrlParams z našej obrazovky Offer Review. Toto je filter s najbohatšou funkcionalitou v našej aplikácii s 11 rôznymi filtrovacími atribútmi vrátane značiek, produktov, predajcov, account manažérov, stavov, typov doručenia a ďalších.

Myšlienka

Namiesto toho, aby sme písali jedno monolitické GraphQL query, ktoré načíta preview dáta pre všetky možné filtrovacie atribúty, zostavujeme reťazec query za behu z troch polí:

// Core data structures

const params: TParams = []; // GraphQL variable declarations
const queries: TQueries = []; // GraphQL query fields
const variables: TVariables = {}; // Actual variable values

Iterujeme cez aktívne kľúče filtrov a pridávame fragmenty query len pre kľúče, ktoré sú skutočne prítomné v URL:

// useFilterPreviewFromUrlParams.ts — building the query

const buildOutFilterPreview = async (filter: TFilter) => {
  const data: TFilterPreview = {};
  const params: string[] = [];
  const queries: string[] = [];
  const variables: TVariables = {};

  const filterKeys = keys(filter);

  filterKeys.forEach((key) => {
    const values = filter[key];
    if (!values) return;

    switch (key) {
      case "brand_slug":
        params.push("$brandsFilters: BrandsFilterInput");
        queries.push(`
                    brands(filters: $brandsFilters) {
                        id
                        name
                    }
                `);
        variables["brandsFilters"] = { slug: values };
        break;

      case "product_id":
        params.push("$productsFilters: [ProductsFilterInput!]");
        queries.push(`
                    products(filters: $productsFilters) {
                        id
                        upc12
                        title
                    }
                `);
        variables["productsFilters"] = values.map((id) => ({
          key: ProductsFilterEnum.Id,
          value: id,
        }));
        break;

      case "vendor_company_id":
        params.push("$companyIds: [ID!]!");
        queries.push(`
                    company(id: $companyIds) {
                        id
                        name
                    }
                `);
        variables["companyIds"] = values;
        break;

      // Some keys don't need a query at all!
      case "status":
        data[key] = values.map((s) => t(`offer-status.pretty.${s.toLowerCase()}`));
        break;

      case "related_offer_slug":
      case "user_email":
        data[key] = values; // already human-readable
        break;
    }
  });

  // ...
};

Všimnite si elegantné rozdelenie: niektoré kľúče filtra potrebujú backendové query (značky, produkty, predajcovia, používatelia), zatiaľ čo iné sú vyriešené úplne na klientovi (stavy používajú i18n preklad, emaily a slugy sú už čitateľné). Na server sa obraciame len vtedy, keď to skutočne potrebujeme.

Zostavenie query za behu

Tu je funkcia, ktorá vezme naše polia a vytvorí platný GraphQL dokument:

// Dynamic GraphQL document construction

const getCombinedQueryDocument = (params, queries) =>
  gql` query FilterPreviewFromUrlParams${params.length > 0 ? "(" + params.join(", ") + ")" : ""} { ${queries.join(
    "\n"
  )} }`;

Ak používateľ filtruje podľa značky a produktu, vygenerované query vyzerá takto:

// Generated GraphQL query — brand + product

query FilterPreviewFromUrlParams($brandsFilters: BrandsFilterInput, $productsFilters: [ProductsFilterInput!]) {
  brands(filters: $brandsFilters) {
    id
    name
  }

  products(filters: $productsFilters) {
    id
    upc12
    title
  }
}

Ale ak filtruje len podľa stavu a emailu? Žiadne GraphQL query sa neodošle - oba sú vyriešené na klientovi. Nulová sieťová réžia.

Prečo je to dôležité: Len na obrazovke Review existuje 11 možných filtrovacích atribútov. Statické query pokrývajúce všetky by načítalo značky, produkty, spoločnosti a dva samostatné zoznamy používateľov - všetko v jednom requeste - aj keby používateľ filtroval len podľa stavu. Dynamický prístup znamená, že query je vždy čo najmenšie.

Mapovanie výsledkov naspäť

Keď dostaneme odpoveď na kombinované query, namapujeme dáta späť na správne kľúče filtra:

// Mapping query results to filter preview labels

if (queries.length > 0) {
  const res = await getCombinedQuery(params, queries, variables);

  keys(variables).forEach((queriedVar) => {
    switch (queriedVar) {
      case "brandsFilters":
        data["brand_slug"] = res.data.brands.map((b) => b.name);
        break;

      case "productsFilters":
        data["product_id"] = res.data.products.map((p) => `${p.upc12} - ${p.title}`);
        break;

      case "companyIds":
        data["vendor_company_id"] = res.data.company.map((c) => c.name);
        break;

      case "keheamUsersFilters":
        data["keheam_user_id"] = res.data.keheam_users.map((u) => fullNameOrEmail(u));
        break;
    }
  });
}

Objekt data teraz obsahuje čitateľné popisky pre každý aktívny filter - a odovzdá sa komponentu FilterPreview, ktorý renderuje chips.

Krok 4: Renderovanie filter preview

Komponent FilterPreview iteruje cez aktívne filtre a renderuje chip pre každú hodnotu, pričom spája surovú hodnotu filtra s jej čitateľným preview popiskom:

// FilterPreview.tsx

const FilterPreview = ({ filter, hints, filterPreview, loading }) => {
  const allFilters = [];
  const allFiltersPreview = [];

  keys(filter).forEach((hintKey) => {
    const values = filter[hintKey];
    const valuesPreview = filterPreview[hintKey];

    if (isArray(values)) {
      values.forEach((value) => allFilters.push({ value, hintKey, hint: hints[hintKey] }));
    }
    // ... same for preview values
  });

  return (
    <Box>
      {loading && <LoadingIndicator overlay />}
      {allFilters.map((item, i) => (
        <FilterInputChip
          label={item.hint.label}
          title={allFiltersPreview[i]?.value}
          onRemove={() => handleDelete(item)}
        />
      ))}
    </Box>
  );
};

Každý chip zobrazuje popisok atribútu (napr. "Značka:") spolu s preview hodnotou (napr. "Acme Foods") - hoci v URL žije len slug "acme". Loading overlay zabezpečuje, že používatelia vidia plynulý prechod, kým sa preview query vykonáva.

Krok 5: Debounce a lazy-loaded návrhy

Vstup filtra napĺňa aj autocomplete dropdown. Každý filtrovací atribút definuje vlastnú suggestionsQuery - funkciu, ktorá načítava možnosti z backendu. Takto obrazovka Review definuje svoje hints:

// useInputHints.ts — defining filter attributes

const hints = useMemo(
  () => ({
    brand_slug: {
      label: t("brand_slug.label"),
      description: t("brand_slug.description"),
      suggestionsQuery: buildBrandQuery(client),
      debounce: 500,
    },
    product_id: {
      label: t("product_id.label"),
      description: t("product_id.description"),
      suggestionsQuery: buildProductQuery(client),
      debounce: 500,
    },
    vendor_company_id: {
      label: t("vendor_company_id.label"),
      description: t("vendor_company_id.description"),
      suggestionsQuery: buildCompanyQuery(client),
      debounce: 500,
    },
    status: {
      label: t("status.label"),
      description: t("status.description"),
      suggestionsQuery: buildStatusQuery(tt),
      // no debounce — local enum, instant
    },
    // ... 7 more attributes
  }),
  [client, tt, rcSlug, periodIds, dcName]
);

Všimnite si, ako každý hint deklaratívne konfiguruje vlastné debounce načasovanie. Návrhy načítané z backendu, ako značky a produkty, používajú 500 ms debounce, zatiaľ čo lokálne enumy, ako stavy a typy produktov, sa vyriešia okamžite bez debounce.

Komponent FilterInput potom používa debounce callback na načítanie návrhov len vtedy, keď používateľ prestane písať:

// Debounced suggestion fetching

const debouncedFetchSuggestions = React.useCallback(debounce(fetchSuggestions, selectedHint?.debounce || 0), [
  selectedHint,
]);

const handleChange = (value: string) => {
  setValue(value);
  if (selectedHint) {
    debouncedFetchSuggestions(selectedHint, value, setSuggestionsLoading, setSuggestions);
  }
};

Krok 6: Od návrhu po URL (buildOutFilter)

Keď si používateľ vyberie návrh z dropdown zoznamu, musíme previesť bohatý objekt návrhu na minimálnu hodnotu, ktorá sa uloží do URL. Toto je callback buildOutFilter a každá obrazovka vyhľadávania implementuje svoju vlastnú verziu:

// ReviewFilter — buildOutFilter

const buildOutFilter = (filter) => {
  const res = {};

  keys(filter).forEach((key) => {
    const values = filter[key];

    switch (key) {
      case "brand_slug":
      case "product_id":
      case "vendor_company_id":
      case "keheam_user_id":
      case "bdm_user_id":
        // Extract just the ID from each suggestion
        res[key] = values.map((s) => s.id).filter(isPresent);
        break;

      case "status":
        // Cast to the correct enum type
        res.status = values.map((s) => s.id as OfferStatus);
        break;

      case "related_offer_slug":
        // Single value, not an array
        res.related_offer_slug = values[0]?.id;
        break;
    }
  });

  return res;
};

Toto je krok "kompresie". Objekt návrhu môže niesť popisok, renderer a celý objekt výsledku. My ho zredukujeme len na id - jedinú vec, ktorú backend potrebuje a ktorá patrí do URL.

Vzor je škálovateľný

Presne tento vzor architektúry používame naprieč štyrmi rôznymi obrazovkami vyhľadávania, každá s rôznymi filtrovacími atribútmi:

  • Offer Review - 11 atribútov (značky, produkty, predajcovia, používatelia, stavy, typy doručenia...)
  • All Offers - 8 atribútov (značky, produkty, retailové reťazce, kalendáre, roky...)
  • Surveys - 10 atribútov (názvy, predajne, distribučné centrá, dátumové rozsahy...)
  • Stores - 7 atribútov (názvy, účty, reťazce, cenové zóny...)

Každá obrazovka poskytuje vlastný useInputHints, buildOutFilter a useFilterPreviewFromUrlParams. Znovupoužiteľný komponent FilterInput sa nestará o špecifiká - len orchestruje tok. Toto oddelenie zodpovedností znamená, že pridanie novej vyhľadávacej obrazovky je záležitosťou definovania konfigurácie filtra, nie prepisovania vyhľadávacej infraštruktúry.

Kľúčové poznatky

Ak budujete niečo podobné, toto by sme odporúčali:

  1. Ukladajte do URL len minimálne dáta. ID, slugy, hodnoty enumov - nič viac. Udržiavajte URL krátke, prehľadné a zdieľateľné.

  2. Zostavujte preview queries dynamicky. Nekódujte monolitické query napevno. Zostavte fragmenty query za behu podľa toho, ktoré filtre sú aktívne. Váš backend vám poďakuje.

  3. Riešte čo najviac na klientovi. Stavy, enumy a už čitateľné hodnoty nepotrebujú round trip cez sieť. Rozdeľte logiku preview na "potrebuje query" vs. "vyriešiť lokálne."

  4. Urobte komponent filtra generický. Presuňte doménovo špecifickú logiku do hookov a callbackov. Základný komponent by mal byť znovupoužiteľný naprieč obrazovkami.

  5. Debounce s rozumom. Návrhy načítané z backendu potrebujú debounce; lokálne enumy nie. Urobte to konfigurovateľné pre každý atribút.

Záver

Vzor dynamického zostavovania queries sa nám v ProGrocery osvedčil. URL adresy ostávajú čisté a zdieľateľné. Backendové queries ostávajú optimálne - nikdy nenačítavajú viac, ako je potrebné. A UI ostáva bohaté, zobrazujúc čitateľné popisky pre každý filter, dokonca aj keď URL obsahuje len kryptické ID.

Implementačné detaily sa samozrejme pre vašu aplikáciu budú líšiť, ale základná myšlienka by mala ostať rovnaká: ukladajte minimálne dáta, zostavujte queries dynamicky, riešte lokálne kde je to možné.

Radi by sme počuli o vašich skúsenostiach s parametrami vyhľadávania v URL. Vyskúšali ste podobný prístup? Narazili ste na edge cases, ktoré sme nespomenuli? Napíšte komentár nižšie!

Sledujte nás pre ďalšie príspevky od engineering tímu ProGrocery.

Autor: Michal Puškel

Plánujete aplikáciu?

Poďme spoločne vybudovať niečo skvelé.

Ahmed Al Hafoudh