From a8cbce08645e1f0920558ee202593864e232d65f Mon Sep 17 00:00:00 2001 From: Emi Matchu Date: Sun, 25 Feb 2024 14:46:27 -0800 Subject: [PATCH] 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! --- app/controllers/items_controller.rb | 14 +-- .../WardrobePage/useSearchResults.js | 90 ++++++++++++++++--- app/javascript/wardrobe-2020/loaders/items.js | 44 ++++++--- .../wardrobe-2020/loaders/shared-types.js | 34 +++---- app/models/item/search/query.rb | 47 ++++++---- app/models/pet_type.rb | 23 ++--- 6 files changed, 184 insertions(+), 68 deletions(-) diff --git a/app/controllers/items_controller.rb b/app/controllers/items_controller.rb index e640dc9c..3fe99d9c 100644 --- a/app/controllers/items_controller.rb +++ b/app/controllers/items_controller.rb @@ -38,14 +38,17 @@ class ItemsController < ApplicationController zone: { only: [:id, :depth, :label], methods: [:is_commonly_used_by_items], - } + }, + restricted_zones: { + only: [:id, :depth, :label], + methods: [:is_commonly_used_by_items], + }, }, methods: [:urls, :known_glitches], }, } ), total_pages: @items.total_pages, - total_count: @items.count, query: @query.to_s, } } @@ -118,10 +121,11 @@ class ItemsController < ApplicationController end def load_appearances - pet_type_name = params[:with_appearances_for] - return {} if pet_type_name.blank? + appearance_params = params[:with_appearances_for] + 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]) end diff --git a/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js b/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js index 0bd63e16..b67c589d 100644 --- a/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js +++ b/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js @@ -1,7 +1,9 @@ +import React from "react"; import gql from "graphql-tag"; import { useQuery } from "@apollo/client"; -import { useDebounce } from "../util"; -import { emptySearchQuery } from "./SearchToolbar"; +import { useDebounce, useLocalStorage } from "../util"; +import { useItemSearch } from "../loaders/items"; +import { emptySearchQuery, searchQueryIsEmpty } from "./SearchToolbar"; import { itemAppearanceFragment } from "../components/useOutfitAppearance"; import { SEARCH_PER_PAGE } from "./SearchPanel"; @@ -41,14 +43,20 @@ export function useSearchResults( const currentPageIndex = currentPageNumber - 1; const offset = currentPageIndex * SEARCH_PER_PAGE; - // const filters = buildSearchFilters(/* TODO */); - // const { loading, error, data } = useItemSearch(filters); + // Until the new item search is ready, we can toggle between them! Use + // `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 { loading: loadingGQL, - error, - data, + error: errorGQL, + data: dataGQL, } = useQuery( gql` query SearchPanel( @@ -125,6 +133,7 @@ export function useSearchResults( context: { sendAuth: true }, skip: skip || + queryMode !== "gql" || (!debouncedQuery.value && !debouncedQuery.filterToItemKind && !debouncedQuery.filterToZoneLabel && @@ -137,10 +146,69 @@ export function useSearchResults( }, ); - const loading = debouncedQuery !== query || loadingGQL; - const items = data?.itemSearch?.items ?? []; - const numTotalItems = data?.itemSearch?.numTotalItems ?? null; - const numTotalPages = Math.ceil(numTotalItems / SEARCH_PER_PAGE); + const { + isLoading: loadingQuery, + error: errorQuery, + 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 }; } + +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; +} diff --git a/app/javascript/wardrobe-2020/loaders/items.js b/app/javascript/wardrobe-2020/loaders/items.js index ffc3a7f3..36ac315f 100644 --- a/app/javascript/wardrobe-2020/loaders/items.js +++ b/app/javascript/wardrobe-2020/loaders/items.js @@ -1,6 +1,6 @@ import { useQuery } from "@tanstack/react-query"; -import { normalizeSwfAssetToLayer } from "./shared-types"; +import { normalizeSwfAssetToLayer, normalizeZone } from "./shared-types"; export function useItemAppearances(id, options = {}) { return useQuery({ @@ -25,10 +25,19 @@ async function loadItemAppearancesData(id) { } 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({ ...queryOptions, queryKey: ["itemSearch", buildItemSearchParams(searchOptions)], queryFn: () => loadItemSearch(searchOptions), + staleTime, }); } @@ -39,15 +48,28 @@ function buildItemSearchParams({ perPage = 30, }) { const params = new URLSearchParams(); - for (const [i, filter] of filters.entries()) { - params.append(`q[${i}][key]`, filter.key); - params.append(`q[${i}][value]`, filter.value); - if (params.isPositive == false) { + for (const [i, { key, value, isPositive }] of filters.entries()) { + params.append(`q[${i}][key]`, key); + if (key === "fits") { + 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"); } } 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("per_page", perPage); @@ -85,7 +107,7 @@ function normalizeItemAppearancesData(data) { function normalizeItemSearchData(data, searchOptions) { return { id: buildItemSearchParams(searchOptions), - numTotalItems: data.total_count, + numTotalPages: data.total_pages, items: data.items.map((item) => ({ id: String(item.id), name: item.name, @@ -110,6 +132,10 @@ function normalizeItemSearchAppearance(data, item) { return { id: `item-${item.id}-body-${data.body.id}`, 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 }; -} diff --git a/app/javascript/wardrobe-2020/loaders/shared-types.js b/app/javascript/wardrobe-2020/loaders/shared-types.js index 55d585e9..9558e2f3 100644 --- a/app/javascript/wardrobe-2020/loaders/shared-types.js +++ b/app/javascript/wardrobe-2020/loaders/shared-types.js @@ -1,19 +1,23 @@ -export function normalizeSwfAssetToLayer(swfAssetData) { +export function normalizeSwfAssetToLayer(data) { return { - id: String(swfAssetData.id), - remoteId: String(swfAssetData.remote_id), - zone: { - id: String(swfAssetData.zone.id), - depth: swfAssetData.zone.depth, - label: swfAssetData.zone.label, - isCommonlyUsedByItems: swfAssetData.zone.is_commonly_used_by_items, - }, - bodyId: swfAssetData.body_id, - knownGlitches: swfAssetData.known_glitches, + id: String(data.id), + remoteId: String(data.remote_id), + zone: normalizeZone(data.zone), + bodyId: data.body_id, + knownGlitches: data.known_glitches, - svgUrl: swfAssetData.urls.svg, - canvasMovieLibraryUrl: swfAssetData.urls.canvas_library, - imageUrl: swfAssetData.urls.png, - swfUrl: swfAssetData.urls.swf, + svgUrl: data.urls.svg, + canvasMovieLibraryUrl: data.urls.canvas_library, + imageUrl: data.urls.png, + 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, }; } diff --git a/app/models/item/search/query.rb b/app/models/item/search/query.rb index 203679ec..cb48b4f6 100644 --- a/app/models/item/search/query.rb +++ b/app/models/item/search/query.rb @@ -46,10 +46,11 @@ class Item Filter.restricts(value) : Filter.not_restricts(value)) 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 ? - Filter.fits(pet_type.body_id, value.downcase) : - Filter.not_fits(pet_type.body_id, value.downcase)) + Filter.fits(pet_type.body_id, color_name, species_name) : + Filter.not_fits(pet_type.body_id, color_name, species_name)) when 'species' begin species = Species.find_by_name!(value) @@ -132,15 +133,15 @@ class Item filters << (is_positive ? Filter.restricts(value, locale) : Filter.not_restricts(value, locale)) - when 'fits_pet_type' - pet_type = PetType.find(value) - color_name = pet_type.color.name - species_name = pet_type.species.name - # NOTE: Some color syntaxes are weird, like `fits:"polka dot-aisha"`! - value = "#{color_name}-#{species_name}" + when 'fits' + raise NotImplementedError if value[:alt_style_id].present? + pet_type = load_pet_type_by_color_and_species( + value[:color_id], value[:species_id]) + color = Color.find value[:color_id] + species = Species.find value[:species_id] filters << (is_positive ? - Filter.fits(pet_type.body_id, value) : - Filter.not_fits(pet_type.body_id, value)) + Filter.fits(pet_type.body_id, color.name, species.name) : + Filter.not_fits(pet_type.body_id, color.name, species.name)) when 'user_closet_hanger_ownership' case value when 'true' @@ -160,9 +161,7 @@ class Item self.new(filters, user) end - def self.load_pet_type_by_name(pet_type_string) - color_name, species_name = pet_type_string.split("-") - + def self.load_pet_type_by_name(color_name, species_name) begin PetType.matching_name(color_name, species_name).first! rescue ActiveRecord::RecordNotFound @@ -171,6 +170,18 @@ class Item raise Item::Search::Error, message 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 class Error < Exception @@ -222,11 +233,15 @@ class Item self.new Item.not_restricts(value), "-restricts:#{q value}" 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}" 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}" end diff --git a/app/models/pet_type.rb b/app/models/pet_type.rb index 54686fec..49230af0 100644 --- a/app/models/pet_type.rb +++ b/app/models/pet_type.rb @@ -180,16 +180,19 @@ class PetType < ApplicationRecord pet_type_body = Item::Appearance::Body.new(body_id, species) all_pets_body = Item::Appearance::Body.new(0, nil) - # Then, convert this into a hash from item ID to appearances. - relationships.group_by(&:parent_id). - transform_values do |rels| - assets = rels.map(&:swf_asset) - if assets.all? { |a| a.body_id == 0 } - Item::Appearance.new all_pets_body, assets - else - Item::Appearance.new pet_type_body, assets - end - end + # Then, convert this into a hash from item ID to SWF assets. + assets_by_item_id = relationships.group_by(&:parent_id). + transform_values { |rels| rels.map(&:swf_asset) } + + # Finally, for each item, return an appearance—even if it's empty! + item_ids.to_h do |item_id| + assets = assets_by_item_id.fetch(item_id, []) + + fits_all_pets = assets.present? && assets.all? { |a| a.body_id == 0 } + body = fits_all_pets ? all_pets_body : pet_type_body + + [item_id, Item::Appearance.new(body, assets)] + end end def self.all_by_ids_or_children(ids, pet_states)