Start working on new item search in wardrobe-2020!

For now, I'm doing it with a secret feature flag, since I want to be
committing but it isn't all quite working yet!

Search works right, and the appearance data is getting returned, but I
don't have the Apollo Cache integrations yet, which we rely on more
than I remembered!

Also, alt styles will crash it for now!
This commit is contained in:
Emi Matchu 2024-02-25 14:46:27 -08:00
parent 52e81557c2
commit a8cbce0864
6 changed files with 184 additions and 68 deletions

View file

@ -38,14 +38,17 @@ class ItemsController < ApplicationController
zone: { zone: {
only: [:id, :depth, :label], only: [:id, :depth, :label],
methods: [:is_commonly_used_by_items], methods: [:is_commonly_used_by_items],
} },
restricted_zones: {
only: [:id, :depth, :label],
methods: [:is_commonly_used_by_items],
},
}, },
methods: [:urls, :known_glitches], methods: [:urls, :known_glitches],
}, },
} }
), ),
total_pages: @items.total_pages, total_pages: @items.total_pages,
total_count: @items.count,
query: @query.to_s, query: @query.to_s,
} }
} }
@ -118,10 +121,11 @@ class ItemsController < ApplicationController
end end
def load_appearances def load_appearances
pet_type_name = params[:with_appearances_for] appearance_params = params[:with_appearances_for]
return {} if pet_type_name.blank? return {} if appearance_params.blank?
pet_type = Item::Search::Query.load_pet_type_by_name(pet_type_name) pet_type = Item::Search::Query.load_pet_type_by_color_and_species(
appearance_params[:color_id], appearance_params[:species_id])
pet_type.appearances_for(@items.map(&:id), swf_asset_includes: [:zone]) pet_type.appearances_for(@items.map(&:id), swf_asset_includes: [:zone])
end end

View file

@ -1,7 +1,9 @@
import React from "react";
import gql from "graphql-tag"; import gql from "graphql-tag";
import { useQuery } from "@apollo/client"; import { useQuery } from "@apollo/client";
import { useDebounce } from "../util"; import { useDebounce, useLocalStorage } from "../util";
import { emptySearchQuery } from "./SearchToolbar"; import { useItemSearch } from "../loaders/items";
import { emptySearchQuery, searchQueryIsEmpty } from "./SearchToolbar";
import { itemAppearanceFragment } from "../components/useOutfitAppearance"; import { itemAppearanceFragment } from "../components/useOutfitAppearance";
import { SEARCH_PER_PAGE } from "./SearchPanel"; import { SEARCH_PER_PAGE } from "./SearchPanel";
@ -41,14 +43,20 @@ export function useSearchResults(
const currentPageIndex = currentPageNumber - 1; const currentPageIndex = currentPageNumber - 1;
const offset = currentPageIndex * SEARCH_PER_PAGE; const offset = currentPageIndex * SEARCH_PER_PAGE;
// const filters = buildSearchFilters(/* TODO */); // Until the new item search is ready, we can toggle between them! Use
// const { loading, error, data } = useItemSearch(filters); // `setItemSearchQueryMode` in the JS console to choose "gql" or "new".
const [queryMode, setQueryMode] = useLocalStorage(
"DTIItemSearchQueryMode",
"gql",
);
React.useEffect(() => {
window.setItemSearchQueryMode = setQueryMode;
}, [setQueryMode]);
// Here's the actual GQL query! At the bottom we have more config than usual!
const { const {
loading: loadingGQL, loading: loadingGQL,
error, error: errorGQL,
data, data: dataGQL,
} = useQuery( } = useQuery(
gql` gql`
query SearchPanel( query SearchPanel(
@ -125,6 +133,7 @@ export function useSearchResults(
context: { sendAuth: true }, context: { sendAuth: true },
skip: skip:
skip || skip ||
queryMode !== "gql" ||
(!debouncedQuery.value && (!debouncedQuery.value &&
!debouncedQuery.filterToItemKind && !debouncedQuery.filterToItemKind &&
!debouncedQuery.filterToZoneLabel && !debouncedQuery.filterToZoneLabel &&
@ -137,10 +146,69 @@ export function useSearchResults(
}, },
); );
const loading = debouncedQuery !== query || loadingGQL; const {
const items = data?.itemSearch?.items ?? []; isLoading: loadingQuery,
const numTotalItems = data?.itemSearch?.numTotalItems ?? null; error: errorQuery,
const numTotalPages = Math.ceil(numTotalItems / SEARCH_PER_PAGE); data: dataQuery,
} = useItemSearch(
{
filters: buildSearchFilters(debouncedQuery, outfitState),
withAppearancesFor: { speciesId, colorId, altStyleId },
page: currentPageIndex + 1,
perPage: SEARCH_PER_PAGE,
},
{
enabled:
!skip && queryMode === "new" && !searchQueryIsEmpty(debouncedQuery),
},
);
const loading =
debouncedQuery !== query ||
(queryMode === "gql" ? loadingGQL : loadingQuery);
const error = queryMode === "gql" ? errorGQL : errorQuery;
const items =
(queryMode === "gql" ? dataGQL?.itemSearch?.items : dataQuery?.items) ?? [];
const numTotalPages =
(queryMode === "gql"
? Math.ceil((dataGQL?.itemSearch?.numTotalItems ?? 0) / SEARCH_PER_PAGE)
: dataQuery?.numTotalPages) ?? 0;
return { loading, error, items, numTotalPages }; return { loading, error, items, numTotalPages };
} }
function buildSearchFilters(query, { speciesId, colorId, altStyleId }) {
const filters = [];
if (query.value) {
filters.push({ key: "name", value: query.value });
}
if (query.filterToItemKind === "NC") {
filters.push({ key: "is_nc" });
} else if (query.filterToItemKind === "PB") {
filters.push({ key: "is_pb" });
} else if (query.filterToItemKind === "NP") {
filters.push({ key: "is_np" });
}
if (query.filterToZoneLabel != null) {
filters.push({
key: "occupied_zone_set_name",
value: query.filterToZoneLabel,
});
}
if (query.filterToCurrentUserOwnsOrWants === "OWNS") {
filters.push({ key: "user_closet_hanger_ownership", value: "true" });
} else if (query.filterToCurrentUserOwnsOrWants === "WANTS") {
filters.push({ key: "user_closet_hanger_ownership", value: "false" });
}
filters.push({
key: "fits",
value: { speciesId, colorId, altStyleId },
});
return filters;
}

View file

@ -1,6 +1,6 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { normalizeSwfAssetToLayer } from "./shared-types"; import { normalizeSwfAssetToLayer, normalizeZone } from "./shared-types";
export function useItemAppearances(id, options = {}) { export function useItemAppearances(id, options = {}) {
return useQuery({ return useQuery({
@ -25,10 +25,19 @@ async function loadItemAppearancesData(id) {
} }
export function useItemSearch(searchOptions, queryOptions = {}) { export function useItemSearch(searchOptions, queryOptions = {}) {
// Item searches are considered fresh for an hour, unless the search
// includes user-specific filters, in which case React Query will pretty
// aggressively reload it!
const includesUserSpecificFilters = searchOptions.filters.some(
(f) => f.key === "user_closet_hanger_ownership",
);
const staleTime = includesUserSpecificFilters ? 0 : 1000 * 60 * 5;
return useQuery({ return useQuery({
...queryOptions, ...queryOptions,
queryKey: ["itemSearch", buildItemSearchParams(searchOptions)], queryKey: ["itemSearch", buildItemSearchParams(searchOptions)],
queryFn: () => loadItemSearch(searchOptions), queryFn: () => loadItemSearch(searchOptions),
staleTime,
}); });
} }
@ -39,15 +48,28 @@ function buildItemSearchParams({
perPage = 30, perPage = 30,
}) { }) {
const params = new URLSearchParams(); const params = new URLSearchParams();
for (const [i, filter] of filters.entries()) { for (const [i, { key, value, isPositive }] of filters.entries()) {
params.append(`q[${i}][key]`, filter.key); params.append(`q[${i}][key]`, key);
params.append(`q[${i}][value]`, filter.value); if (key === "fits") {
if (params.isPositive == false) { params.append(`q[${i}][value][species_id]`, value.speciesId);
params.append(`q[${i}][value][color_id]`, value.colorId);
if (value.altStyleId != null) {
params.append(`q[${i}][value][alt_style_id]`, value.altStyleId);
}
} else {
params.append(`q[${i}][value]`, value);
}
if (isPositive == false) {
params.append(`q[${i}][is_positive]`, "false"); params.append(`q[${i}][is_positive]`, "false");
} }
} }
if (withAppearancesFor != null) { if (withAppearancesFor != null) {
params.append("with_appearances_for", withAppearancesFor); const { speciesId, colorId, altStyleId } = withAppearancesFor;
params.append(`with_appearances_for[species_id]`, speciesId);
params.append(`with_appearances_for[color_id]`, colorId);
if (altStyleId != null) {
params.append(`with_appearances_for[alt_style_id]`, altStyleId);
}
} }
params.append("page", page); params.append("page", page);
params.append("per_page", perPage); params.append("per_page", perPage);
@ -85,7 +107,7 @@ function normalizeItemAppearancesData(data) {
function normalizeItemSearchData(data, searchOptions) { function normalizeItemSearchData(data, searchOptions) {
return { return {
id: buildItemSearchParams(searchOptions), id: buildItemSearchParams(searchOptions),
numTotalItems: data.total_count, numTotalPages: data.total_pages,
items: data.items.map((item) => ({ items: data.items.map((item) => ({
id: String(item.id), id: String(item.id),
name: item.name, name: item.name,
@ -110,6 +132,10 @@ function normalizeItemSearchAppearance(data, item) {
return { return {
id: `item-${item.id}-body-${data.body.id}`, id: `item-${item.id}-body-${data.body.id}`,
layers: data.swf_assets.map(normalizeSwfAssetToLayer), layers: data.swf_assets.map(normalizeSwfAssetToLayer),
restrictedZones: data.swf_assets
.map((a) => a.restricted_zones)
.flat()
.map(normalizeZone),
}; };
} }
@ -127,7 +153,3 @@ function normalizeBody(body) {
}, },
}; };
} }
function normalizeZone(zone) {
return { id: String(zone.id), label: zone.label, depth: zone.depth };
}

View file

@ -1,19 +1,23 @@
export function normalizeSwfAssetToLayer(swfAssetData) { export function normalizeSwfAssetToLayer(data) {
return { return {
id: String(swfAssetData.id), id: String(data.id),
remoteId: String(swfAssetData.remote_id), remoteId: String(data.remote_id),
zone: { zone: normalizeZone(data.zone),
id: String(swfAssetData.zone.id), bodyId: data.body_id,
depth: swfAssetData.zone.depth, knownGlitches: data.known_glitches,
label: swfAssetData.zone.label,
isCommonlyUsedByItems: swfAssetData.zone.is_commonly_used_by_items,
},
bodyId: swfAssetData.body_id,
knownGlitches: swfAssetData.known_glitches,
svgUrl: swfAssetData.urls.svg, svgUrl: data.urls.svg,
canvasMovieLibraryUrl: swfAssetData.urls.canvas_library, canvasMovieLibraryUrl: data.urls.canvas_library,
imageUrl: swfAssetData.urls.png, imageUrl: data.urls.png,
swfUrl: swfAssetData.urls.swf, swfUrl: data.urls.swf,
};
}
export function normalizeZone(data) {
return {
id: String(data.id),
depth: data.depth,
label: data.label,
isCommonlyUsedByItems: data.is_commonly_used_by_items,
}; };
} }

View file

@ -46,10 +46,11 @@ class Item
Filter.restricts(value) : Filter.restricts(value) :
Filter.not_restricts(value)) Filter.not_restricts(value))
when 'fits' when 'fits'
pet_type = load_pet_type_by_name(value) color_name, species_name = value.split("-")
pet_type = load_pet_type_by_name(color_name, species_name)
filters << (is_positive ? filters << (is_positive ?
Filter.fits(pet_type.body_id, value.downcase) : Filter.fits(pet_type.body_id, color_name, species_name) :
Filter.not_fits(pet_type.body_id, value.downcase)) Filter.not_fits(pet_type.body_id, color_name, species_name))
when 'species' when 'species'
begin begin
species = Species.find_by_name!(value) species = Species.find_by_name!(value)
@ -132,15 +133,15 @@ class Item
filters << (is_positive ? filters << (is_positive ?
Filter.restricts(value, locale) : Filter.restricts(value, locale) :
Filter.not_restricts(value, locale)) Filter.not_restricts(value, locale))
when 'fits_pet_type' when 'fits'
pet_type = PetType.find(value) raise NotImplementedError if value[:alt_style_id].present?
color_name = pet_type.color.name pet_type = load_pet_type_by_color_and_species(
species_name = pet_type.species.name value[:color_id], value[:species_id])
# NOTE: Some color syntaxes are weird, like `fits:"polka dot-aisha"`! color = Color.find value[:color_id]
value = "#{color_name}-#{species_name}" species = Species.find value[:species_id]
filters << (is_positive ? filters << (is_positive ?
Filter.fits(pet_type.body_id, value) : Filter.fits(pet_type.body_id, color.name, species.name) :
Filter.not_fits(pet_type.body_id, value)) Filter.not_fits(pet_type.body_id, color.name, species.name))
when 'user_closet_hanger_ownership' when 'user_closet_hanger_ownership'
case value case value
when 'true' when 'true'
@ -160,9 +161,7 @@ class Item
self.new(filters, user) self.new(filters, user)
end end
def self.load_pet_type_by_name(pet_type_string) def self.load_pet_type_by_name(color_name, species_name)
color_name, species_name = pet_type_string.split("-")
begin begin
PetType.matching_name(color_name, species_name).first! PetType.matching_name(color_name, species_name).first!
rescue ActiveRecord::RecordNotFound rescue ActiveRecord::RecordNotFound
@ -171,6 +170,18 @@ class Item
raise Item::Search::Error, message raise Item::Search::Error, message
end end
end end
def self.load_pet_type_by_color_and_species(color_id, species_id)
begin
PetType.where(color_id: color_id, species_id: species_id).first!
rescue ActiveRecord::RecordNotFound
color_name = Color.find(color_id).name rescue "Color #{color_id}"
species_name = Species.find(species_id).name rescue "Species #{species_id}"
message = I18n.translate('items.search.errors.not_found.pet_type',
name1: color_name.capitalize, name2: species_name.capitalize)
raise Item::Search::Error, message
end
end
end end
class Error < Exception class Error < Exception
@ -222,11 +233,15 @@ class Item
self.new Item.not_restricts(value), "-restricts:#{q value}" self.new Item.not_restricts(value), "-restricts:#{q value}"
end end
def self.fits(body_id, value) def self.fits(body_id, color_name, species_name)
# NOTE: Some color syntaxes are weird, like `fits:"polka dot-aisha"`!
value = "#{color_name}-#{species_name}".downcase
self.new Item.fits(body_id), "fits:#{q value}" self.new Item.fits(body_id), "fits:#{q value}"
end end
def self.not_fits(body_id, value) def self.not_fits(body_id, color_name, species_name)
# NOTE: Some color syntaxes are weird, like `fits:"polka dot-aisha"`!
value = "#{color_name}-#{species_name}".downcase
self.new Item.not_fits(body_id), "-fits:#{q value}" self.new Item.not_fits(body_id), "-fits:#{q value}"
end end

View file

@ -180,16 +180,19 @@ class PetType < ApplicationRecord
pet_type_body = Item::Appearance::Body.new(body_id, species) pet_type_body = Item::Appearance::Body.new(body_id, species)
all_pets_body = Item::Appearance::Body.new(0, nil) all_pets_body = Item::Appearance::Body.new(0, nil)
# Then, convert this into a hash from item ID to appearances. # Then, convert this into a hash from item ID to SWF assets.
relationships.group_by(&:parent_id). assets_by_item_id = relationships.group_by(&:parent_id).
transform_values do |rels| transform_values { |rels| rels.map(&:swf_asset) }
assets = rels.map(&:swf_asset)
if assets.all? { |a| a.body_id == 0 } # Finally, for each item, return an appearance—even if it's empty!
Item::Appearance.new all_pets_body, assets item_ids.to_h do |item_id|
else assets = assets_by_item_id.fetch(item_id, [])
Item::Appearance.new pet_type_body, assets
end fits_all_pets = assets.present? && assets.all? { |a| a.body_id == 0 }
end body = fits_all_pets ? all_pets_body : pet_type_body
[item_id, Item::Appearance.new(body, assets)]
end
end end
def self.all_by_ids_or_children(ids, pet_states) def self.all_by_ids_or_children(ids, pet_states)