diff --git a/cypress/integration/ItemPage/ItemZonesInfo.spec.js b/cypress/integration/ItemPage/ItemZonesInfo.spec.js new file mode 100644 index 0000000..97e5dbf --- /dev/null +++ b/cypress/integration/ItemPage/ItemZonesInfo.spec.js @@ -0,0 +1,45 @@ +// Give network requests a bit of breathing room! (A bit more, because this +// page has a lot of data!) +const networkTimeout = { timeout: 10000 }; + +describe("ItemZonesInfo", () => { + it("shows simple zone data for an all-species Background", () => { + cy.visit("/items/37375"); + cy.get("[data-test-id='item-zones-info']", networkTimeout).should( + "have.text", + "Zone: Background" + ); + }); + + it("shows simple zone data for a species-specific Hat", () => { + cy.visit("/items/34985"); + cy.get("[data-test-id='item-zones-info']", networkTimeout).should( + "have.text", + "Zone: Hat" + ); + }); + + it("shows distinct zone data for an all-species in-hand item", () => { + cy.visit("/items/43397"); + cy.get("[data-test-id='item-zones-info']", networkTimeout).should( + "have.text", + "Zones: Right-hand Item (52 species)" + "Left-hand Item (2 species)" + ); + + cy.contains("(52 species)").focus(); + cy.contains( + "Acara, Aisha, Blumaroo, Bori, Bruce, Buzz, Chia, Chomby, Cybunny, Draik, Elephante, Eyrie, Flotsam, Gelert, Gnorbu, Grarrl, Grundo, Hissi, Ixi, Jetsam, Jubjub, Kacheek, Kau, Kiko, Koi, Korbat, Kougra, Krawk, Kyrii, Lupe, Lutari, Meerca, Moehog, Mynci, Nimmo, Ogrin, Peophin, Poogle, Pteri, Quiggle, Ruki, Scorchio, Shoyru, Skeith, Techo, Tonu, Uni, Usul, Wocky, Xweetok, Yurble, Zafara" + ).should("exist"); + + cy.contains("(2 species)").focus(); + cy.contains("Lenny, Tuskaninny").should("exist"); + }); + + it("shows simple zone data for a Mutant-only Dress", () => { + cy.visit("/items/70564"); + cy.get("[data-test-id='item-zones-info']", networkTimeout).should( + "have.text", + "Zone: Shirt/Dress" + ); + }); +}); diff --git a/src/app/ItemPage.js b/src/app/ItemPage.js index 769c971..d6af813 100644 --- a/src/app/ItemPage.js +++ b/src/app/ItemPage.js @@ -574,9 +574,19 @@ function ItemPageOutfitPreview({ itemId }) { item(id: $itemId) { id name - compatibleBodies { - id - representsAllBodies + compatibleBodiesAndTheirZones { + body { + id + representsAllBodies + species { + id + name + } + } + zones { + id + label @client + } } canonicalAppearance( preferredSpeciesId: $preferredSpeciesId @@ -628,7 +638,10 @@ function ItemPageOutfitPreview({ itemId }) { } ); - const compatibleBodies = data?.item?.compatibleBodies || []; + const compatibleBodies = + data?.item?.compatibleBodiesAndTheirZones?.map(({ body }) => body) || []; + const compatibleBodiesAndTheirZones = + data?.item?.compatibleBodiesAndTheirZones || []; // If there's only one compatible body, and the canonical species's name // appears in the item name, then this is probably a species-specific item, @@ -825,29 +838,11 @@ function ItemPageOutfitPreview({ itemId }) { /> - + {compatibleBodiesAndTheirZones.length > 0 && ( + + )} ); @@ -1276,34 +1271,55 @@ function SpeciesFaceOption({ ); } -function ItemZonesInfo({ zonesAndTheirBodies }) { - const sortedZonesAndTheirBodies = [...zonesAndTheirBodies].sort((a, b) => - zonesAndTheirBodiesSortKey(a).localeCompare(zonesAndTheirBodiesSortKey(b)) +function ItemZonesInfo({ compatibleBodiesAndTheirZones }) { + // Reorganize the body-and-zones data, into zone-and-bodies data. Also, we're + // merging zones with the same label, because that's how user-facing zone UI + // generally works! + const zoneLabelsAndTheirBodiesMap = {}; + for (const { body, zones } of compatibleBodiesAndTheirZones) { + for (const zone of zones) { + if (!zoneLabelsAndTheirBodiesMap[zone.label]) { + zoneLabelsAndTheirBodiesMap[zone.label] = { + zoneLabel: zone.label, + bodies: [], + }; + } + zoneLabelsAndTheirBodiesMap[zone.label].bodies.push(body); + } + } + const zoneLabelsAndTheirBodies = Object.values(zoneLabelsAndTheirBodiesMap); + + const sortedZonesAndTheirBodies = [...zoneLabelsAndTheirBodies].sort((a, b) => + buildSortKeyForZoneLabelsAndTheirBodies(a).localeCompare( + buildSortKeyForZoneLabelsAndTheirBodies(b) + ) ); // We only show body info if there's more than one group of bodies to talk // about. If they all have the same zones, it's clear from context that any // preview available in the list has the zones listed here. const bodyGroups = new Set( - zonesAndTheirBodies.map(({ bodies }) => bodies.map((b) => b.id).join(",")) + zoneLabelsAndTheirBodies.map(({ bodies }) => + bodies.map((b) => b.id).join(",") + ) ); const showBodyInfo = bodyGroups.size > 1; return ( - + - Zones: + {sortedZonesAndTheirBodies.length > 1 ? "Zones" : "Zone"}: {" "} - {sortedZonesAndTheirBodies.map(({ zone, bodies }) => ( + {sortedZonesAndTheirBodies.map(({ zoneLabel, bodies }) => ( @@ -1314,8 +1330,8 @@ function ItemZonesInfo({ zonesAndTheirBodies }) { ); } -function ItemZonesInfoListItem({ zone, bodies, showBodyInfo }) { - let content = zone.label; +function ItemZonesInfoListItem({ zoneLabel, bodies, showBodyInfo }) { + let content = zoneLabel; if (showBodyInfo) { if (bodies.some((b) => b.representsAllBodies)) { @@ -1332,7 +1348,7 @@ function ItemZonesInfoListItem({ zone, bodies, showBodyInfo }) { content = ( <> {content}{" "} - + body.representsAllBodies); - const bodyCountString = bodies.length.toString().padStart(4, "0"); - return `${representsAllBodies ? "A" : "Z"}-${bodyCountString}-${zone.label}`; + + // To sort by body count _descending_, we subtract it from a large number. + // Then, to make it work in string comparison, we pad it with leading zeroes. + // Hacky but solid! + const inverseBodyCount = (9999 - bodies.length).toString().padStart(4, "0"); + + console.log( + `${representsAllBodies ? "A" : "Z"}-${inverseBodyCount}-${zoneLabel}` + ); + return `${representsAllBodies ? "A" : "Z"}-${inverseBodyCount}-${zoneLabel}`; } /** diff --git a/src/server/types/Item.js b/src/server/types/Item.js index 78321ea..635a793 100644 --- a/src/server/types/Item.js +++ b/src/server/types/Item.js @@ -85,7 +85,13 @@ const typeDefs = gql` # All bodies that this item is compatible with. Note that this might return # the special representsAllPets body, e.g. if this is just a Background! + # Deprecated: Impress 2020 now uses compatibleBodiesAndTheirZones. compatibleBodies: [Body!]! @cacheControl(maxAge: 1, staleWhileRevalidate: ${oneWeek}) + + # All bodies that this item is compatible with, and the zones this item + # occupies for that body. Note that this might return the special + # representsAllPets body, e.g. if this is just a Background! + compatibleBodiesAndTheirZones: [BodyAndZones!]! @cacheControl(maxAge: 1, staleWhileRevalidate: ${oneWeek}) } type ItemAppearance { @@ -97,6 +103,11 @@ const typeDefs = gql` restrictedZones: [Zone!]! } + type BodyAndZones { + body: Body! + zones: [Zone!]! + } + input FitsPetSearchFilter { speciesId: ID! colorId: ID! @@ -404,6 +415,30 @@ const resolvers = { const bodies = bodyIds.map((id) => ({ id })); return bodies; }, + compatibleBodiesAndTheirZones: async ({ id }, _, { db }) => { + const [rows, __] = await db.query( + ` + SELECT + swf_assets.body_id AS bodyId, + (SELECT species_id FROM pet_types WHERE body_id = bodyId LIMIT 1) + AS speciesId, + GROUP_CONCAT(DISTINCT swf_assets.zone_id) AS zoneIds + FROM items + INNER JOIN parents_swf_assets ON + items.id = parents_swf_assets.parent_id AND + parents_swf_assets.parent_type = "Item" + INNER JOIN swf_assets ON + parents_swf_assets.swf_asset_id = swf_assets.id + WHERE items.id = ? + GROUP BY swf_assets.body_id + `, + [id] + ); + return rows.map((row) => ({ + body: { id: row.bodyId, species: { id: row.speciesId } }, + zones: row.zoneIds.split(",").map((zoneId) => ({ id: zoneId })), + })); + }, }, ItemAppearance: {