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"