diff --git a/frontend/initial_props.py b/frontend/initial_props.py index c2f3a6a7e..07e0311bb 100644 --- a/frontend/initial_props.py +++ b/frontend/initial_props.py @@ -9,6 +9,7 @@ ) from .graphql import get_initial_session +from project import contentful # This is changed by test suites to ensure that # everything works okay when the server-side renderer fails @@ -65,6 +66,7 @@ def create_initial_props_for_lambda( "debug": settings.DEBUG, "facebookAppId": settings.FACEBOOK_APP_ID, "nycGeoSearchOrigin": settings.NYC_GEOSEARCH_ORIGIN, + "contentfulCommonStrings": contentful.get_common_strings(), "extraDevLinks": [ dict( name="Mailchimp subscribe API documentation", diff --git a/frontend/lib/app-context.tsx b/frontend/lib/app-context.tsx index e7f67393a..5d351bc65 100644 --- a/frontend/lib/app-context.tsx +++ b/frontend/lib/app-context.tsx @@ -6,6 +6,7 @@ import { buildContextHocFactory } from "./util/context-util"; import { SiteChoice } from "../../common-data/site-choices"; import { SiteRoutes } from "./global-site-routes"; import { LocaleChoice } from "../../common-data/locale-choices"; +import { ContentfulCommonStringsMapping } from "@justfixnyc/contentful-common-strings"; /** Metadata about forms submitted via legacy POST. */ export interface AppLegacyFormSubmission { @@ -174,6 +175,12 @@ export interface AppServerInfo { */ nycGeoSearchOrigin: string; + /** + * Content from Contentful, shared across multiple JustFix properties, + * that have been passed to us from the back-end. + */ + contentfulCommonStrings: ContentfulCommonStringsMapping | null; + /** * Additional links to other development-related links such as * documentation, tooling, etc. diff --git a/frontend/lib/contentful.tsx b/frontend/lib/contentful.tsx new file mode 100644 index 000000000..3eb73dca0 --- /dev/null +++ b/frontend/lib/contentful.tsx @@ -0,0 +1,21 @@ +import { Document } from "@contentful/rich-text-types"; +import { ContentfulCommonStrings } from "@justfixnyc/contentful-common-strings"; +import { useContext } from "react"; + +import { AppContext } from "./app-context"; +import i18n from "./i18n"; + +/** + * Retrieve a common string (one shared across multiple JustFix properties) + * in the current locale from Contentful and return it. + * + * If the string doesn't exist or Contentful integration is disabled, + * `null` will be returned. + */ +export function useContentfulCommonString(key: string): Document | null { + const ccs = new ContentfulCommonStrings( + useContext(AppContext).server.contentfulCommonStrings || {} + ); + + return ccs.get(key, i18n.locale); +} diff --git a/frontend/lib/tests/util.tsx b/frontend/lib/tests/util.tsx index df677a280..1f8439cac 100644 --- a/frontend/lib/tests/util.tsx +++ b/frontend/lib/tests/util.tsx @@ -76,6 +76,7 @@ export const FakeServerInfo: Readonly = { enableWipLocales: false, facebookAppId: "", nycGeoSearchOrigin: "https://myfunky.geosearch.nyc", + contentfulCommonStrings: null, extraDevLinks: [], }; diff --git a/frontend/lib/ui/covid-banners.tsx b/frontend/lib/ui/covid-banners.tsx index 7fcd9c922..3f3bab69c 100644 --- a/frontend/lib/ui/covid-banners.tsx +++ b/frontend/lib/ui/covid-banners.tsx @@ -7,10 +7,14 @@ import { CSSTransition } from "react-transition-group"; import JustfixRoutes from "../justfix-route-info"; import { useDebouncedValue } from "../util/use-debounced-value"; import { SupportedLocaleMap } from "../i18n"; -import { CovidMoratoriumBanner } from "@justfixnyc/react-common"; -import { li18n } from "../i18n-lingui"; import { Trans } from "@lingui/macro"; import { EnglishOutboundLink } from "./localized-outbound-link"; +import { + documentToReactComponents, + RenderNode, +} from "@contentful/rich-text-react-renderer"; +import { useContentfulCommonString } from "../contentful"; +import { INLINES } from "@contentful/rich-text-types"; export const MORATORIUM_FAQ_URL: SupportedLocaleMap = { en: @@ -27,12 +31,20 @@ const getRoutesWithMoratoriumBanner = () => [ JustfixRoutes.locale.home, ]; +const RENDER_NODE: RenderNode = { + [INLINES.HYPERLINK]: (node, children) => ( + + {children} + + ), +}; + /** * This banner is intended to show right below the navbar on certain pages and is a general * overview of how JustFix.nyc is adapting to the COVID-19 crisis and Eviction Moratorium. */ -const MoratoriumBanner = (props: { pathname?: string }) => { +const MoratoriumBanner: React.FC<{ pathname?: string }> = (props) => { // This has to be debounced or it weirdly collides with our loading overlay // that appears when pages need to load JS bundles and such, so we'll add a // short debounce, which seems to obviate this issue. @@ -40,41 +52,42 @@ const MoratoriumBanner = (props: { pathname?: string }) => { props.pathname && getRoutesWithMoratoriumBanner().includes(props.pathname), 10 ); - const [isVisible, setVisibility] = useState(true); - const show = !!includeBanner && isVisible; + const document = useContentfulCommonString("covidMoratoriumBanner"); return ( - -
-
-
- -
- -
-
+ + + ) ); }; diff --git a/package.json b/package.json index 9b25694e3..1bcc2f42d 100644 --- a/package.json +++ b/package.json @@ -85,10 +85,12 @@ "@babel/preset-env": "7.7.6", "@babel/preset-typescript": "7.9.0", "@babel/register": "7.7.4", + "@contentful/rich-text-react-renderer": "15.0.0", + "@contentful/rich-text-types": "15.0.0", "@frontapp/plugin-sdk": "^1.0.1", + "@justfixnyc/contentful-common-strings": "^0.0.2", "@justfixnyc/geosearch-requester": "0.4.0", "@justfixnyc/react-aria-modal": "5.1.0", - "@justfixnyc/react-common": "0.0.12", "@justfixnyc/util": "0.2.0", "@lingui/cli": "2.9.1", "@lingui/macro": "2.9.1", diff --git a/project/contentful.py b/project/contentful.py new file mode 100644 index 000000000..a24c15ba1 --- /dev/null +++ b/project/contentful.py @@ -0,0 +1,74 @@ +import logging +from django.conf import settings +from django.core.cache import cache +from typing import Any, Dict, Optional +import requests + + +logger = logging.getLogger(__name__) + +ORIGIN = "https://cdn.contentful.com" + +CommonStrings = Dict[str, Any] + + +def _to_common_strings_map(raw: Any) -> CommonStrings: + """ + Converts the given raw Contentful API result and converts + it into a Contentful common strings mapping. + + This is the Python version of the `toCommonStringsMap` + function from the following TypeScript code: + + https://github.com/JustFixNYC/justfix-ts/blob/master/packages/contentful-common-strings/src/fetch-common-strings.ts + """ + + result: CommonStrings = {} + + for item in raw["items"]: + fields = item["fields"] + key = fields.get("id", {}).get("en") + value = fields.get("value") + if key and value: + result[key] = value + + return result + + +def get_common_strings() -> Optional[CommonStrings]: + """ + Fetches Contentful common strings and returns them. + + Caching is used to ensure that we don't trigger Contentful's rate + limiting or cause undue latency. + + If Contentful integration is disabled, or if a network error + occurs and we don't have a cached value, returns `None`. + """ + + if not (settings.CONTENTFUL_ACCESS_TOKEN and settings.CONTENTFUL_SPACE_ID): + # Contentful integration is disabled. + return None + + cache_key = f"contentful_common_strings.{settings.CONTENTFUL_SPACE_ID}" + + result = cache.get(cache_key) + + if result is None: + try: + response = requests.get( + f"{ORIGIN}/spaces/{settings.CONTENTFUL_SPACE_ID}/entries", + { + "access_token": settings.CONTENTFUL_ACCESS_TOKEN, + "locale": "*", + "metadata.tags.sys.id[in]": settings.CONTENTFUL_COMMON_STRING_TAG, + }, + timeout=settings.CONTENTFUL_TIMEOUT, + ) + response.raise_for_status() + result = _to_common_strings_map(response.json()) + cache.set(cache_key, result, settings.CONTENTFUL_CACHE_TIMEOUT) + except Exception: + logger.exception(f"Error while retrieving data from {ORIGIN}") + + return result diff --git a/project/justfix_environment.py b/project/justfix_environment.py index ce357d968..4bf094a80 100644 --- a/project/justfix_environment.py +++ b/project/justfix_environment.py @@ -396,6 +396,10 @@ class JustfixEnvironment(typed_environ.BaseEnvironment): # The origin of the NYC GeoSearch API. NYC_GEOSEARCH_ORIGIN: str = "https://geosearch.planninglabs.nyc" + CONTENTFUL_SPACE_ID: str = "" + + CONTENTFUL_ACCESS_TOKEN: str = "" + class JustfixBuildPipelineDefaults(JustfixEnvironment): """ diff --git a/project/settings.py b/project/settings.py index 0f4131626..00a37c5f1 100644 --- a/project/settings.py +++ b/project/settings.py @@ -424,6 +424,16 @@ GEOCODING_TIMEOUT = 8 +CONTENTFUL_SPACE_ID = env.CONTENTFUL_SPACE_ID + +CONTENTFUL_ACCESS_TOKEN = env.CONTENTFUL_ACCESS_TOKEN + +CONTENTFUL_COMMON_STRING_TAG = "common" + +CONTENTFUL_TIMEOUT = 3 + +CONTENTFUL_CACHE_TIMEOUT = 5 + GA_TRACKING_ID = env.GA_TRACKING_ID GTM_CONTAINER_ID = env.GTM_CONTAINER_ID diff --git a/project/settings_pytest.py b/project/settings_pytest.py index 55efda822..68fb50c6a 100644 --- a/project/settings_pytest.py +++ b/project/settings_pytest.py @@ -24,6 +24,8 @@ # Disable a bunch of third-party integrations by default. GEOCODING_SEARCH_URL = "" +CONTENTFUL_SPACE_ID = "" +CONTENTFUL_ACCESS_TOKEN = "" AIRTABLE_API_KEY = "" AIRTABLE_URL = "" SLACK_WEBHOOK_URL = "" diff --git a/project/tests/test_contentful.py b/project/tests/test_contentful.py new file mode 100644 index 000000000..950544625 --- /dev/null +++ b/project/tests/test_contentful.py @@ -0,0 +1,94 @@ +import pytest +from django.core.cache import cache + +from project.contentful import get_common_strings + + +ENTRIES_URL = "https://cdn.contentful.com/spaces/myspaceid/entries" + +CONTENTFUL_DOC = { + "nodeType": "document", + "data": {}, + "content": [ + { + "nodeType": "paragraph", + "content": [ + { + "nodeType": "text", + "value": "Hello!", + "marks": [], + "data": {}, + }, + ], + }, + ], +} + +RAW_ENTRIES_RESPONSE = { + "sys": {"type": "Array"}, + "total": 1, + "skip": 0, + "limit": 100, + "items": [ + { + "metadata": {"tags": [{"sys": {"type": "Link", "linkType": "Tag", "id": "common"}}]}, + "sys": { + "space": {"sys": {"type": "Link", "linkType": "Space", "id": "markmr2gi204"}}, + "id": "6JHYqWl0h2QWvObWQfNH4m", + "type": "Entry", + "createdAt": "2021-06-16T11:01:57.811Z", + "updatedAt": "2021-06-16T12:44:37.768Z", + "environment": {"sys": {"id": "master", "type": "Link", "linkType": "Environment"}}, + "revision": 6, + "contentType": {"sys": {"type": "Link", "linkType": "ContentType", "id": "string"}}, + }, + "fields": { + "id": {"en": "covidMoratoriumBanner"}, + "value": { + "en": CONTENTFUL_DOC, + }, + }, + } + ], +} + + +@pytest.fixture +def enabled(settings): + settings.CONTENTFUL_ACCESS_TOKEN = "myaccesstoken" + settings.CONTENTFUL_SPACE_ID = "myspaceid" + + +class TestGetCommonStrings: + def setup(self): + cache.clear() + + def test_it_returns_none_when_disabled(self): + assert get_common_strings() is None + + def test_it_returns_none_when_enabled_but_err_occurs(self, enabled, requests_mock): + requests_mock.get(ENTRIES_URL, status_code=500) + assert get_common_strings() is None + + def test_it_works(self, enabled, requests_mock): + requests_mock.get(ENTRIES_URL, json=RAW_ENTRIES_RESPONSE) + assert get_common_strings() == {"covidMoratoriumBanner": {"en": CONTENTFUL_DOC}} + + def test_it_caches_result(self, enabled, requests_mock): + requests_mock.get(ENTRIES_URL, json=RAW_ENTRIES_RESPONSE) + + strings = get_common_strings() + assert strings is not None + + requests_mock.get(ENTRIES_URL, status_code=500) + + assert get_common_strings() == strings + + def test_it_does_not_cache_errors(self, enabled, requests_mock): + requests_mock.get(ENTRIES_URL, status_code=500) + + assert get_common_strings() is None + + requests_mock.get(ENTRIES_URL, json=RAW_ENTRIES_RESPONSE) + + assert get_common_strings() is not None diff --git a/yarn.lock b/yarn.lock index 0fb8003a0..07ba2b6b8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1114,6 +1114,18 @@ exec-sh "^0.3.2" minimist "^1.2.0" +"@contentful/rich-text-react-renderer@15.0.0": + version "15.0.0" + resolved "https://registry.yarnpkg.com/@contentful/rich-text-react-renderer/-/rich-text-react-renderer-15.0.0.tgz#3b9be3587af4c54f033383fcb1731340de14d210" + integrity sha512-59NwFkyVyCAJAPilXrNlOjN6y4EZo+h3KLHwREVy8Lo+Fl03GZx5LUb3pijiM4MFdVxByvUiRyRA5VJFzbGX3w== + dependencies: + "@contentful/rich-text-types" "^15.0.0" + +"@contentful/rich-text-types@15.0.0", "@contentful/rich-text-types@^15.0.0": + version "15.0.0" + resolved "https://registry.yarnpkg.com/@contentful/rich-text-types/-/rich-text-types-15.0.0.tgz#bd676960dea3e4b5a56fd6653d8cc800a81d6bca" + integrity sha512-e7aXF/BhXPOpLW+PsbD57Fyt0meiRYT3nTct2iLGa2WYRZMM6E+clnCAXhSRBQ8qhAso7SSUGeiBMxPeaO3KKQ== + "@discoveryjs/json-ext@^0.5.0": version "0.5.2" resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.2.tgz#8f03a22a04de437254e8ce8cc84ba39689288752" @@ -1375,6 +1387,15 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" +"@justfixnyc/contentful-common-strings@^0.0.2": + version "0.0.2" + resolved "https://registry.yarnpkg.com/@justfixnyc/contentful-common-strings/-/contentful-common-strings-0.0.2.tgz#0f5a08e0f4b8033f938cdb0562343fdca3ebc22f" + integrity sha512-mc14thTXZ/wAUCvh0tbkcgVUNVncQejgz6eER6gq2MpB2/p846ChqK+D4CQygq+nCv37ACmOgWywlG4YBdzygQ== + dependencies: + "@contentful/rich-text-types" "^15.0.0" + cross-fetch "^3.1.4" + yargs "^17.0.1" + "@justfixnyc/geosearch-requester@0.4.0": version "0.4.0" resolved "https://registry.yarnpkg.com/@justfixnyc/geosearch-requester/-/geosearch-requester-0.4.0.tgz#3bf36996eddcc9545d2f8a6793bb14ccaa0016d7" @@ -1385,11 +1406,6 @@ resolved "https://registry.yarnpkg.com/@justfixnyc/react-aria-modal/-/react-aria-modal-5.1.0.tgz#edbd9f8432528d0702ae32a38bedc0e06bb1e30c" integrity sha512-b9YIw7azL273CkRczighjuAb0Qtd7RM4f0NjmYCf3PT7opHkRpAUuhi2KZZAPeVY2Eg0MgzZo+h/ZwsAAF9BQQ== -"@justfixnyc/react-common@0.0.12": - version "0.0.12" - resolved "https://registry.yarnpkg.com/@justfixnyc/react-common/-/react-common-0.0.12.tgz#9a0ca76848bf9d6d0be942e8539dd88dc6174a52" - integrity sha512-c9nGEI3Wtt20/495VMU7LqtxQ8pyqNHtjip7kN7CY3NlzGGFJOMz3cr8VurwmpgvHN/77EPnjOU8WkNEeqkcSw== - "@justfixnyc/util@0.2.0": version "0.2.0" resolved "https://registry.yarnpkg.com/@justfixnyc/util/-/util-0.2.0.tgz#82cf176a5ab5bf266008c16a3d5a492e9cc953b8" @@ -3640,6 +3656,15 @@ cliui@^6.0.0: strip-ansi "^6.0.0" wrap-ansi "^6.2.0" +cliui@^7.0.2: + version "7.0.4" + resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" + integrity sha512-OcRE68cOsVMXp1Yvonl/fzkQOyjLSu/8bhPDfQt0e0/Eb283TKP20Fs2MqoPsr9SwA595rRCA+QMzYc9nBP+JQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.0" + wrap-ansi "^7.0.0" + clone-deep@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/clone-deep/-/clone-deep-4.0.1.tgz#c19fd9bdbbf85942b4fd979c84dcf7d5f07c2387" @@ -3874,6 +3899,13 @@ cross-fetch@3.0.6: dependencies: node-fetch "2.6.1" +cross-fetch@^3.1.4: + version "3.1.4" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.4.tgz#9723f3a3a247bf8b89039f3a380a9244e8fa2f39" + integrity sha512-1eAtFWdIubi6T4XPy6ei9iUFoKpUkIF971QLN8lIvvvwueI65+Nw5haMNKUwfJxabqlIIDODJKGrQ66gxC0PbQ== + dependencies: + node-fetch "2.6.1" + cross-spawn@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-3.0.1.tgz#1256037ecb9f0c5f79e3d6ef135e30770184b982" @@ -5021,7 +5053,7 @@ gensync@^1.0.0-beta.1: resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.1.tgz#58f4361ff987e5ff6e1e7a210827aa371eaac269" integrity sha512-r8EC6NO1sngH/zdD9fiRDLdcgnbayXah+mLgManTaIZJqEC1MZstmnox8KpnI2/fxQwrp5OpCOYWLp4rBl4Jcg== -get-caller-file@^2.0.1: +get-caller-file@^2.0.1, get-caller-file@^2.0.5: version "2.0.5" resolved "https://registry.yarnpkg.com/get-caller-file/-/get-caller-file-2.0.5.tgz#4f94412a82db32f36e3b0b9741f8a97feb031f7e" integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== @@ -9946,6 +9978,15 @@ wrap-ansi@^6.2.0: string-width "^4.1.0" strip-ansi "^6.0.0" +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-7.0.0.tgz#67e145cff510a6a6984bdf1152911d69d2eb9e43" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -9993,6 +10034,11 @@ y18n@^4.0.0: resolved "https://registry.yarnpkg.com/y18n/-/y18n-4.0.1.tgz#8db2b83c31c5d75099bb890b23f3094891e247d4" integrity sha512-wNcy4NvjMYL8gogWWYAO7ZFWFfHcbdbE57tZO8e4cbpj8tfUcwrwqSl3ad8HxpYWCdXcJUCeKKZS62Av1affwQ== +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.yarnpkg.com/y18n/-/y18n-5.0.8.tgz#7f4934d0f7ca8c56f95314939ddcd2dd91ce1d55" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + yallist@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" @@ -10026,6 +10072,11 @@ yargs-parser@^18.1.2: camelcase "^5.0.0" decamelize "^1.2.0" +yargs-parser@^20.2.2: + version "20.2.9" + resolved "https://registry.yarnpkg.com/yargs-parser/-/yargs-parser-20.2.9.tgz#2eb7dc3b0289718fc295f362753845c41a0c94ee" + integrity sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w== + yargs@^13.3.0, yargs@^13.3.2: version "13.3.2" resolved "https://registry.yarnpkg.com/yargs/-/yargs-13.3.2.tgz#ad7ffefec1aa59565ac915f82dccb38a9c31a2dd" @@ -10059,6 +10110,19 @@ yargs@^15.4.1: y18n "^4.0.0" yargs-parser "^18.1.2" +yargs@^17.0.1: + version "17.0.1" + resolved "https://registry.yarnpkg.com/yargs/-/yargs-17.0.1.tgz#6a1ced4ed5ee0b388010ba9fd67af83b9362e0bb" + integrity sha512-xBBulfCc8Y6gLFcrPvtqKz9hz8SO0l1Ni8GgDekvBX2ro0HRQImDGnikfc33cgzcYUSncapnNcZDjVFIH3f6KQ== + dependencies: + cliui "^7.0.2" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.0" + y18n "^5.0.5" + yargs-parser "^20.2.2" + yarn@^1.21.1: version "1.22.4" resolved "https://registry.yarnpkg.com/yarn/-/yarn-1.22.4.tgz#01c1197ca5b27f21edc8bc472cd4c8ce0e5a470e"