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!
This commit is contained in:
Emi Matchu 2024-02-25 12:06:20 -08:00
parent ab1fade529
commit a3dcaa0f0e
8 changed files with 166 additions and 40 deletions

View file

@ -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)

View file

@ -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,

View file

@ -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,
};
}

View file

@ -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" };

View file

@ -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,
};
}

View file

@ -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

View file

@ -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)

View file

@ -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