Compare commits

...

6 commits

Author SHA1 Message Date
e5bf6d6ba1 Oops, fix remaining references to locale in item search
This is both unnecessary now, but also caused a bug in the new search
stuff where searching by zone would pass an extra `locale` argument to
a filter that doesn't need it!
2024-02-25 15:00:22 -08:00
61a4dcad02 Oops, "fits:blue-acara" search should return non-body-specific items
Idk when this regressed exactly, but probably people didn't super
notice because I don't think it's a very common thing to type directly
into the Infinite Closet search box! (It used to be crucial to the old
wardrobe app.)

But I'm using it in the wardrobe app again now, so, fixed!
2024-02-25 14:47:37 -08:00
a8cbce0864 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!
2024-02-25 14:46:27 -08:00
52e81557c2 Update search filters to consider NP and PB mutually exclusive
`is:np` now means "is not NC and is not PB".

Note that it might be good to make NC and PB explicitly mutually
exclusive too? It would complicate queries though, and not matter in
most cases… the Burlap Usul Bow is the only item that we currently
return for `is:pb is:nc`, which is probably because of a rarity issue?
2024-02-25 12:57:04 -08:00
a3dcaa0f0e 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!
2024-02-25 12:06:32 -08:00
ab1fade529 Remove unused React dependency on homepage
Oh right, this was for `modeling.jsx`, which is gone now. Bye!
2024-02-25 10:49:00 -08:00
10 changed files with 340 additions and 95 deletions

View file

@ -30,7 +30,24 @@ 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],
},
restricted_zones: {
only: [:id, :depth, :label],
methods: [:is_commonly_used_by_items],
},
},
methods: [:urls, :known_glitches],
},
}
),
total_pages: @items.total_pages,
query: @query.to_s,
}
@ -104,11 +121,12 @@ 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.appearances_for(@items.map(&:id))
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
def search_error(e)

View file

@ -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,11 +43,20 @@ export function useSearchResults(
const currentPageIndex = currentPageNumber - 1;
const offset = currentPageIndex * SEARCH_PER_PAGE;
// Here's the actual GQL query! At the bottom we have more config than usual!
// 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]);
const {
loading: loadingGQL,
error,
data,
error: errorGQL,
data: dataGQL,
} = useQuery(
gql`
query SearchPanel(
@ -122,6 +133,7 @@ export function useSearchResults(
context: { sendAuth: true },
skip:
skip ||
queryMode !== "gql" ||
(!debouncedQuery.value &&
!debouncedQuery.filterToItemKind &&
!debouncedQuery.filterToZoneLabel &&
@ -134,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;
}

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, normalizeZone } 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,121 @@ async function loadItemAppearancesData(id) {
return res.json().then(normalizeItemAppearancesData);
}
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,
});
}
function buildItemSearchParams({
filters = [],
withAppearancesFor = null,
page = 1,
perPage = 30,
}) {
const params = new URLSearchParams();
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) {
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);
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),
numTotalPages: data.total_pages,
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),
restrictedZones: data.swf_assets
.map((a) => a.restricted_zones)
.flat()
.map(normalizeZone),
};
}
function normalizeBody(body) {
if (String(body.id) === "0") {
return { id: "0" };
@ -55,7 +153,3 @@ function normalizeBody(body) {
},
};
}
function normalizeZone(zone) {
return { id: String(zone.id), label: zone.label, depth: zone.depth };
}

View file

@ -0,0 +1,23 @@
export function normalizeSwfAssetToLayer(data) {
return {
id: String(data.id),
remoteId: String(data.remote_id),
zone: normalizeZone(data.zone),
bodyId: data.body_id,
knownGlitches: data.known_glitches,
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,
};
}

View file

@ -29,20 +29,26 @@ class Item < ApplicationRecord
scope :with_closet_hangers, -> { joins(:closet_hangers) }
scope :name_includes, ->(value, locale = I18n.locale) {
scope :name_includes, ->(value) {
Item.where("name LIKE ?", "%" + sanitize_sql_like(value) + "%")
}
scope :name_excludes, ->(value, locale = I18n.locale) {
scope :name_excludes, ->(value) {
Item.where("name NOT LIKE ?", "%" + sanitize_sql_like(value) + "%")
}
scope :is_nc, -> {
i = Item.arel_table
where(i[:rarity_index].in(Item::NCRarities).or(i[:is_manually_nc].eq(true)))
}
scope :is_np, -> {
scope :is_not_nc, -> {
i = Item.arel_table
where(i[:rarity_index].in(Item::NCRarities).or(i[:is_manually_nc].eq(true)).not)
}
scope :is_np, -> {
self.is_not_nc.is_not_pb
}
scope :is_not_np, -> {
self.merge Item.is_nc.or(Item.is_pb)
}
scope :is_pb, -> {
where('description LIKE ?',
'%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%')
@ -81,8 +87,7 @@ class Item < ApplicationRecord
where("NOT (#{condition})", *zone_ids)
}
scope :fits, ->(body_id) {
sa = SwfAsset.arel_table
joins(:swf_assets).where(sa[:body_id].eq(body_id)).distinct
joins(:swf_assets).where(swf_assets: {body_id: [body_id, 0]}).distinct
}
scope :not_fits, ->(body_id) {
i = Item.arel_table
@ -445,8 +450,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

@ -17,11 +17,6 @@ class Item
def to_s
@text || @filters.map(&:to_s).join(' ')
end
def self.locale
(I18n.fallbacks[I18n.locale] &
I18n.locales_with_neopets_language_code).first
end
TEXT_FILTER_EXPR = /([+-]?)(?:(\p{Word}+):)?(?:"([^"]+)"|(\S+))/
def self.from_text(text, user=nil)
@ -35,8 +30,8 @@ class Item
case key
when 'name'
filters << (is_positive ?
Filter.name_includes(value, locale) :
Filter.name_excludes(value, locale))
Filter.name_includes(value) :
Filter.name_excludes(value))
when 'occupies'
filters << (is_positive ?
Filter.occupies(value) :
@ -46,10 +41,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)
@ -116,27 +112,30 @@ class Item
case filter_params[:key]
when 'name'
filters << (is_positive ?
Filter.name_includes(value, locale) :
Filter.name_excludes(value, locale))
Filter.name_includes(value) :
Filter.name_excludes(value))
when 'is_nc'
filters << (is_positive ? Filter.is_nc : Filter.is_not_nc)
when 'is_pb'
filters << (is_positive ? Filter.is_pb : Filter.is_not_pb)
when 'is_np'
filters << (is_positive ? Filter.is_np : Filter.is_not_np)
when 'occupied_zone_set_name'
filters << (is_positive ?
Filter.occupies(value, locale) :
Filter.not_occupies(value, locale))
filters << (is_positive ? Filter.occupies(value) :
Filter.not_occupies(value))
when 'restricted_zone_set_name'
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}"
Filter.restricts(value) :
Filter.not_restricts(value))
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'
@ -156,9 +155,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
@ -167,6 +164,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
@ -194,12 +203,12 @@ class Item
"#<#{self.class.name} #{@text.inspect}>"
end
def self.name_includes(value, locale)
self.new Item.name_includes(value, locale), "#{q value}"
def self.name_includes(value)
self.new Item.name_includes(value), "#{q value}"
end
def self.name_excludes(value, locale)
self.new Item.name_excludes(value, locale), "-#{q value}"
def self.name_excludes(value)
self.new Item.name_excludes(value), "-#{q value}"
end
def self.occupies(value)
@ -218,11 +227,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
@ -255,7 +268,7 @@ class Item
end
def self.is_not_nc
self.new Item.is_np, '-is:nc'
self.new Item.is_not_nc, '-is:nc'
end
def self.is_np
@ -263,7 +276,7 @@ class Item
end
def self.is_not_np
self.new Item.is_nc, '-is:np'
self.new Item.is_not_np, '-is:np'
end
def self.is_pb

View file

@ -169,16 +169,30 @@ 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]})
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 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)

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

View file

@ -110,4 +110,4 @@
- content_for :javascripts do
= include_javascript_libraries :jquery20, :jquery_tmpl
= javascript_include_tag 'ajax_auth', 'lib/react', 'lib/jquery.timeago', 'outfits/new'
= javascript_include_tag 'ajax_auth', 'lib/jquery.timeago', 'outfits/new'