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:
parent
52e7456987
commit
7a3aa609ba
12 changed files with 331 additions and 290 deletions
|
@ -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
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
|
|
|
@ -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)),
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
85
app/javascript/wardrobe-2020/loaders/outfits.js
Normal file
85
app/javascript/wardrobe-2020/loaders/outfits.js
Normal 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;
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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",
|
||||||
|
|
12
yarn.lock
12
yarn.lock
|
@ -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"
|
||||||
|
|
Loading…
Reference in a new issue