From 7a3aa609ba1b5dba0340158bcbdf3fcd856699b7 Mon Sep 17 00:00:00 2001 From: Matchu Date: Thu, 2 Nov 2023 13:50:33 -0700 Subject: [PATCH] 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? --- app/controllers/outfits_controller.rb | 16 ++- app/javascript/wardrobe-2020/AppProvider.js | 15 +- .../WardrobePage/useOutfitSaving.js | 109 +++------------ .../WardrobePage/useOutfitState.js | 90 ++++-------- .../components/useCurrentUser.js | 83 +++-------- .../wardrobe-2020/loaders/outfits.js | 85 ++++++++++++ app/models/outfit.rb | 130 ++++++++++++------ app/models/pet_state.rb | 74 +++++++--- app/models/user.rb | 4 + app/views/outfits/edit.html.haml | 2 + package.json | 1 + yarn.lock | 12 ++ 12 files changed, 331 insertions(+), 290 deletions(-) create mode 100644 app/javascript/wardrobe-2020/loaders/outfits.js diff --git a/app/controllers/outfits_controller.rb b/app/controllers/outfits_controller.rb index 22f591e9..ec4b1e32 100644 --- a/app/controllers/outfits_controller.rb +++ b/app/controllers/outfits_controller.rb @@ -2,7 +2,9 @@ class OutfitsController < ApplicationController before_action :find_authorized_outfit, :only => [:update, :destroy] def create - @outfit = Outfit.build_for_user(current_user, outfit_params) + @outfit = Outfit.new(outfit_params) + @outfit.user = current_user + if @outfit.save render :json => @outfit else @@ -81,7 +83,11 @@ class OutfitsController < ApplicationController def show @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 def start @@ -100,8 +106,7 @@ class OutfitsController < ApplicationController end def update - @outfit.attributes = outfit_params - if @outfit.save + if @outfit.update(outfit_params) render :json => @outfit else render_outfit_errors @@ -112,7 +117,8 @@ class OutfitsController < ApplicationController def outfit_params 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 def find_authorized_outfit diff --git a/app/javascript/wardrobe-2020/AppProvider.js b/app/javascript/wardrobe-2020/AppProvider.js index a47a20d4..90d48f4c 100644 --- a/app/javascript/wardrobe-2020/AppProvider.js +++ b/app/javascript/wardrobe-2020/AppProvider.js @@ -5,19 +5,24 @@ import { ChakraProvider, Box, useColorModeValue } from "@chakra-ui/react"; import { ApolloProvider } from "@apollo/client"; import { BrowserRouter } from "react-router-dom"; import { Global } from "@emotion/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import buildApolloClient from "./apolloClient"; +const reactQueryClient = new QueryClient(); + export default function AppProvider({ children }) { React.useEffect(() => setupLogging(), []); return ( - - - {children} - - + + + + {children} + + + ); } diff --git a/app/javascript/wardrobe-2020/WardrobePage/useOutfitSaving.js b/app/javascript/wardrobe-2020/WardrobePage/useOutfitSaving.js index cc35a19f..4bc61618 100644 --- a/app/javascript/wardrobe-2020/WardrobePage/useOutfitSaving.js +++ b/app/javascript/wardrobe-2020/WardrobePage/useOutfitSaving.js @@ -6,6 +6,7 @@ import useCurrentUser from "../components/useCurrentUser"; import gql from "graphql-tag"; import { useMutation } from "@apollo/client"; import { outfitStatesAreEqual } from "./useOutfitState"; +import { useSaveOutfitMutation } from "../loaders/outfits"; function useOutfitSaving(outfitState, dispatchToOutfit) { const { isLoggedIn, id: currentUserId } = useCurrentUser(); @@ -52,100 +53,25 @@ function useOutfitSaving(outfitState, dispatchToOutfit) { // yet, but you can't delete it. const canDeleteOutfit = !isNewOutfit && canSaveOutfit; - const [sendSaveOutfitMutation, { loading: isSaving }] = useMutation( - gql` - mutation UseOutfitSaving_SaveOutfit( - $id: ID # Optional, is null when saving new outfits. - $name: String # Optional, server may fill in a placeholder. - $speciesId: ID! - $colorId: ID! - $pose: Pose! - $wornItemIds: [ID!]! - $closetedItemIds: [ID!]! + const saveOutfitMutation = useSaveOutfitMutation({ + onSuccess: (outfit) => { + if ( + String(outfit.id) === String(outfitState.id) && + outfit.name !== outfitState.name ) { - outfit: saveOutfit( - id: $id - name: $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]; - }, - }, + dispatchToOutfit({ + type: "rename", + outfitName: outfit.name, }); - - // 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( (outfitState) => { - sendSaveOutfitMutation({ - variables: { + saveOutfitMutation + .mutateAsync({ id: outfitState.id, name: outfitState.name, speciesId: outfitState.speciesId, @@ -153,9 +79,8 @@ function useOutfitSaving(outfitState, dispatchToOutfit) { pose: outfitState.pose, wornItemIds: [...outfitState.wornItemIds], closetedItemIds: [...outfitState.closetedItemIds], - }, - }) - .then(({ data: { outfit } }) => { + }) + .then((outfit) => { // Navigate to the new saved outfit URL. Our Apollo cache should pick // up the data from this mutation response, and combine it with the // 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 // changes, so that the auto-save effect is only responding to the // debounced state! - [sendSaveOutfitMutation, pathname, navigate, toast], + [saveOutfitMutation.mutateAsync, pathname, navigate, toast], ); const saveOutfit = React.useCallback( diff --git a/app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js b/app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js index 791ada95..25533b50 100644 --- a/app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js +++ b/app/javascript/wardrobe-2020/WardrobePage/useOutfitState.js @@ -5,6 +5,7 @@ import { useQuery, useApolloClient } from "@apollo/client"; import { useNavigate, useLocation, useSearchParams } from "react-router-dom"; import { itemAppearanceFragment } from "../components/useOutfitAppearance"; +import { useSavedOutfit } from "../loaders/outfits"; enableMapSet(); @@ -23,64 +24,36 @@ function useOutfitState() { // 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. const { - loading: outfitLoading, + isLoading: outfitLoading, error: outfitError, data: outfitData, - } = useQuery( - gql` - query OutfitStateSavedOutfit($id: ID!) { - outfit(id: $id) { - id - name - updatedAt - creator { - id - } - petAppearance { - id - species { - id - } - color { - id - } - pose - } - wornItems { - id - } - closetedItems { - id - } + status: outfitStatus, + } = useSavedOutfit(urlOutfitState.id, { enabled: urlOutfitState.id != null }); - # TODO: Consider pre-loading some fields, instead of doing them in - # follow-up queries? - } - } - `, - { - 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; + const creator = outfitData?.user; + const updatedAt = outfitData?.updated_at; // We memoize this to make `outfitStateWithoutExtras` an even more reliable // stable object! const savedOutfitState = React.useMemo( - () => getOutfitStateFromOutfitData(outfitData?.outfit), - [outfitData?.outfit], + () => getOutfitStateFromOutfitData(outfitData), + [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 // the URL immediately, without having to wait for any effects, to avoid race // conditions! @@ -405,7 +378,7 @@ function useParseOutfitUrl() { // has historically used both! const location = useLocation(); 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. const mergedParams = new URLSearchParams(); @@ -429,7 +402,7 @@ function useParseOutfitUrl() { function readOutfitStateFromSearchParams(pathname, searchParams) { // For the /outfits/:id page, ignore the query string, and just wait for the // outfit data to load in! - const pathnameMatch = pathname.match(/^\/outfits\/([0-9]+)/) + const pathnameMatch = pathname.match(/^\/outfits\/([0-9]+)/); if (pathnameMatch) { return { ...EMPTY_CUSTOMIZATION_STATE, @@ -457,17 +430,14 @@ function getOutfitStateFromOutfitData(outfit) { } return { - id: outfit.id, + id: String(outfit.id), name: outfit.name, - // Note that these fields are intentionally null if loading, rather than - // falling back to a default appearance like Blue Acara. - speciesId: outfit.petAppearance?.species?.id, - colorId: outfit.petAppearance?.color?.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)), + speciesId: String(outfit.species_id), + colorId: String(outfit.color_id), + pose: outfit.pose, + wornItemIds: new Set((outfit.item_ids?.worn || []).map((id) => String(id))), closetedItemIds: new Set( - (outfit.closetedItems || []).map((item) => item.id), + (outfit.item_ids?.closeted || []).map((id) => String(id)), ), }; } diff --git a/app/javascript/wardrobe-2020/components/useCurrentUser.js b/app/javascript/wardrobe-2020/components/useCurrentUser.js index 19b3fc90..8e04c2db 100644 --- a/app/javascript/wardrobe-2020/components/useCurrentUser.js +++ b/app/javascript/wardrobe-2020/components/useCurrentUser.js @@ -1,75 +1,30 @@ -import { gql, useMutation, useQuery } from "@apollo/client"; -import { useLocalStorage } from "../util"; - -const NOT_LOGGED_IN_USER = { - isLoading: false, - isLoggedIn: false, - id: null, - username: null, -}; +// Read the current user ID once from the tags, and use that forever! +const currentUserId = readCurrentUserId(); function useCurrentUser() { - const currentUser = useCurrentUserQuery(); - - // 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"]; + if (currentUserId == null) { return { - isLoading: false, - isLoggedIn: true, - id, - username: ``, + isLoggedIn: false, + id: null, }; } - return currentUser; + return { + isLoggedIn: true, + id: currentUserId, + }; } -function useCurrentUserQuery() { - const { loading, data } = useQuery( - gql` - query useCurrentUser { - currentUser { - id - username - } - } - `, - { - 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, - }; +function readCurrentUserId() { + try { + const element = document.querySelector("meta[name=dti-current-user-id]"); + return JSON.parse(element.getAttribute("content")); + } catch (error) { + console.error( + `[readCurrentUserId] Couldn't read user ID, using null instead`, + error, + ); + return null; } } diff --git a/app/javascript/wardrobe-2020/loaders/outfits.js b/app/javascript/wardrobe-2020/loaders/outfits.js new file mode 100644 index 00000000..6612cfc6 --- /dev/null +++ b/app/javascript/wardrobe-2020/loaders/outfits.js @@ -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; +} diff --git a/app/models/outfit.rb b/app/models/outfit.rb index ab8dda05..5dfe4eda 100644 --- a/app/models/outfit.rb +++ b/app/models/outfit.rb @@ -1,14 +1,29 @@ class Outfit < ApplicationRecord has_many :item_outfit_relationships, :dependent => :destroy has_many :worn_item_outfit_relationships, -> { where(is_worn: true) }, - :class_name => 'ItemOutfitRelationship' - has_many :worn_items, :through => :worn_item_outfit_relationships, :source => :item - belongs_to :pet_state + class_name: 'ItemOutfitRelationship' + has_many :worn_items, through: :worn_item_outfit_relationships, source: :item + + belongs_to :pet_state, optional: true # We validate presence below! belongs_to :user, optional: true 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 scope :wardrobe_order, -> { order('starred DESC', :name) } @@ -66,13 +81,10 @@ class Outfit < ApplicationRecord end def as_json(more_options={}) - serializable_hash :only => [:id, :name, :pet_state_id, :starred], - :methods => [:color_id, :species_id, :worn_and_unworn_item_ids, - :image_versions, :image_enqueued, :image_layers_hash] - end - - def closet_item_ids - item_outfit_relationships.map(&:item_id) + serializable_hash( + only: [:id, :name, :pet_state_id, :starred, :created_at, :updated_at], + methods: [:color_id, :species_id, :pose, :item_ids, :user] + ) end def color_id @@ -83,42 +95,76 @@ class Outfit < ApplicationRecord pet_state.pet_type.species_id end - def worn_and_unworn_item_ids - {:worn => [], :unworn => []}.tap do |output| - item_outfit_relationships.each do |rel| - key = rel.is_worn? ? :worn : :unworn - output[key] << rel.item_id - end + def pose + pet_state.pose + end + + def biology=(biology) + @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 - def worn_and_unworn_item_ids=(all_item_ids) - new_rels = [] - all_item_ids.each do |key, item_ids| - worn = key == 'worn' - unless item_ids.blank? - 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 + def item_ids + rels = item_outfit_relationships + { + worn: rels.filter { |r| r.is_worn? }.map { |r| r.item_id }, + closeted: rels.filter { |r| !r.is_worn? }.map { |r| r.item_id } + } end - def self.build_for_user(user, params) - Outfit.new.tap do |outfit| - name = params.delete(:name) - starred = params.delete(:starred) - anonymous = params.delete(:anonymous) == "true" - if user && !anonymous - outfit.user = user - outfit.name = name - outfit.starred = starred - end - outfit.attributes = params + def item_ids=(item_ids) + # Ensure there are no duplicates between the worn/closeted IDs. If an ID is + # present in both, it's kept in `worn` and removed from `closeted`. + worn_item_ids = item_ids.fetch(:worn, []).uniq + closeted_item_ids = item_ids.fetch(:closeted, []).uniq + closeted_item_ids.reject! { |id| worn_item_ids.include?(id) } + + # Set the worn and closeted item outfit relationships. If there are any + # others attached to this outfit, they are implicitly deleted. + new_relationships = [] + 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 diff --git a/app/models/pet_state.rb b/app/models/pet_state.rb index 69b390f0..650fe919 100644 --- a/app/models/pet_state.rb +++ b/app/models/pet_state.rb @@ -16,31 +16,61 @@ class PetState < ApplicationRecord attr_writer :parent_swf_asset_relationships_to_update - # Our ideal order is: happy, sad, sick, UC, any+effects, glitched, with male - # 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 + # A simple ordering that tries to bring reliable pet states to the front. scope :emotion_order, -> { - joins(:parent_swf_asset_relationships). - 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}"). - group("pet_states.id"). - 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")) + order(Arel.sql( + "(mood_id IS NULL) ASC, mood_id ASC, female DESC, unconverted DESC, " + + "glitched ASC, id DESC" + )) } + # 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={}) { id: id, diff --git a/app/models/user.rb b/app/models/user.rb index 9b94b7b5..557ccc12 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -35,6 +35,10 @@ class User < ApplicationRecord name == 'matchu' # you know that's right. end + def as_json + serializable_hash only: [:id, :name] + end + def unowned_items # Join all items against our owned closet hangers, group by item ID, then # only return those with zero matching hangers. diff --git a/app/views/outfits/edit.html.haml b/app/views/outfits/edit.html.haml index 67eb5646..807c8734 100644 --- a/app/views/outfits/edit.html.haml +++ b/app/views/outfits/edit.html.haml @@ -18,5 +18,7 @@ = stylesheet_link_tag 'fonts' = javascript_include_tag 'wardrobe-2020-page', defer: true = open_graph_tags + = csrf_meta_tags + %meta{name: 'dti-current-user-id', content: user_signed_in? ? current_user.id : "null"} %body #wardrobe-2020-root diff --git a/package.json b/package.json index 5b311b59..ae14497f 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@loadable/component": "^5.12.0", "@sentry/react": "^5.30.0", "@sentry/tracing": "^5.30.0", + "@tanstack/react-query": "^5.4.3", "apollo-link-persisted-queries": "^0.2.2", "easeljs": "^1.0.2", "esbuild": "^0.19.0", diff --git a/yarn.lock b/yarn.lock index 8071b00b..b07a5db5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -989,6 +989,18 @@ "@sentry/types" "5.30.0" 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": version "4.6.6" resolved "https://registry.yarnpkg.com/@types/lodash.mergewith/-/lodash.mergewith-4.6.6.tgz#c4698f5b214a433ff35cb2c75ee6ec7f99d79f10"