diff --git a/src/realmModels/Taxon.js b/src/realmModels/Taxon.js index 3676dfb54..50535c75e 100644 --- a/src/realmModels/Taxon.js +++ b/src/realmModels/Taxon.js @@ -160,6 +160,43 @@ class Taxon extends Realm.Object { }; } + /** + * Inverse of mapApiToRealm: maps a live Realm Taxon into the plain, + * snake_case ApiTaxon shape the API and most display/search code expect. + * Live Realm objects expose camelCase mapTo accessors (preferredCommonName, + * defaultPhoto); we read those (falling back to the snake_case keys so a + * plain object passes through unchanged) and return a detached POJO. Use + * this whenever a live Taxon needs to leave the data layer (React/Zustand + * state, async boundaries, child components) so we aren't passing around an + * object Realm can invalidate underneath us. + * + * @param {object} taxon - a live Realm Taxon (or an already-plain object) + * @returns {object} plain ApiTaxon-shaped object + */ + static mapRealmToPojo( taxon ) { + if ( !taxon ) return taxon; + const photo = taxon.defaultPhoto || taxon.default_photo; + return { + id: taxon.id, + name: taxon.name, + rank: taxon.rank, + rank_level: taxon.rank_level, + iconic_taxon_name: taxon.iconic_taxon_name, + ancestor_ids: taxon.ancestor_ids + ? Array.from( taxon.ancestor_ids ) + : undefined, + preferred_common_name: taxon.preferredCommonName ?? taxon.preferred_common_name, + default_photo: photo?.url + ? { + id: photo.id, + url: photo.url, + attribution: photo.attribution, + license_code: photo.license_code ?? photo.licenseCode, + } + : undefined, + }; + } + static uri = item => ( item && item.default_photo ) && { uri: item.default_photo.url }; static schema = { diff --git a/tests/unit/models/Taxon.test.js b/tests/unit/models/Taxon.test.js new file mode 100644 index 000000000..8b3572016 --- /dev/null +++ b/tests/unit/models/Taxon.test.js @@ -0,0 +1,108 @@ +import Taxon from "realmModels/Taxon"; +import safeRealmWrite from "sharedHelpers/safeRealmWrite"; + +describe( "Taxon", ( ) => { + describe( "mapRealmToPojo", ( ) => { + it( "returns the value unchanged when given null or undefined", ( ) => { + expect( Taxon.mapRealmToPojo( null ) ).toBeNull( ); + expect( Taxon.mapRealmToPojo( undefined ) ).toBeUndefined( ); + } ); + + describe( "with an already-plain snake_case object", ( ) => { + const apiTaxon = { + id: 745, + name: "Silphium perfoliatum", + rank: "species", + rank_level: 10, + iconic_taxon_name: "Plantae", + ancestor_ids: [1, 2, 3], + preferred_common_name: "Cup Plant", + default_photo: { + id: 7, + url: "https://example.com/photo.jpg", + attribution: "(c) someone", + license_code: "cc-by", + }, + }; + + it( "passes the snake_case fields through unchanged", ( ) => { + const result = Taxon.mapRealmToPojo( apiTaxon ); + expect( result.id ).toBe( 745 ); + expect( result.name ).toBe( "Silphium perfoliatum" ); + expect( result.rank ).toBe( "species" ); + expect( result.rank_level ).toBe( 10 ); + expect( result.iconic_taxon_name ).toBe( "Plantae" ); + expect( result.preferred_common_name ).toBe( "Cup Plant" ); + } ); + + it( "maps the default_photo fields", ( ) => { + const result = Taxon.mapRealmToPojo( apiTaxon ); + expect( result.default_photo ).toEqual( { + id: 7, + url: "https://example.com/photo.jpg", + attribution: "(c) someone", + license_code: "cc-by", + } ); + } ); + + it( "returns a detached array copy of ancestor_ids", ( ) => { + const result = Taxon.mapRealmToPojo( apiTaxon ); + expect( Array.isArray( result.ancestor_ids ) ).toBe( true ); + expect( result.ancestor_ids ).toEqual( [1, 2, 3] ); + // a copy, not the same reference we passed in + expect( result.ancestor_ids ).not.toBe( apiTaxon.ancestor_ids ); + } ); + } ); + + it( "leaves default_photo undefined when the photo has no url", ( ) => { + const result = Taxon.mapRealmToPojo( { + id: 1, + name: "Life", + default_photo: { id: 9, attribution: "(c) someone" }, + } ); + expect( result.default_photo ).toBeUndefined( ); + } ); + + describe( "with a live Realm Taxon", ( ) => { + let realmTaxon; + + beforeEach( ( ) => { + safeRealmWrite( global.realm, ( ) => { + realmTaxon = global.realm.create( "Taxon", { + id: 123, + name: "Danaus plexippus", + rank: "species", + rank_level: 10, + iconic_taxon_name: "Insecta", + ancestor_ids: [48460, 1, 47120], + // exercise the camelCase mapTo accessors + preferred_common_name: "Monarch", + default_photo: { + id: 555, + url: "https://example.com/monarch.jpg", + attribution: "(c) lepidopterist", + license_code: "cc-by-nc", + }, + } ); + }, "create Taxon for mapRealmToPojo test" ); + } ); + + afterEach( ( ) => { + safeRealmWrite( global.realm, ( ) => { + global.realm.delete( realmTaxon ); + }, "delete Taxon for mapRealmToPojo test" ); + } ); + + it( "returns a detached plain object, not a live Realm object", ( ) => { + const result = Taxon.mapRealmToPojo( realmTaxon ); + // a plain object literal, not a live Realm.Object the database can invalidate + expect( Object.getPrototypeOf( result ) ).toBe( Object.prototype ); + expect( result.id ).toBe( 123 ); + expect( result.name ).toBe( "Danaus plexippus" ); + expect( result.rank ).toBe( "species" ); + expect( result.rank_level ).toBe( 10 ); + expect( result.iconic_taxon_name ).toBe( "Insecta" ); + } ); + } ); + } ); +} );