Use the main app for outfit saving, not impress-2020

This came in a few parts!
1. Add meta tags to let us know we're logged in.
2. Install React Query, which has the data-loading sensibilities I like
   about Apollo without the GraphQL that has honestly been a drag.
3. Replace the outfit-loading and outfit-saving calls with API calls to
   the main app.
4. Update the main app's API calls to use our more flexible data
   constructs like "pose".

Would've loved to do this more incrementally, but it's hard to! You
can't split out outfit-loading and outfit-saving, or auth from any of
that, or the state gets all out-of-sorts.

Still, this is a good nugget we've pulled out all-in-all, and one that
people have been asking for! Can maybe look to logged-in item search
soon too, for own/want data?
This commit is contained in:
Emi Matchu 2023-11-02 13:50:33 -07:00
parent 52e7456987
commit 7a3aa609ba
12 changed files with 331 additions and 290 deletions

View file

@ -2,7 +2,9 @@ class OutfitsController < ApplicationController
before_action :find_authorized_outfit, :only => [:update, :destroy] before_action :find_authorized_outfit, :only => [:update, :destroy]
def create def create
@outfit = Outfit.build_for_user(current_user, outfit_params) @outfit = Outfit.new(outfit_params)
@outfit.user = current_user
if @outfit.save if @outfit.save
render :json => @outfit render :json => @outfit
else else
@ -81,7 +83,11 @@ class OutfitsController < ApplicationController
def show def show
@outfit = Outfit.find(params[:id]) @outfit = Outfit.find(params[:id])
render "outfits/edit", layout: false
respond_to do |format|
format.html { render "outfits/edit", layout: false }
format.json { render json: @outfit }
end
end end
def start def start
@ -100,8 +106,7 @@ class OutfitsController < ApplicationController
end end
def update def update
@outfit.attributes = outfit_params if @outfit.update(outfit_params)
if @outfit.save
render :json => @outfit render :json => @outfit
else else
render_outfit_errors render_outfit_errors
@ -112,7 +117,8 @@ class OutfitsController < ApplicationController
def outfit_params def outfit_params
params.require(:outfit).permit( params.require(:outfit).permit(
:name, :pet_state_id, :starred, :worn_and_unworn_item_ids, :anonymous) :name, :starred, item_ids: {worn: [], closeted: []},
biology: [:species_id, :color_id, :pose])
end end
def find_authorized_outfit def find_authorized_outfit

View file

@ -5,19 +5,24 @@ import { ChakraProvider, Box, useColorModeValue } from "@chakra-ui/react";
import { ApolloProvider } from "@apollo/client"; import { ApolloProvider } from "@apollo/client";
import { BrowserRouter } from "react-router-dom"; import { BrowserRouter } from "react-router-dom";
import { Global } from "@emotion/react"; import { Global } from "@emotion/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import buildApolloClient from "./apolloClient"; import buildApolloClient from "./apolloClient";
const reactQueryClient = new QueryClient();
export default function AppProvider({ children }) { export default function AppProvider({ children }) {
React.useEffect(() => setupLogging(), []); React.useEffect(() => setupLogging(), []);
return ( return (
<BrowserRouter> <BrowserRouter>
<DTIApolloProvider> <QueryClientProvider client={reactQueryClient}>
<ChakraProvider resetCSS={false}> <DTIApolloProvider>
<ScopedCSSReset>{children}</ScopedCSSReset> <ChakraProvider resetCSS={false}>
</ChakraProvider> <ScopedCSSReset>{children}</ScopedCSSReset>
</DTIApolloProvider> </ChakraProvider>
</DTIApolloProvider>
</QueryClientProvider>
</BrowserRouter> </BrowserRouter>
); );
} }

View file

@ -6,6 +6,7 @@ import useCurrentUser from "../components/useCurrentUser";
import gql from "graphql-tag"; import gql from "graphql-tag";
import { useMutation } from "@apollo/client"; import { useMutation } from "@apollo/client";
import { outfitStatesAreEqual } from "./useOutfitState"; import { outfitStatesAreEqual } from "./useOutfitState";
import { useSaveOutfitMutation } from "../loaders/outfits";
function useOutfitSaving(outfitState, dispatchToOutfit) { function useOutfitSaving(outfitState, dispatchToOutfit) {
const { isLoggedIn, id: currentUserId } = useCurrentUser(); const { isLoggedIn, id: currentUserId } = useCurrentUser();
@ -52,100 +53,25 @@ function useOutfitSaving(outfitState, dispatchToOutfit) {
// yet, but you can't delete it. // yet, but you can't delete it.
const canDeleteOutfit = !isNewOutfit && canSaveOutfit; const canDeleteOutfit = !isNewOutfit && canSaveOutfit;
const [sendSaveOutfitMutation, { loading: isSaving }] = useMutation( const saveOutfitMutation = useSaveOutfitMutation({
gql` onSuccess: (outfit) => {
mutation UseOutfitSaving_SaveOutfit( if (
$id: ID # Optional, is null when saving new outfits. String(outfit.id) === String(outfitState.id) &&
$name: String # Optional, server may fill in a placeholder. outfit.name !== outfitState.name
$speciesId: ID!
$colorId: ID!
$pose: Pose!
$wornItemIds: [ID!]!
$closetedItemIds: [ID!]!
) { ) {
outfit: saveOutfit( dispatchToOutfit({
id: $id type: "rename",
name: $name outfitName: outfit.name,
speciesId: $speciesId
colorId: $colorId
pose: $pose
wornItemIds: $wornItemIds
closetedItemIds: $closetedItemIds
) {
id
name
petAppearance {
id
species {
id
}
color {
id
}
pose
}
wornItems {
id
}
closetedItems {
id
}
creator {
id
}
}
}
`,
{
context: { sendAuth: true },
update: (cache, { data: { outfit } }) => {
// After save, add this outfit to the current user's outfit list. This
// will help when navigating back to Your Outfits, to force a refresh.
// https://www.apollographql.com/docs/react/caching/cache-interaction/#example-updating-the-cache-after-a-mutation
cache.modify({
id: cache.identify(outfit.creator),
fields: {
outfits: (existingOutfitRefs = [], { readField }) => {
const isAlreadyInList = existingOutfitRefs.some(
(ref) => readField("id", ref) === outfit.id,
);
if (isAlreadyInList) {
return existingOutfitRefs;
}
const newOutfitRef = cache.writeFragment({
data: outfit,
fragment: gql`
fragment NewOutfit on Outfit {
id
}
`,
});
return [...existingOutfitRefs, newOutfitRef];
},
},
}); });
}
// Also, send a `rename` action, if this is still the current outfit,
// and the server renamed it (e.g. "Untitled outfit (1)"). (It's
// tempting to do a full reset, in case the server knows something we
// don't, but we don't want to clobber changes the user made since
// starting the save!)
if (outfit.id === outfitState.id && outfit.name !== outfitState.name) {
dispatchToOutfit({
type: "rename",
outfitName: outfit.name,
});
}
},
}, },
); });
const isSaving = saveOutfitMutation.isPending;
const saveOutfitFromProvidedState = React.useCallback( const saveOutfitFromProvidedState = React.useCallback(
(outfitState) => { (outfitState) => {
sendSaveOutfitMutation({ saveOutfitMutation
variables: { .mutateAsync({
id: outfitState.id, id: outfitState.id,
name: outfitState.name, name: outfitState.name,
speciesId: outfitState.speciesId, speciesId: outfitState.speciesId,
@ -153,9 +79,8 @@ function useOutfitSaving(outfitState, dispatchToOutfit) {
pose: outfitState.pose, pose: outfitState.pose,
wornItemIds: [...outfitState.wornItemIds], wornItemIds: [...outfitState.wornItemIds],
closetedItemIds: [...outfitState.closetedItemIds], closetedItemIds: [...outfitState.closetedItemIds],
}, })
}) .then((outfit) => {
.then(({ data: { outfit } }) => {
// Navigate to the new saved outfit URL. Our Apollo cache should pick // Navigate to the new saved outfit URL. Our Apollo cache should pick
// up the data from this mutation response, and combine it with the // up the data from this mutation response, and combine it with the
// existing cached data, to make this smooth without any loading UI. // existing cached data, to make this smooth without any loading UI.
@ -176,7 +101,7 @@ function useOutfitSaving(outfitState, dispatchToOutfit) {
// It's important that this callback _doesn't_ change when the outfit // It's important that this callback _doesn't_ change when the outfit
// changes, so that the auto-save effect is only responding to the // changes, so that the auto-save effect is only responding to the
// debounced state! // debounced state!
[sendSaveOutfitMutation, pathname, navigate, toast], [saveOutfitMutation.mutateAsync, pathname, navigate, toast],
); );
const saveOutfit = React.useCallback( const saveOutfit = React.useCallback(

View file

@ -5,6 +5,7 @@ import { useQuery, useApolloClient } from "@apollo/client";
import { useNavigate, useLocation, useSearchParams } from "react-router-dom"; import { useNavigate, useLocation, useSearchParams } from "react-router-dom";
import { itemAppearanceFragment } from "../components/useOutfitAppearance"; import { itemAppearanceFragment } from "../components/useOutfitAppearance";
import { useSavedOutfit } from "../loaders/outfits";
enableMapSet(); enableMapSet();
@ -23,64 +24,36 @@ function useOutfitState() {
// If there's an outfit ID (i.e. we're on /outfits/:id), load basic data // If there's an outfit ID (i.e. we're on /outfits/:id), load basic data
// about the outfit. We'll use it to initialize the local state. // about the outfit. We'll use it to initialize the local state.
const { const {
loading: outfitLoading, isLoading: outfitLoading,
error: outfitError, error: outfitError,
data: outfitData, data: outfitData,
} = useQuery( status: outfitStatus,
gql` } = useSavedOutfit(urlOutfitState.id, { enabled: urlOutfitState.id != null });
query OutfitStateSavedOutfit($id: ID!) {
outfit(id: $id) {
id
name
updatedAt
creator {
id
}
petAppearance {
id
species {
id
}
color {
id
}
pose
}
wornItems {
id
}
closetedItems {
id
}
# TODO: Consider pre-loading some fields, instead of doing them in const creator = outfitData?.user;
# follow-up queries? const updatedAt = outfitData?.updated_at;
}
}
`,
{
variables: { id: urlOutfitState.id },
skip: urlOutfitState.id == null,
returnPartialData: true,
onCompleted: (outfitData) => {
dispatchToOutfit({
type: "resetToSavedOutfitData",
savedOutfitData: outfitData.outfit,
});
},
},
);
const creator = outfitData?.outfit?.creator;
const updatedAt = outfitData?.outfit?.updatedAt;
// We memoize this to make `outfitStateWithoutExtras` an even more reliable // We memoize this to make `outfitStateWithoutExtras` an even more reliable
// stable object! // stable object!
const savedOutfitState = React.useMemo( const savedOutfitState = React.useMemo(
() => getOutfitStateFromOutfitData(outfitData?.outfit), () => getOutfitStateFromOutfitData(outfitData),
[outfitData?.outfit], [outfitData],
); );
// When the saved outfit data comes in, we reset the local outfit state to
// match.
// TODO: I forget the details of why we have both resetting the local state,
// and a thing where we fallback between the different kinds of outfit state.
// Probably something about SSR when we were on Next.js? Could be simplified?`
React.useEffect(() => {
if (outfitStatus === "success") {
dispatchToOutfit({
type: "resetToSavedOutfitData",
savedOutfitData: outfitData,
});
}
}, [outfitStatus, outfitData]);
// Choose which customization state to use. We want it to match the outfit in // Choose which customization state to use. We want it to match the outfit in
// the URL immediately, without having to wait for any effects, to avoid race // the URL immediately, without having to wait for any effects, to avoid race
// conditions! // conditions!
@ -405,7 +378,7 @@ function useParseOutfitUrl() {
// has historically used both! // has historically used both!
const location = useLocation(); const location = useLocation();
const [justSearchParams] = useSearchParams(); const [justSearchParams] = useSearchParams();
const hashParams = new URLSearchParams(location.hash.substr(1)); const hashParams = new URLSearchParams(location.hash.slice(1));
// Merge them into one URLSearchParams object. // Merge them into one URLSearchParams object.
const mergedParams = new URLSearchParams(); const mergedParams = new URLSearchParams();
@ -429,7 +402,7 @@ function useParseOutfitUrl() {
function readOutfitStateFromSearchParams(pathname, searchParams) { function readOutfitStateFromSearchParams(pathname, searchParams) {
// For the /outfits/:id page, ignore the query string, and just wait for the // For the /outfits/:id page, ignore the query string, and just wait for the
// outfit data to load in! // outfit data to load in!
const pathnameMatch = pathname.match(/^\/outfits\/([0-9]+)/) const pathnameMatch = pathname.match(/^\/outfits\/([0-9]+)/);
if (pathnameMatch) { if (pathnameMatch) {
return { return {
...EMPTY_CUSTOMIZATION_STATE, ...EMPTY_CUSTOMIZATION_STATE,
@ -457,17 +430,14 @@ function getOutfitStateFromOutfitData(outfit) {
} }
return { return {
id: outfit.id, id: String(outfit.id),
name: outfit.name, name: outfit.name,
// Note that these fields are intentionally null if loading, rather than speciesId: String(outfit.species_id),
// falling back to a default appearance like Blue Acara. colorId: String(outfit.color_id),
speciesId: outfit.petAppearance?.species?.id, pose: outfit.pose,
colorId: outfit.petAppearance?.color?.id, wornItemIds: new Set((outfit.item_ids?.worn || []).map((id) => String(id))),
pose: outfit.petAppearance?.pose,
// Whereas the items are more convenient to just leave as empty lists!
wornItemIds: new Set((outfit.wornItems || []).map((item) => item.id)),
closetedItemIds: new Set( closetedItemIds: new Set(
(outfit.closetedItems || []).map((item) => item.id), (outfit.item_ids?.closeted || []).map((id) => String(id)),
), ),
}; };
} }

View file

@ -1,75 +1,30 @@
import { gql, useMutation, useQuery } from "@apollo/client"; // Read the current user ID once from the <meta> tags, and use that forever!
import { useLocalStorage } from "../util"; const currentUserId = readCurrentUserId();
const NOT_LOGGED_IN_USER = {
isLoading: false,
isLoggedIn: false,
id: null,
username: null,
};
function useCurrentUser() { function useCurrentUser() {
const currentUser = useCurrentUserQuery(); if (currentUserId == null) {
// In development, you can start the server with
// `IMPRESS_LOG_IN_AS=12345 vc dev` to simulate logging in as user 12345.
//
// This flag shouldn't be present in prod anyway, but the dev check is an
// extra safety precaution!
//
// NOTE: In package.json, we forward the flag to REACT_APP_IMPRESS_LOG_IN_AS,
// because create-react-app only forwards flags with that prefix.
if (
process.env["NODE_ENV"] === "development" &&
process.env["REACT_APP_IMPRESS_LOG_IN_AS"]
) {
const id = process.env["REACT_APP_IMPRESS_LOG_IN_AS"];
return { return {
isLoading: false, isLoggedIn: false,
isLoggedIn: true, id: null,
id,
username: `<Simulated User ${id}>`,
}; };
} }
return currentUser; return {
isLoggedIn: true,
id: currentUserId,
};
} }
function useCurrentUserQuery() { function readCurrentUserId() {
const { loading, data } = useQuery( try {
gql` const element = document.querySelector("meta[name=dti-current-user-id]");
query useCurrentUser { return JSON.parse(element.getAttribute("content"));
currentUser { } catch (error) {
id console.error(
username `[readCurrentUserId] Couldn't read user ID, using null instead`,
} error,
} );
`, return null;
{
onError: (error) => {
// On error, we don't report anything to the user, but we do keep a
// record in the console. We figure that most errors are likely to be
// solvable by retrying the login button and creating a new session,
// which the user would do without an error prompt anyway; and if not,
// they'll either get an error when they try, or they'll see their
// login state continue to not work, which should be a clear hint that
// something is wrong and they need to reach out.
console.error("[useCurrentUser] Couldn't get current user:", error);
},
},
);
if (loading) {
return { ...NOT_LOGGED_IN_USER, isLoading: true };
} else if (data?.currentUser == null) {
return NOT_LOGGED_IN_USER;
} else {
return {
isLoading: false,
isLoggedIn: true,
id: data.currentUser.id,
username: data.currentUser.username,
};
} }
} }

View file

@ -0,0 +1,85 @@
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
export function useSavedOutfit(id, options) {
return useQuery({
...options,
queryKey: ["outfits", String(id)],
queryFn: () => loadSavedOutfit(id),
});
}
export function useSaveOutfitMutation(options) {
const queryClient = useQueryClient();
return useMutation({
...options,
mutationFn: saveOutfit,
onSuccess: (outfit) => {
queryClient.setQueryData(["outfits", String(outfit.id)], outfit);
options.onSuccess(outfit);
},
});
}
async function loadSavedOutfit(id) {
const res = await fetch(`/outfits/${encodeURIComponent(id)}.json`);
if (!res.ok) {
throw new Error(`loading outfit failed: ${res.status} ${res.statusText}`);
}
return res.json();
}
async function saveOutfit({
id, // optional, null when creating a new outfit
name, // optional, server may fill in a placeholder
speciesId,
colorId,
pose,
wornItemIds,
closetedItemIds,
}) {
const params = {
outfit: {
name: name,
biology: {
species_id: speciesId,
color_id: colorId,
pose: pose,
},
item_ids: { worn: wornItemIds, closeted: closetedItemIds },
},
};
let res;
if (id == null) {
res = await fetch(`/outfits.json`, {
method: "POST",
body: JSON.stringify(params),
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": getCSRFToken(),
},
});
} else {
res = await fetch(`/outfits/${encodeURIComponent(id)}.json`, {
method: "PUT",
body: JSON.stringify(params),
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": getCSRFToken(),
},
});
}
if (!res.ok) {
throw new Error(`saving outfit failed: ${res.status} ${res.statusText}`);
}
return res.json();
}
function getCSRFToken() {
return document.querySelector("meta[name=csrf-token]")?.content;
}

View file

@ -1,14 +1,29 @@
class Outfit < ApplicationRecord class Outfit < ApplicationRecord
has_many :item_outfit_relationships, :dependent => :destroy has_many :item_outfit_relationships, :dependent => :destroy
has_many :worn_item_outfit_relationships, -> { where(is_worn: true) }, has_many :worn_item_outfit_relationships, -> { where(is_worn: true) },
:class_name => 'ItemOutfitRelationship' class_name: 'ItemOutfitRelationship'
has_many :worn_items, :through => :worn_item_outfit_relationships, :source => :item has_many :worn_items, through: :worn_item_outfit_relationships, source: :item
belongs_to :pet_state
belongs_to :pet_state, optional: true # We validate presence below!
belongs_to :user, optional: true belongs_to :user, optional: true
validates :name, :presence => {:if => :user_id}, :uniqueness => {:scope => :user_id, :if => :user_id} validates :name, :presence => {:if => :user_id}, :uniqueness => {:scope => :user_id, :if => :user_id}
validates :pet_state, :presence => true validates :pet_state, presence: {
message: ->(object, _) do
if object.biology
"does not exist for " +
"species ##{object.biology[:species_id]}, " +
"color ##{object.biology[:color_id]}, " +
"pose #{object.biology[:pose]}"
else
"must exist"
end
end
}
before_validation :ensure_unique_name, if: :user_id?
attr_reader :biology
delegate :color, to: :pet_state delegate :color, to: :pet_state
scope :wardrobe_order, -> { order('starred DESC', :name) } scope :wardrobe_order, -> { order('starred DESC', :name) }
@ -66,13 +81,10 @@ class Outfit < ApplicationRecord
end end
def as_json(more_options={}) def as_json(more_options={})
serializable_hash :only => [:id, :name, :pet_state_id, :starred], serializable_hash(
:methods => [:color_id, :species_id, :worn_and_unworn_item_ids, only: [:id, :name, :pet_state_id, :starred, :created_at, :updated_at],
:image_versions, :image_enqueued, :image_layers_hash] methods: [:color_id, :species_id, :pose, :item_ids, :user]
end )
def closet_item_ids
item_outfit_relationships.map(&:item_id)
end end
def color_id def color_id
@ -83,42 +95,76 @@ class Outfit < ApplicationRecord
pet_state.pet_type.species_id pet_state.pet_type.species_id
end end
def worn_and_unworn_item_ids def pose
{:worn => [], :unworn => []}.tap do |output| pet_state.pose
item_outfit_relationships.each do |rel| end
key = rel.is_worn? ? :worn : :unworn
output[key] << rel.item_id def biology=(biology)
end @biology = biology.slice(:species_id, :color_id, :pose)
begin
pet_type = PetType.where(
species_id: @biology[:species_id],
color_id: @biology[:color_id],
).first!
self.pet_state = pet_type.pet_states.with_pose(@biology[:pose]).
emotion_order.first!
rescue ActiveRecord::RecordNotFound
# If there's no such pet state (which shouldn't happen normally in-app),
# we don't set `pet_state` but we keep `@biology` for validation.
end end
end end
def worn_and_unworn_item_ids=(all_item_ids) def item_ids
new_rels = [] rels = item_outfit_relationships
all_item_ids.each do |key, item_ids| {
worn = key == 'worn' worn: rels.filter { |r| r.is_worn? }.map { |r| r.item_id },
unless item_ids.blank? closeted: rels.filter { |r| !r.is_worn? }.map { |r| r.item_id }
item_ids.each do |item_id| }
rel = ItemOutfitRelationship.new
rel.item_id = item_id
rel.is_worn = worn
new_rels << rel
end
end
end
self.item_outfit_relationships = new_rels
end end
def self.build_for_user(user, params) def item_ids=(item_ids)
Outfit.new.tap do |outfit| # Ensure there are no duplicates between the worn/closeted IDs. If an ID is
name = params.delete(:name) # present in both, it's kept in `worn` and removed from `closeted`.
starred = params.delete(:starred) worn_item_ids = item_ids.fetch(:worn, []).uniq
anonymous = params.delete(:anonymous) == "true" closeted_item_ids = item_ids.fetch(:closeted, []).uniq
if user && !anonymous closeted_item_ids.reject! { |id| worn_item_ids.include?(id) }
outfit.user = user
outfit.name = name # Set the worn and closeted item outfit relationships. If there are any
outfit.starred = starred # others attached to this outfit, they are implicitly deleted.
end new_relationships = []
outfit.attributes = params new_relationships += worn_item_ids.map do |item_id|
ItemOutfitRelationship.new(item_id: item_id, is_worn: true)
end
new_relationships += closeted_item_ids.map do |item_id|
ItemOutfitRelationship.new(item_id: item_id, is_worn: false)
end
self.item_outfit_relationships = new_relationships
end
def ensure_unique_name
# If no name was provided, start with "Untitled outfit".
self.name = "Untitled outfit" if name.blank?
# Strip whitespace from the name.
self.name.strip!
# Get the base name of the provided name, without any "(1)" suffixes.
base_name = name.sub(/\s*\([0-9]+\)$/, '')
# Find the user's other outfits that start with the same base name, and get
# *their* names, with whitespace stripped.
existing_outfits = self.user.outfits.
where("name LIKE ?", Outfit.sanitize_sql_like(base_name) + "%")
existing_outfits = existing_outfits.where("id != ?", id) unless id.nil?
existing_names = existing_outfits.map(&:name).map(&:strip)
# Try the provided name first, but if it's taken, add a "(1)" suffix and
# keep incrementing it until it's not.
i = 1
while existing_names.include?(name)
self.name = "#{base_name} (#{i})"
i += 1
end end
end end
end end

View file

@ -16,31 +16,61 @@ class PetState < ApplicationRecord
attr_writer :parent_swf_asset_relationships_to_update attr_writer :parent_swf_asset_relationships_to_update
# Our ideal order is: happy, sad, sick, UC, any+effects, glitched, with male # A simple ordering that tries to bring reliable pet states to the front.
# before female within those groups for consistency. We therefore order as
# follows, listed in order of priority:
# * Send glitched states to the back
# * Bring known happy states to the front (we don't want to sort by mood_id
# DESC first because then labeled sad will appear before unlabeled happy)
# * Send states with effect assets to the back
# * Bring state with more assets forward (that is, send UC near the back)
# * Bring males forward
# * Bring states with a lower asset ID sum forward (the idea being that
# sad/female states are usually created after a happy/male base, but that's
# becoming increasingly untrue over time - this is a very last resort)
#
# Maybe someday, when most states are labeled, we can depend exclusively on
# their labels - or at least use more than is-happy and is-female. For now,
# though, this strikes a good balance of bringing default to the front for
# many pet types (the highest priority!) and otherwise doing decent sorting.
bio_effect_zone_id = 4
scope :emotion_order, -> { scope :emotion_order, -> {
joins(:parent_swf_asset_relationships). order(Arel.sql(
joins("LEFT JOIN swf_assets effect_assets ON effect_assets.id = parents_swf_assets.swf_asset_id AND effect_assets.zone_id = #{bio_effect_zone_id}"). "(mood_id IS NULL) ASC, mood_id ASC, female DESC, unconverted DESC, " +
group("pet_states.id"). "glitched ASC, id DESC"
order(Arel.sql("glitched ASC, (mood_id = 1) DESC, COUNT(effect_assets.remote_id) ASC, COUNT(parents_swf_assets.swf_asset_id) DESC, female ASC, SUM(parents_swf_assets.swf_asset_id) ASC")) ))
} }
# Filter pet states using the "pose" concept we use in the editor.
scope :with_pose, ->(pose) {
case pose
when "UNCONVERTED"
where(unconverted: true)
when "HAPPY_MASC"
where(mood_id: 1, female: false)
when "HAPPY_FEM"
where(mood_id: 1, female: true)
when "SAD_MASC"
where(mood_id: 2, female: false)
when "SAD_FEM"
where(mood_id: 2, female: true)
when "SICK_MASC"
where(mood_id: 4, female: false)
when "SICK_FEM"
where(mood_id: 4, female: true)
when "UNKNOWN"
where(mood_id: nil).or(where(female: nil))
else
raise ArgumentError, "unexpected pose value #{pose}"
end
}
def pose
if unconverted?
"UNCONVERTED"
elsif mood_id.nil? || female.nil?
"UNKNOWN"
elsif mood_id == 1 && !female?
"HAPPY_MASC"
elsif mood_id == 1 && female?
"HAPPY_FEM"
elsif mood_id == 2 && !female?
"SAD_MASC"
elsif mood_id == 2 && female?
"SAD_FEM"
elsif mood_id == 4 && !female?
"SICK_MASC"
elsif mood_id == 4 && female?
"SICK_FEM"
else
raise "could not identify pose: moodId=#{mood_id}, female=#{female}, " +
"unconverted=#{unconverted}"
end
end
def as_json(options={}) def as_json(options={})
{ {
id: id, id: id,

View file

@ -35,6 +35,10 @@ class User < ApplicationRecord
name == 'matchu' # you know that's right. name == 'matchu' # you know that's right.
end end
def as_json
serializable_hash only: [:id, :name]
end
def unowned_items def unowned_items
# Join all items against our owned closet hangers, group by item ID, then # Join all items against our owned closet hangers, group by item ID, then
# only return those with zero matching hangers. # only return those with zero matching hangers.

View file

@ -18,5 +18,7 @@
= stylesheet_link_tag 'fonts' = stylesheet_link_tag 'fonts'
= javascript_include_tag 'wardrobe-2020-page', defer: true = javascript_include_tag 'wardrobe-2020-page', defer: true
= open_graph_tags = open_graph_tags
= csrf_meta_tags
%meta{name: 'dti-current-user-id', content: user_signed_in? ? current_user.id : "null"}
%body %body
#wardrobe-2020-root #wardrobe-2020-root

View file

@ -9,6 +9,7 @@
"@loadable/component": "^5.12.0", "@loadable/component": "^5.12.0",
"@sentry/react": "^5.30.0", "@sentry/react": "^5.30.0",
"@sentry/tracing": "^5.30.0", "@sentry/tracing": "^5.30.0",
"@tanstack/react-query": "^5.4.3",
"apollo-link-persisted-queries": "^0.2.2", "apollo-link-persisted-queries": "^0.2.2",
"easeljs": "^1.0.2", "easeljs": "^1.0.2",
"esbuild": "^0.19.0", "esbuild": "^0.19.0",

View file

@ -989,6 +989,18 @@
"@sentry/types" "5.30.0" "@sentry/types" "5.30.0"
tslib "^1.9.3" tslib "^1.9.3"
"@tanstack/query-core@5.4.3":
version "5.4.3"
resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.4.3.tgz#fbdd36ccf1acf70579980f2e7cf16d2c2aa2a5e9"
integrity sha512-fnI9ORjcuLGm1sNrKatKIosRQUpuqcD4SV7RqRSVmj8JSicX2aoMyKryHEBpVQvf6N4PaBVgBxQomjsbsGPssQ==
"@tanstack/react-query@^5.4.3":
version "5.4.3"
resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.4.3.tgz#cf59120690032e44b8c1c4c463cfb43aaad2fc5f"
integrity sha512-4aSOrRNa6yEmf7mws5QPTVMn8Lp7L38tFoTZ0c1ZmhIvbr8GIA0WT7X5N3yz/nuK8hUtjw9cAzBr4BPDZZ+tzA==
dependencies:
"@tanstack/query-core" "5.4.3"
"@types/lodash.mergewith@4.6.6": "@types/lodash.mergewith@4.6.6":
version "4.6.6" version "4.6.6"
resolved "https://registry.yarnpkg.com/@types/lodash.mergewith/-/lodash.mergewith-4.6.6.tgz#c4698f5b214a433ff35cb2c75ee6ec7f99d79f10" resolved "https://registry.yarnpkg.com/@types/lodash.mergewith/-/lodash.mergewith-4.6.6.tgz#c4698f5b214a433ff35cb2c75ee6ec7f99d79f10"