From a3dcaa0f0e4fda569a5633cf820974dd41aeb5f8 Mon Sep 17 00:00:00 2001 From: Emi Matchu Date: Sun, 25 Feb 2024 12:06:20 -0800 Subject: [PATCH] Add `useItemSearch` for wardrobe app, but don't use it yet! Adding new functionality to the item search JSON endpoint, and adding an adapter layer to match the GQL format! Hopefully this will be pretty drop-in-able, we'll see! --- app/controllers/items_controller.rb | 18 +++- .../WardrobePage/useSearchResults.js | 3 + .../wardrobe-2020/loaders/alt-styles.js | 20 +--- app/javascript/wardrobe-2020/loaders/items.js | 96 ++++++++++++++++--- .../wardrobe-2020/loaders/shared-types.js | 19 ++++ app/models/item.rb | 19 +++- app/models/pet_type.rb | 21 +++- app/models/zone.rb | 10 +- 8 files changed, 166 insertions(+), 40 deletions(-) create mode 100644 app/javascript/wardrobe-2020/loaders/shared-types.js diff --git a/app/controllers/items_controller.rb b/app/controllers/items_controller.rb index c2e2d6ee..e640dc9c 100644 --- a/app/controllers/items_controller.rb +++ b/app/controllers/items_controller.rb @@ -30,8 +30,22 @@ class ItemsController < ApplicationController items: @items.as_json( methods: [:nc?, :pb?, :owned?, :wanted?], ), - appearances: load_appearances, + appearances: load_appearances.as_json( + include: { + swf_assets: { + only: [:id, :remote_id, :body_id], + include: { + zone: { + 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, } } @@ -108,7 +122,7 @@ class ItemsController < ApplicationController return {} if pet_type_name.blank? pet_type = Item::Search::Query.load_pet_type_by_name(pet_type_name) - pet_type.appearances_for(@items.map(&:id)) + pet_type.appearances_for(@items.map(&:id), swf_asset_includes: [:zone]) end def search_error(e) diff --git a/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js b/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js index 0c52b7cf..0bd63e16 100644 --- a/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js +++ b/app/javascript/wardrobe-2020/WardrobePage/useSearchResults.js @@ -41,6 +41,9 @@ export function useSearchResults( const currentPageIndex = currentPageNumber - 1; const offset = currentPageIndex * SEARCH_PER_PAGE; + // const filters = buildSearchFilters(/* TODO */); + // const { loading, error, data } = useItemSearch(filters); + // Here's the actual GQL query! At the bottom we have more config than usual! const { loading: loadingGQL, diff --git a/app/javascript/wardrobe-2020/loaders/alt-styles.js b/app/javascript/wardrobe-2020/loaders/alt-styles.js index a66f81db..4b761bbe 100644 --- a/app/javascript/wardrobe-2020/loaders/alt-styles.js +++ b/app/javascript/wardrobe-2020/loaders/alt-styles.js @@ -1,5 +1,7 @@ import { useQuery } from "@tanstack/react-query"; +import { normalizeSwfAssetToLayer } from "./shared-types"; + export function useAltStylesForSpecies(speciesId, options = {}) { return useQuery({ ...options, @@ -63,21 +65,3 @@ function normalizeAltStyle(altStyleData) { }, }; } - -function normalizeSwfAssetToLayer(swfAssetData) { - return { - id: String(swfAssetData.id), - zone: { - id: String(swfAssetData.zone.id), - depth: swfAssetData.zone.depth, - label: swfAssetData.zone.label, - }, - bodyId: swfAssetData.body_id, - knownGlitches: swfAssetData.known_glitches, - - svgUrl: swfAssetData.urls.svg, - canvasMovieLibraryUrl: swfAssetData.urls.canvas_library, - imageUrl: swfAssetData.urls.png, - swfUrl: swfAssetData.urls.swf, - }; -} diff --git a/app/javascript/wardrobe-2020/loaders/items.js b/app/javascript/wardrobe-2020/loaders/items.js index 8b193c2c..ffc3a7f3 100644 --- a/app/javascript/wardrobe-2020/loaders/items.js +++ b/app/javascript/wardrobe-2020/loaders/items.js @@ -1,5 +1,7 @@ import { useQuery } from "@tanstack/react-query"; +import { normalizeSwfAssetToLayer } from "./shared-types"; + export function useItemAppearances(id, options = {}) { return useQuery({ ...options, @@ -9,7 +11,9 @@ export function useItemAppearances(id, options = {}) { } async function loadItemAppearancesData(id) { - const res = await fetch(`/items/${encodeURIComponent(id)}/appearances.json`); + const res = await fetch( + `/items/${encodeURIComponent(id)}/appearances.json`, + ); if (!res.ok) { throw new Error( @@ -20,27 +24,95 @@ async function loadItemAppearancesData(id) { return res.json().then(normalizeItemAppearancesData); } +export function useItemSearch(searchOptions, queryOptions = {}) { + return useQuery({ + ...queryOptions, + queryKey: ["itemSearch", buildItemSearchParams(searchOptions)], + queryFn: () => loadItemSearch(searchOptions), + }); +} + +function buildItemSearchParams({ + filters = [], + withAppearancesFor = null, + page = 1, + 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) { + params.append(`q[${i}][is_positive]`, "false"); + } + } + if (withAppearancesFor != null) { + params.append("with_appearances_for", withAppearancesFor); + } + params.append("page", page); + params.append("per_page", perPage); + return params.toString(); +} + +async function loadItemSearch(searchOptions) { + const params = buildItemSearchParams(searchOptions); + + const res = await fetch(`/items.json?${params}`); + + if (!res.ok) { + throw new Error( + `loading item search failed: ${res.status} ${res.statusText}`, + ); + } + + return res + .json() + .then((data) => normalizeItemSearchData(data, searchOptions)); +} +window.loadItemSearch = loadItemSearch; + function normalizeItemAppearancesData(data) { return { name: data.name, appearances: data.appearances.map((appearance) => ({ body: normalizeBody(appearance.body), - swfAssets: appearance.swf_assets.map((asset) => ({ - id: String(asset.id), - knownGlitches: asset.known_glitches, - zone: normalizeZone(asset.zone), - restrictedZones: asset.restricted_zones.map((z) => normalizeZone(z)), - urls: { - swf: asset.urls.swf, - png: asset.urls.png, - manifest: asset.urls.manifest, - }, - })), + swfAssets: appearance.swf_assets.map(normalizeSwfAssetToLayer), })), restrictedZones: data.restricted_zones.map((z) => normalizeZone(z)), }; } +function normalizeItemSearchData(data, searchOptions) { + return { + id: buildItemSearchParams(searchOptions), + numTotalItems: data.total_count, + items: data.items.map((item) => ({ + id: String(item.id), + name: item.name, + thumbnailUrl: item.thumbnail_url, + isNc: item["nc?"], + isPb: item["pb?"], + currentUserOwnsThis: item["owned?"], + currentUserWantsThis: item["wanted?"], + appearanceOn: normalizeItemSearchAppearance( + data.appearances[item.id], + item, + ), + })), + }; +} + +function normalizeItemSearchAppearance(data, item) { + if (data == null) { + return null; + } + + return { + id: `item-${item.id}-body-${data.body.id}`, + layers: data.swf_assets.map(normalizeSwfAssetToLayer), + }; +} + function normalizeBody(body) { if (String(body.id) === "0") { return { id: "0" }; diff --git a/app/javascript/wardrobe-2020/loaders/shared-types.js b/app/javascript/wardrobe-2020/loaders/shared-types.js new file mode 100644 index 00000000..55d585e9 --- /dev/null +++ b/app/javascript/wardrobe-2020/loaders/shared-types.js @@ -0,0 +1,19 @@ +export function normalizeSwfAssetToLayer(swfAssetData) { + 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, + + svgUrl: swfAssetData.urls.svg, + canvasMovieLibraryUrl: swfAssetData.urls.canvas_library, + imageUrl: swfAssetData.urls.png, + swfUrl: swfAssetData.urls.swf, + }; +} diff --git a/app/models/item.rb b/app/models/item.rb index 772e04b0..ea223afc 100644 --- a/app/models/item.rb +++ b/app/models/item.rb @@ -445,8 +445,23 @@ class Item < ApplicationRecord @parent_swf_asset_relationships_to_update = rels end - Appearance = Struct.new(:body, :swf_assets) - Appearance::Body = Struct.new(:id, :species) + # NOTE: Adding the JSON serializer makes `as_json` treat this like a model + # instead of like a hash, so you can target its children with things like + # the `include` option. This feels clunky though, I wish I had something a + # bit more suited to it! + Appearance = Struct.new(:body, :swf_assets) do + include ActiveModel::Serializers::JSON + def attributes + {body: body, swf_assets: swf_assets} + end + end + Appearance::Body = Struct.new(:id, :species) do + include ActiveModel::Serializers::JSON + def attributes + {id: id, species: species} + end + end + def appearances all_swf_assets = swf_assets.to_a diff --git a/app/models/pet_type.rb b/app/models/pet_type.rb index 361b05f2..54686fec 100644 --- a/app/models/pet_type.rb +++ b/app/models/pet_type.rb @@ -169,16 +169,27 @@ class PetType < ApplicationRecord }.first end - def appearances_for(item_ids) + def appearances_for(item_ids, swf_asset_includes: []) # First, load all the relationships for these items that also fit this # body. - relationships = ParentSwfAssetRelationship.includes(:swf_asset). + relationships = ParentSwfAssetRelationship. + includes(swf_asset: swf_asset_includes). where(parent_type: "Item", parent_id: item_ids). where(swf_asset: {body_id: [body_id, 0]}) - # 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) } + 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 end def self.all_by_ids_or_children(ids, pet_states) diff --git a/app/models/zone.rb b/app/models/zone.rb index 602f80f4..92e6ad93 100644 --- a/app/models/zone.rb +++ b/app/models/zone.rb @@ -1,4 +1,4 @@ -class Zone < ActiveRecord::Base +class Zone < ActiveRecord::Base # When selecting zones that an asset occupies, we allow the zone to set # whether or not the zone is "sometimes" occupied. This is false by default. attr_writer :sometimes @@ -16,6 +16,14 @@ class Zone < ActiveRecord::Base def uncertain_label @sometimes ? "#{label} sometimes" : label end + + def is_commonly_used_by_items + # Zone metadata marks item zones with types 2, 3, and 4. But also, in + # practice, the Biology Effects zone (type 1, ID 4) has been used for a few + # items too. So, that's what we return true for! + # TODO: It'd probably be better to make this a database field? + [2, 3, 4].include?(type_id) || id == 4 + end def self.plainify_label(label) label.delete('\- /').parameterize