diff --git a/src/amo/constants.js b/src/amo/constants.js index 3534b3dd09..e564183904 100644 --- a/src/amo/constants.js +++ b/src/amo/constants.js @@ -327,9 +327,10 @@ export const RECOMMENDED = 'recommended'; export const SPOTLIGHT = 'spotlight'; export const STRATEGIC = 'strategic'; +// This array is sorted by "importance". export const ALL_PROMOTED_CATEGORIES = [ - LINE, RECOMMENDED, + LINE, SPOTLIGHT, STRATEGIC, ]; diff --git a/src/amo/reducers/addons.js b/src/amo/reducers/addons.js index 805b0b5def..ab182ebbce 100644 --- a/src/amo/reducers/addons.js +++ b/src/amo/reducers/addons.js @@ -11,6 +11,7 @@ import type { UpdateRatingCountsAction, } from 'amo/actions/reviews'; import { + makeInternalPromoted, selectLocalizedContent, selectCategoryObject, } from 'amo/reducers/utils'; @@ -236,7 +237,7 @@ export function createInternalAddon( previews: apiAddon.previews ? createInternalPreviews(apiAddon.previews, lang) : undefined, - promoted: apiAddon.promoted, + promoted: makeInternalPromoted(apiAddon.promoted), ratings: apiAddon.ratings, requires_payment: apiAddon.requires_payment, review_url: apiAddon.review_url, diff --git a/src/amo/reducers/autocomplete.js b/src/amo/reducers/autocomplete.js index 20b9dd9767..b119ec2dd0 100644 --- a/src/amo/reducers/autocomplete.js +++ b/src/amo/reducers/autocomplete.js @@ -3,7 +3,10 @@ import invariant from 'invariant'; import { getAddonIconUrl } from 'amo/imageUtils'; import { SET_LANG } from 'amo/reducers/api'; -import { selectLocalizedContent } from 'amo/reducers/utils'; +import { + makeInternalPromoted, + selectLocalizedContent, +} from 'amo/reducers/utils'; import type { PromotedType } from 'amo/types/addons'; import type { LocalizedString } from 'amo/types/api'; @@ -18,7 +21,7 @@ export type ExternalSuggestion = {| icon_url: string, id: number, name: LocalizedString, - promoted: PromotedType | null, + promoted: Array | PromotedType | null, type: string, url: string, |}; @@ -27,7 +30,7 @@ export type SuggestionType = {| addonId: number, iconUrl: string, name: string, - promoted: PromotedType | null, + promoted: Array, type: string, url: string, |}; @@ -108,7 +111,7 @@ export const createInternalSuggestion = ( addonId: externalSuggestion.id, iconUrl: getAddonIconUrl(externalSuggestion), name: selectLocalizedContent(externalSuggestion.name, lang), - promoted: externalSuggestion.promoted, + promoted: makeInternalPromoted(externalSuggestion.promoted), type: externalSuggestion.type, url: externalSuggestion.url, }; diff --git a/src/amo/reducers/utils.js b/src/amo/reducers/utils.js index 11d3035dbd..ed2c93537d 100644 --- a/src/amo/reducers/utils.js +++ b/src/amo/reducers/utils.js @@ -3,7 +3,7 @@ import invariant from 'invariant'; import type { LocalizedString } from 'amo/types/api'; import type { CategoryEntry } from './categories'; -import type { ExternalAddonType } from '../types/addons'; +import type { ExternalAddonType, PromotedType } from '../types/addons'; export const selectLocalizedContent = ( field: LocalizedString, @@ -26,3 +26,12 @@ export const selectCategoryObject = ( ): CategoryEntry => { return apiAddon.categories; }; + +export const makeInternalPromoted = ( + promoted: Array | PromotedType | null, +): Array => { + if (!promoted) { + return []; + } + return Array.isArray(promoted) ? promoted : [promoted]; +}; diff --git a/src/amo/types/addons.js b/src/amo/types/addons.js index 11f80a972a..d38a077a83 100644 --- a/src/amo/types/addons.js +++ b/src/amo/types/addons.js @@ -133,7 +133,7 @@ export type ExternalAddonType = {| locale_disambiguation?: string, name: LocalizedString, previews?: Array, - promoted: PromotedType | null, + promoted: Array | PromotedType | null, ratings: {| average: number, bayesian_average: number, @@ -174,6 +174,8 @@ export type AddonType = {| summary: string | null, support_email: string | null, support_url: UrlWithOutgoing | null, + // normalized promoted categories, + promoted: Array, // Here are some custom properties for our internal representation. currentVersionId: VersionIdType | null, isMozillaSignedExtension: boolean, diff --git a/src/amo/utils/addons.js b/src/amo/utils/addons.js index 74ca855288..f91368f0fe 100644 --- a/src/amo/utils/addons.js +++ b/src/amo/utils/addons.js @@ -10,6 +10,7 @@ import { FATAL_INSTALL_ERROR, FATAL_UNINSTALL_ERROR, INSTALL_FAILED, + ALL_PROMOTED_CATEGORIES, } from 'amo/constants'; import log from 'amo/logger'; import { getPreviewImage } from 'amo/imageUtils'; @@ -121,15 +122,25 @@ export const getPromotedCategory = ({ clientApp: string, forBadging?: boolean, |}): PromotedCategoryType | null => { - let category = null; - if (addon && addon.promoted && addon.promoted.apps.includes(clientApp)) { - category = addon.promoted.category; + if (!addon?.promoted) { + return null; } - // Special logic if we're using the category for badging. - if (forBadging && !BADGE_CATEGORIES.includes(category)) { - category = null; - } + const categories: Array = addon.promoted + .filter((promoted) => { + if (!promoted.apps.includes(clientApp)) { + return false; + } + // Special logic if we're using the category for badging. + // We shouldn't add badges that are in BADGE_CATEGORIES. + return forBadging ? BADGE_CATEGORIES.includes(promoted.category) : true; + }) + .map((promoted) => promoted.category) + .sort( + (a, b) => + ALL_PROMOTED_CATEGORIES.indexOf(a) - ALL_PROMOTED_CATEGORIES.indexOf(b), + ); - return category; + // Return only the 'most important' badge. + return categories.shift() || null; }; diff --git a/tests/unit/amo/pages/TestAddon.js b/tests/unit/amo/pages/TestAddon.js index 0769d4037d..e5039b5e91 100644 --- a/tests/unit/amo/pages/TestAddon.js +++ b/tests/unit/amo/pages/TestAddon.js @@ -27,6 +27,9 @@ import { INCOMPATIBLE_UNSUPPORTED_PLATFORM, INSTALLING, RECOMMENDED, + LINE, + SPOTLIGHT, + STRATEGIC, REVIEWER_TOOLS_VIEW, SET_VIEW_CONTEXT, STATIC_THEMES_REVIEW, @@ -2965,6 +2968,18 @@ describe(__filename, () => { }, ); + it('does not render the strategic or spotlight badges and correctly renders only the most important badge (RECOMMENDED)', () => { + const categories = [LINE, RECOMMENDED, STRATEGIC, SPOTLIGHT]; + addon.promoted = categories.map((category) => ({ + category, + apps: [clientApp], + })); + renderWithAddon(); + const badges = screen.getAllByClassName('PromotedBadge'); + expect(badges).toHaveLength(1); + expect(badges[0]).toHaveClass(`PromotedBadge--recommended`); + }); + // See https://github.com/mozilla/addons-frontend/issues/8285. it('does not pass an alt property to IconPromotedBadge', () => { renderWithPromotedCategory(); diff --git a/tests/unit/amo/reducers/test_autocomplete.js b/tests/unit/amo/reducers/test_autocomplete.js index 237ac924fc..a9ca33f431 100644 --- a/tests/unit/amo/reducers/test_autocomplete.js +++ b/tests/unit/amo/reducers/test_autocomplete.js @@ -87,7 +87,7 @@ describe(__filename, () => { addonId: result.id, iconUrl: result.icon_url, name, - promoted, + promoted: [promoted], url: result.url, }, ]); diff --git a/tests/unit/amo/reducers/test_utils.js b/tests/unit/amo/reducers/test_utils.js index a1a29b68aa..531f3f1a51 100644 --- a/tests/unit/amo/reducers/test_utils.js +++ b/tests/unit/amo/reducers/test_utils.js @@ -1,4 +1,8 @@ -import { selectLocalizedContent } from 'amo/reducers/utils'; +import { + selectLocalizedContent, + makeInternalPromoted, +} from 'amo/reducers/utils'; +import { CLIENT_APP_FIREFOX, RECOMMENDED } from 'amo/constants'; describe(__filename, () => { describe('selectLocalizedContent', () => { @@ -33,4 +37,24 @@ describe(__filename, () => { ).toEqual(expected); }); }); + + describe('makeInternalPromoted', () => { + it('returns the empty list if promoted is null', () => { + expect(makeInternalPromoted(null)).toEqual([]); + }); + + it('returns the empty list if promoted is empty', () => { + expect(makeInternalPromoted([])).toEqual([]); + }); + + it('returns promoted if promoted is a list', () => { + const promoted = [{ category: RECOMMENDED, apps: [CLIENT_APP_FIREFOX] }]; + expect(makeInternalPromoted(promoted)).toEqual(promoted); + }); + + it('returns promoted in a list if promoted is an object', () => { + const promoted = { category: RECOMMENDED, apps: [CLIENT_APP_FIREFOX] }; + expect(makeInternalPromoted(promoted)).toEqual([promoted]); + }); + }); }); diff --git a/tests/unit/amo/utils/test_addons.js b/tests/unit/amo/utils/test_addons.js index d5ed0cef99..c74341c4c5 100644 --- a/tests/unit/amo/utils/test_addons.js +++ b/tests/unit/amo/utils/test_addons.js @@ -249,6 +249,88 @@ describe(__filename, () => { ).toEqual(category); }); + it('returns only the most important category if the addon is promoted in multiple categories for the specified app', () => { + const categories = [SPOTLIGHT, STRATEGIC, RECOMMENDED]; + const promoted = categories.map((category) => ({ + category, + apps: [CLIENT_APP_ANDROID], + })); + + const addon = createInternalAddonWithLang({ + ...fakeAddon, + promoted, + }); + const suggestion = createInternalSuggestionWithLang( + createFakeAutocompleteResult({ + promoted, + }), + ); + + expect( + getPromotedCategory({ + addon, + clientApp: CLIENT_APP_ANDROID, + }), + ).toEqual(RECOMMENDED); + expect( + getPromotedCategory({ + addon: suggestion, + clientApp: CLIENT_APP_ANDROID, + }), + ).toEqual(RECOMMENDED); + }); + + it('returns null if the addon is promoted in multiple categories, but not for the specified app', () => { + const categories = [RECOMMENDED, SPOTLIGHT, STRATEGIC]; + const promoted = categories.map((category) => ({ + category, + apps: [CLIENT_APP_FIREFOX], + })); + + const addon = createInternalAddonWithLang({ + ...fakeAddon, + promoted, + }); + const suggestion = createInternalSuggestionWithLang( + createFakeAutocompleteResult({ + promoted, + }), + ); + + expect( + getPromotedCategory({ + addon, + clientApp: CLIENT_APP_ANDROID, + }), + ).toEqual(null); + expect( + getPromotedCategory({ + addon: suggestion, + clientApp: CLIENT_APP_ANDROID, + }), + ).toEqual(null); + }); + + it('returns null if the addon is not promoted via empty list', () => { + const addon = createInternalAddonWithLang({ + ...fakeAddon, + promoted: [], + }); + const suggestion = createInternalSuggestionWithLang( + createFakeAutocompleteResult({ promoted: [] }), + ); + + expect( + getPromotedCategory({ addon, clientApp: CLIENT_APP_ANDROID }), + ).toEqual(null); + expect( + getPromotedCategory({ + addon: suggestion, + clientApp: CLIENT_APP_ANDROID, + }), + ).toEqual(null); + }); + describe('forBadging === true', () => { it.each([SPOTLIGHT, STRATEGIC])( 'returns null if the category is not one for badges, category: %s',