1
0
Fork 0
forked from OpenNeo/impress

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]
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

View file

@ -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 (
<BrowserRouter>
<QueryClientProvider client={reactQueryClient}>
<DTIApolloProvider>
<ChakraProvider resetCSS={false}>
<ScopedCSSReset>{children}</ScopedCSSReset>
</ChakraProvider>
</DTIApolloProvider>
</QueryClientProvider>
</BrowserRouter>
);
}

View file

@ -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];
},
},
});
// 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(

View file

@ -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)),
),
};
}

View file

@ -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 <meta> 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: `<Simulated User ${id}>`,
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);
},
},
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,
);
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,
};
return null;
}
}

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
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
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
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
outfit.attributes = params
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

View file

@ -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,

View file

@ -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.

View file

@ -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

View file

@ -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",

View file

@ -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"