Finish outfit auto-saving!

Hope it actually work-works lol

Did some refactors in useOutfitState to support the new reset action we do after auto-saving, in case the server tweaked things like the name.
This commit is contained in:
Emi Matchu 2021-04-28 15:47:59 -07:00
parent 217aa8dcc1
commit 1d97988383
4 changed files with 89 additions and 37 deletions

View file

@ -27,9 +27,9 @@ GRANT INSERT ON modeling_logs TO impress2020;
-- User data tables -- User data tables
GRANT SELECT, INSERT, DELETE ON closet_hangers TO impress2020; GRANT SELECT, INSERT, DELETE ON closet_hangers TO impress2020;
GRANT SELECT, UPDATE ON closet_lists TO impress2020; GRANT SELECT, UPDATE ON closet_lists TO impress2020;
GRANT SELECT ON item_outfit_relationships TO impress2020; GRANT SELECT, DELETE ON item_outfit_relationships TO impress2020;
GRANT SELECT ON neopets_connections TO impress2020; GRANT SELECT ON neopets_connections TO impress2020;
GRANT SELECT ON outfits TO impress2020; GRANT SELECT, INSERT, UPDATE ON outfits TO impress2020;
GRANT SELECT, UPDATE ON users TO impress2020; GRANT SELECT, UPDATE ON users TO impress2020;
GRANT SELECT, UPDATE ON openneo_id.users TO impress2020; GRANT SELECT, UPDATE ON openneo_id.users TO impress2020;

View file

@ -258,7 +258,7 @@ function ItemZoneGroupSkeleton({ itemCount }) {
); );
} }
function useOutfitSaving(outfitState) { function useOutfitSaving(outfitState, dispatchToOutfit) {
const { isLoggedIn, id: currentUserId } = useCurrentUser(); const { isLoggedIn, id: currentUserId } = useCurrentUser();
const history = useHistory(); const history = useHistory();
const toast = useToast(); const toast = useToast();
@ -272,9 +272,25 @@ function useOutfitSaving(outfitState) {
const isNewOutfit = outfitState.id == null; const isNewOutfit = outfitState.id == null;
// Whether this outfit's latest local changes have been saved to the server. // Whether this outfit's latest local changes have been saved to the server.
// And log it to the console!
const latestVersionIsSaved = const latestVersionIsSaved =
outfitState.savedOutfitState && outfitState.savedOutfitState &&
outfitStatesAreEqual(outfitState, outfitState.savedOutfitState); outfitStatesAreEqual(
outfitState.outfitStateWithoutExtras,
outfitState.savedOutfitState
);
React.useEffect(() => {
console.debug(
"[useOutfitSaving] Latest version is saved? %s\nCurrent: %o\nSaved: %o",
latestVersionIsSaved,
outfitState.outfitStateWithoutExtras,
outfitState.savedOutfitState
);
}, [
latestVersionIsSaved,
outfitState.outfitStateWithoutExtras,
outfitState.savedOutfitState,
]);
// Only logged-in users can save outfits - and they can only save new outfits, // Only logged-in users can save outfits - and they can only save new outfits,
// or outfits they created. // or outfits they created.
@ -347,6 +363,15 @@ function useOutfitSaving(outfitState) {
}, },
}, },
}); });
// Also, send a `reset` action, to show whatever the server returned.
// This is important for suffix changes to `name`, but can also be
// relevant for graceful failure when a bug causes a change not to
// persist.
dispatchToOutfit({
type: "resetToSavedOutfitData",
savedOutfitData: outfit,
});
}, },
} }
); );
@ -360,8 +385,8 @@ function useOutfitSaving(outfitState) {
speciesId: outfitState.speciesId, speciesId: outfitState.speciesId,
colorId: outfitState.colorId, colorId: outfitState.colorId,
pose: outfitState.pose, pose: outfitState.pose,
wornItemIds: outfitState.wornItemIds, wornItemIds: [...outfitState.wornItemIds],
closetedItemIds: outfitState.closetedItemIds, closetedItemIds: [...outfitState.closetedItemIds],
}, },
}) })
.then(({ data: { outfit } }) => { .then(({ data: { outfit } }) => {
@ -416,7 +441,7 @@ function useOutfitSaving(outfitState) {
!outfitStatesAreEqual(debouncedOutfitState, outfitState.savedOutfitState) !outfitStatesAreEqual(debouncedOutfitState, outfitState.savedOutfitState)
) { ) {
console.info( console.info(
"[useOutfitSaving] Auto-saving outfit from old state to new state", "[useOutfitSaving] Auto-saving outfit\nSaved: %o\nCurrent (debounced): %o",
outfitState.savedOutfitState, outfitState.savedOutfitState,
debouncedOutfitState debouncedOutfitState
); );
@ -452,7 +477,7 @@ function useOutfitSaving(outfitState) {
* OutfitSavingIndicator shows a Save button, or the "Saved" or "Saving" state, * OutfitSavingIndicator shows a Save button, or the "Saved" or "Saving" state,
* if the user can save this outfit. If not, this is empty! * if the user can save this outfit. If not, this is empty!
*/ */
function OutfitSavingIndicator({ outfitState }) { function OutfitSavingIndicator({ outfitState, dispatchToOutfit }) {
const { const {
canSaveOutfit, canSaveOutfit,
isNewOutfit, isNewOutfit,
@ -460,7 +485,7 @@ function OutfitSavingIndicator({ outfitState }) {
latestVersionIsSaved, latestVersionIsSaved,
saveError, saveError,
saveOutfit, saveOutfit,
} = useOutfitSaving(outfitState); } = useOutfitSaving(outfitState, dispatchToOutfit);
const errorTextColor = useColorModeValue("red.600", "red.400"); const errorTextColor = useColorModeValue("red.600", "red.400");
@ -579,7 +604,10 @@ function OutfitHeading({ outfitState, dispatchToOutfit }) {
</Box> </Box>
<Box width="4" flex="1 0 auto" /> <Box width="4" flex="1 0 auto" />
<Box flex="0 0 auto"> <Box flex="0 0 auto">
<OutfitSavingIndicator outfitState={outfitState} /> <OutfitSavingIndicator
outfitState={outfitState}
dispatchToOutfit={dispatchToOutfit}
/>
</Box> </Box>
<Box width="2" /> <Box width="2" />
<Menu placement="bottom-end"> <Menu placement="bottom-end">

View file

@ -61,8 +61,8 @@ function useOutfitState() {
returnPartialData: true, returnPartialData: true,
onCompleted: (outfitData) => { onCompleted: (outfitData) => {
dispatchToOutfit({ dispatchToOutfit({
type: "reset", type: "resetToSavedOutfitData",
newState: getOutfitStateFromOutfitData(outfitData.outfit), savedOutfitData: outfitData.outfit,
}); });
}, },
} }
@ -97,17 +97,17 @@ function useOutfitState() {
if (urlOutfitState.id === localOutfitState.id) { if (urlOutfitState.id === localOutfitState.id) {
// Use the reducer state: they're both for the same saved outfit, or both // Use the reducer state: they're both for the same saved outfit, or both
// for an unsaved outfit (null === null). // for an unsaved outfit (null === null).
console.debug("Choosing local outfit state"); console.debug("[useOutfitState] Choosing local outfit state");
outfitState = localOutfitState; outfitState = localOutfitState;
} else if (urlOutfitState.id && urlOutfitState.id === savedOutfitState.id) { } else if (urlOutfitState.id && urlOutfitState.id === savedOutfitState.id) {
// Use the saved outfit state: it's for the saved outfit the URL points to. // Use the saved outfit state: it's for the saved outfit the URL points to.
console.debug("Choosing saved outfit state"); console.debug("[useOutfitState] Choosing saved outfit state");
outfitState = savedOutfitState; outfitState = savedOutfitState;
} else { } else {
// Use the URL state: it's more up-to-date than any of the others. (Worst // Use the URL state: it's more up-to-date than any of the others. (Worst
// case, it's empty except for ID, which is fine while the saved outfit // case, it's empty except for ID, which is fine while the saved outfit
// data loads!) // data loads!)
console.debug("Choosing URL outfit state"); console.debug("[useOutfitState] Choosing URL outfit state");
outfitState = urlOutfitState; outfitState = urlOutfitState;
} }
@ -350,8 +350,8 @@ const outfitStateReducer = (apolloClient) => (baseState, action) => {
// particular about which version of the pose to show if more than one. // particular about which version of the pose to show if more than one.
state.appearanceId = action.appearanceId || null; state.appearanceId = action.appearanceId || null;
}); });
case "reset": case "resetToSavedOutfitData":
return action.newState; return getOutfitStateFromOutfitData(action.savedOutfitData);
default: default:
throw new Error(`unexpected action ${JSON.stringify(action)}`); throw new Error(`unexpected action ${JSON.stringify(action)}`);
} }

View file

@ -141,10 +141,6 @@ const resolvers = {
); );
} }
if (id) {
throw new Error("TODO: Add support for updating existing outfits");
}
// Get the base name of the provided name: trim it, and strip any "(1)" // Get the base name of the provided name: trim it, and strip any "(1)"
// suffixes. // suffixes.
const baseName = (rawName || "Untitled outfit").replace( const baseName = (rawName || "Untitled outfit").replace(
@ -153,21 +149,23 @@ const resolvers = {
); );
const namePlaceholder = baseName.trim().replace(/_%/g, "\\$0") + "%"; const namePlaceholder = baseName.trim().replace(/_%/g, "\\$0") + "%";
// Then, look for outfits from this user with the same base name. // Then, look for outfits from this user with the same base name. Omit
// the current outfit (if it's already saved), because it's fine for this
// outfit to have the same name as itself lol!
const [outfitRows] = await db.query( const [outfitRows] = await db.query(
` `
SELECT name FROM outfits WHERE user_id = ? AND name LIKE ?; SELECT name FROM outfits WHERE user_id = ? AND name LIKE ? AND id != ?;
`, `,
[currentUserId, namePlaceholder] [currentUserId, namePlaceholder, id || "<no-ID-new-outfit>"]
); );
const existingOutfitNames = new Set( const existingOutfitNames = new Set(
outfitRows.map(({ name }) => name.trim()) outfitRows.map(({ name }) => name.trim())
); );
// Then, get the unique name to use for this outfit: try the base name // Then, get the unique name to use for this outfit: try the provided
// first, but, if it's taken, keep incrementing the "(1)" suffix until // name first, but, if it's taken, add a "(1)" suffix and keep
// it's not. // incrementing it until it's not.
let name = baseName; let name = rawName;
for (let i = 1; existingOutfitNames.has(name); i++) { for (let i = 1; existingOutfitNames.has(name); i++) {
name = `${baseName} (${i})`; name = `${baseName} (${i})`;
} }
@ -197,7 +195,19 @@ const resolvers = {
await db.beginTransaction(); await db.beginTransaction();
let newOutfitId; let newOutfitId;
try { try {
const [result] = await db.execute( // If this is a new outfit, INSERT it. Or, if it's an existing outfit,
// UPDATE it.
const [result] = id
? await db.execute(
`
UPDATE outfits
SET name = ?, pet_state_id = ?,
updated_at = CURRENT_TIMESTAMP()
WHERE id = ?;
`,
[name, petState.id, id]
)
: await db.execute(
` `
INSERT INTO outfits INSERT INTO outfits
(name, pet_state_id, user_id, created_at, updated_at) (name, pet_state_id, user_id, created_at, updated_at)
@ -205,7 +215,21 @@ const resolvers = {
`, `,
[name, petState.id, currentUserId] [name, petState.id, currentUserId]
); );
newOutfitId = String(result.insertId); newOutfitId = id || String(result.insertId);
// If this outfit is already saved, clear the item relationships. We'll
// re-insert them in the next query. (We're in a transaction, so this
// is safe: on error, we'll roll this back!)
//
// TODO: This delete-then-insert paradigm isn't super-performant,
// performing the actual needed sync could be better. Keep an eye
// on query perf!
if (id) {
await db.execute(
`DELETE FROM item_outfit_relationships WHERE outfit_id = ?;`,
[id]
);
}
if (wornItemIds.length > 0 || closetedItemIds.length > 0) { if (wornItemIds.length > 0 || closetedItemIds.length > 0) {
const itemRowPlaceholders = [ const itemRowPlaceholders = [