Compare commits

..

No commits in common. "e5bf6d6ba17bca0733980cc877d6738772c7b85f" and "e3d46ea5d02a9dba86744b8d3558d3ad9a2e4fbd" have entirely different histories.

10 changed files with 95 additions and 340 deletions

View file

@ -30,24 +30,7 @@ class ItemsController < ApplicationController
items: @items.as_json( items: @items.as_json(
methods: [:nc?, :pb?, :owned?, :wanted?], methods: [:nc?, :pb?, :owned?, :wanted?],
), ),
appearances: load_appearances.as_json( appearances: load_appearances,
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, total_pages: @items.total_pages,
query: @query.to_s, query: @query.to_s,
} }
@ -121,12 +104,11 @@ class ItemsController < ApplicationController
end end
def load_appearances def load_appearances
appearance_params = params[:with_appearances_for] pet_type_name = params[:with_appearances_for]
return {} if appearance_params.blank? return {} if pet_type_name.blank?
pet_type = Item::Search::Query.load_pet_type_by_color_and_species( pet_type = Item::Search::Query.load_pet_type_by_name(pet_type_name)
appearance_params[:color_id], appearance_params[:species_id]) pet_type.appearances_for(@items.map(&:id))
pet_type.appearances_for(@items.map(&:id), swf_asset_includes: [:zone])
end end
def search_error(e) def search_error(e)

View file

@ -1,9 +1,7 @@
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, useLocalStorage } from "../util"; import { useDebounce } from "../util";
import { useItemSearch } from "../loaders/items"; import { emptySearchQuery } from "./SearchToolbar";
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";
@ -43,20 +41,11 @@ export function useSearchResults(
const currentPageIndex = currentPageNumber - 1; const currentPageIndex = currentPageNumber - 1;
const offset = currentPageIndex * SEARCH_PER_PAGE; const offset = currentPageIndex * SEARCH_PER_PAGE;
// Until the new item search is ready, we can toggle between them! Use // Here's the actual GQL query! At the bottom we have more config than usual!
// `setItemSearchQueryMode` in the JS console to choose "gql" or "new".
const [queryMode, setQueryMode] = useLocalStorage(
"DTIItemSearchQueryMode",
"gql",
);
React.useEffect(() => {
window.setItemSearchQueryMode = setQueryMode;
}, [setQueryMode]);
const { const {
loading: loadingGQL, loading: loadingGQL,
error: errorGQL, error,
data: dataGQL, data,
} = useQuery( } = useQuery(
gql` gql`
query SearchPanel( query SearchPanel(
@ -133,7 +122,6 @@ 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 &&
@ -146,69 +134,10 @@ export function useSearchResults(
}, },
); );
const { const loading = debouncedQuery !== query || loadingGQL;
isLoading: loadingQuery, const items = data?.itemSearch?.items ?? [];
error: errorQuery, const numTotalItems = data?.itemSearch?.numTotalItems ?? null;
data: dataQuery, const numTotalPages = Math.ceil(numTotalItems / SEARCH_PER_PAGE);
} = 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,7 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { normalizeSwfAssetToLayer } from "./shared-types";
export function useAltStylesForSpecies(speciesId, options = {}) { export function useAltStylesForSpecies(speciesId, options = {}) {
return useQuery({ return useQuery({
...options, ...options,
@ -65,3 +63,21 @@ 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,7 +1,5 @@
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { normalizeSwfAssetToLayer, normalizeZone } from "./shared-types";
export function useItemAppearances(id, options = {}) { export function useItemAppearances(id, options = {}) {
return useQuery({ return useQuery({
...options, ...options,
@ -11,9 +9,7 @@ export function useItemAppearances(id, options = {}) {
} }
async function loadItemAppearancesData(id) { async function loadItemAppearancesData(id) {
const res = await fetch( const res = await fetch(`/items/${encodeURIComponent(id)}/appearances.json`);
`/items/${encodeURIComponent(id)}/appearances.json`,
);
if (!res.ok) { if (!res.ok) {
throw new Error( throw new Error(
@ -24,121 +20,27 @@ async function loadItemAppearancesData(id) {
return res.json().then(normalizeItemAppearancesData); 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) { function normalizeItemAppearancesData(data) {
return { return {
name: data.name, name: data.name,
appearances: data.appearances.map((appearance) => ({ appearances: data.appearances.map((appearance) => ({
body: normalizeBody(appearance.body), body: normalizeBody(appearance.body),
swfAssets: appearance.swf_assets.map(normalizeSwfAssetToLayer), 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,
},
})),
})), })),
restrictedZones: data.restricted_zones.map((z) => normalizeZone(z)), 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) { function normalizeBody(body) {
if (String(body.id) === "0") { if (String(body.id) === "0") {
return { id: "0" }; return { id: "0" };
@ -153,3 +55,7 @@ function normalizeBody(body) {
}, },
}; };
} }
function normalizeZone(zone) {
return { id: String(zone.id), label: zone.label, depth: zone.depth };
}

View file

@ -1,23 +0,0 @@
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,26 +29,20 @@ class Item < ApplicationRecord
scope :with_closet_hangers, -> { joins(:closet_hangers) } scope :with_closet_hangers, -> { joins(:closet_hangers) }
scope :name_includes, ->(value) { scope :name_includes, ->(value, locale = I18n.locale) {
Item.where("name LIKE ?", "%" + sanitize_sql_like(value) + "%") Item.where("name LIKE ?", "%" + sanitize_sql_like(value) + "%")
} }
scope :name_excludes, ->(value) { scope :name_excludes, ->(value, locale = I18n.locale) {
Item.where("name NOT LIKE ?", "%" + sanitize_sql_like(value) + "%") Item.where("name NOT LIKE ?", "%" + sanitize_sql_like(value) + "%")
} }
scope :is_nc, -> { scope :is_nc, -> {
i = Item.arel_table i = Item.arel_table
where(i[:rarity_index].in(Item::NCRarities).or(i[:is_manually_nc].eq(true))) where(i[:rarity_index].in(Item::NCRarities).or(i[:is_manually_nc].eq(true)))
} }
scope :is_not_nc, -> { scope :is_np, -> {
i = Item.arel_table i = Item.arel_table
where(i[:rarity_index].in(Item::NCRarities).or(i[:is_manually_nc].eq(true)).not) 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, -> { scope :is_pb, -> {
where('description LIKE ?', where('description LIKE ?',
'%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%') '%' + sanitize_sql_like(PAINTBRUSH_SET_DESCRIPTION) + '%')
@ -87,7 +81,8 @@ class Item < ApplicationRecord
where("NOT (#{condition})", *zone_ids) where("NOT (#{condition})", *zone_ids)
} }
scope :fits, ->(body_id) { scope :fits, ->(body_id) {
joins(:swf_assets).where(swf_assets: {body_id: [body_id, 0]}).distinct sa = SwfAsset.arel_table
joins(:swf_assets).where(sa[:body_id].eq(body_id)).distinct
} }
scope :not_fits, ->(body_id) { scope :not_fits, ->(body_id) {
i = Item.arel_table i = Item.arel_table
@ -450,23 +445,8 @@ class Item < ApplicationRecord
@parent_swf_asset_relationships_to_update = rels @parent_swf_asset_relationships_to_update = rels
end end
# NOTE: Adding the JSON serializer makes `as_json` treat this like a model Appearance = Struct.new(:body, :swf_assets)
# instead of like a hash, so you can target its children with things like Appearance::Body = Struct.new(:id, :species)
# 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 def appearances
all_swf_assets = swf_assets.to_a all_swf_assets = swf_assets.to_a

View file

@ -18,6 +18,11 @@ class Item
@text || @filters.map(&:to_s).join(' ') @text || @filters.map(&:to_s).join(' ')
end end
def self.locale
(I18n.fallbacks[I18n.locale] &
I18n.locales_with_neopets_language_code).first
end
TEXT_FILTER_EXPR = /([+-]?)(?:(\p{Word}+):)?(?:"([^"]+)"|(\S+))/ TEXT_FILTER_EXPR = /([+-]?)(?:(\p{Word}+):)?(?:"([^"]+)"|(\S+))/
def self.from_text(text, user=nil) def self.from_text(text, user=nil)
filters = [] filters = []
@ -30,8 +35,8 @@ class Item
case key case key
when 'name' when 'name'
filters << (is_positive ? filters << (is_positive ?
Filter.name_includes(value) : Filter.name_includes(value, locale) :
Filter.name_excludes(value)) Filter.name_excludes(value, locale))
when 'occupies' when 'occupies'
filters << (is_positive ? filters << (is_positive ?
Filter.occupies(value) : Filter.occupies(value) :
@ -41,11 +46,10 @@ class Item
Filter.restricts(value) : Filter.restricts(value) :
Filter.not_restricts(value)) Filter.not_restricts(value))
when 'fits' when 'fits'
color_name, species_name = value.split("-") pet_type = load_pet_type_by_name(value)
pet_type = load_pet_type_by_name(color_name, species_name)
filters << (is_positive ? filters << (is_positive ?
Filter.fits(pet_type.body_id, color_name, species_name) : Filter.fits(pet_type.body_id, value.downcase) :
Filter.not_fits(pet_type.body_id, color_name, species_name)) Filter.not_fits(pet_type.body_id, value.downcase))
when 'species' when 'species'
begin begin
species = Species.find_by_name!(value) species = Species.find_by_name!(value)
@ -112,30 +116,27 @@ class Item
case filter_params[:key] case filter_params[:key]
when 'name' when 'name'
filters << (is_positive ? filters << (is_positive ?
Filter.name_includes(value) : Filter.name_includes(value, locale) :
Filter.name_excludes(value)) Filter.name_excludes(value, locale))
when 'is_nc' when 'is_nc'
filters << (is_positive ? Filter.is_nc : Filter.is_not_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' when 'occupied_zone_set_name'
filters << (is_positive ? Filter.occupies(value) : filters << (is_positive ?
Filter.not_occupies(value)) Filter.occupies(value, locale) :
Filter.not_occupies(value, locale))
when 'restricted_zone_set_name' when 'restricted_zone_set_name'
filters << (is_positive ? filters << (is_positive ?
Filter.restricts(value) : Filter.restricts(value, locale) :
Filter.not_restricts(value)) Filter.not_restricts(value, locale))
when 'fits' when 'fits_pet_type'
raise NotImplementedError if value[:alt_style_id].present? pet_type = PetType.find(value)
pet_type = load_pet_type_by_color_and_species( color_name = pet_type.color.name
value[:color_id], value[:species_id]) species_name = pet_type.species.name
color = Color.find value[:color_id] # NOTE: Some color syntaxes are weird, like `fits:"polka dot-aisha"`!
species = Species.find value[:species_id] value = "#{color_name}-#{species_name}"
filters << (is_positive ? filters << (is_positive ?
Filter.fits(pet_type.body_id, color.name, species.name) : Filter.fits(pet_type.body_id, value) :
Filter.not_fits(pet_type.body_id, color.name, species.name)) Filter.not_fits(pet_type.body_id, value))
when 'user_closet_hanger_ownership' when 'user_closet_hanger_ownership'
case value case value
when 'true' when 'true'
@ -155,7 +156,9 @@ class Item
self.new(filters, user) self.new(filters, user)
end end
def self.load_pet_type_by_name(color_name, species_name) def self.load_pet_type_by_name(pet_type_string)
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
@ -164,18 +167,6 @@ 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
@ -203,12 +194,12 @@ class Item
"#<#{self.class.name} #{@text.inspect}>" "#<#{self.class.name} #{@text.inspect}>"
end end
def self.name_includes(value) def self.name_includes(value, locale)
self.new Item.name_includes(value), "#{q value}" self.new Item.name_includes(value, locale), "#{q value}"
end end
def self.name_excludes(value) def self.name_excludes(value, locale)
self.new Item.name_excludes(value), "-#{q value}" self.new Item.name_excludes(value, locale), "-#{q value}"
end end
def self.occupies(value) def self.occupies(value)
@ -227,15 +218,11 @@ 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, color_name, species_name) def self.fits(body_id, value)
# 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, color_name, species_name) def self.not_fits(body_id, value)
# 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
@ -268,7 +255,7 @@ class Item
end end
def self.is_not_nc def self.is_not_nc
self.new Item.is_not_nc, '-is:nc' self.new Item.is_np, '-is:nc'
end end
def self.is_np def self.is_np
@ -276,7 +263,7 @@ class Item
end end
def self.is_not_np def self.is_not_np
self.new Item.is_not_np, '-is:np' self.new Item.is_nc, '-is:np'
end end
def self.is_pb def self.is_pb

View file

@ -169,30 +169,16 @@ class PetType < ApplicationRecord
}.first }.first
end end
def appearances_for(item_ids, swf_asset_includes: []) def appearances_for(item_ids)
# First, load all the relationships for these items that also fit this # First, load all the relationships for these items that also fit this
# body. # body.
relationships = ParentSwfAssetRelationship. relationships = ParentSwfAssetRelationship.includes(:swf_asset).
includes(swf_asset: swf_asset_includes).
where(parent_type: "Item", parent_id: item_ids). where(parent_type: "Item", parent_id: item_ids).
where(swf_asset: {body_id: [body_id, 0]}) 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. # Then, convert this into a hash from item ID to SWF assets.
assets_by_item_id = relationships.group_by(&:parent_id). assets_by_item_id = relationships.group_by(&:parent_id).
transform_values { |rels| rels.map(&:swf_asset) } 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 end
def self.all_by_ids_or_children(ids, pet_states) def self.all_by_ids_or_children(ids, pet_states)

View file

@ -17,14 +17,6 @@ class Zone < ActiveRecord::Base
@sometimes ? "#{label} sometimes" : label @sometimes ? "#{label} sometimes" : label
end 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) def self.plainify_label(label)
label.delete('\- /').parameterize label.delete('\- /').parameterize
end end

View file

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