Skip to content

Commit

Permalink
Pull content for moratorium banner from Contentful. (#2125)
Browse files Browse the repository at this point in the history
This is an attempt to try pulling content for the Covid moratorium banner text directly from Contentful, so that we don't need to push a change to this repository whenever we want to change the text of the banner.

It currently uses the new [`@justfixnyc/contentful-common-strings`](https://www.npmjs.com/package/@justfixnyc/contentful-common-strings) package to help accomplish this.

## How it works

The tenant platform now optionally supports retrieving "common strings" from Contentful.  By "common strings" we really mean "common [rich text](https://www.contentful.com/developers/docs/concepts/rich-text/) strings".

Each common string has an alphanumeric id and a value.  The value is a localizable rich text value.  For example, in this PR, the common string with id `covidMoratoriumBanner` is expected to have a value containing the rich text to show users about the current state of the COVID Moratorium, localized in both English and Spanish.

The platform retrieves all entries from its configured Contentful space [tagged](https://www.contentful.com/help/tags/) `common`.  Each of these entries is expected to have a short text field called `id` and a localized rich text field called `value`.  

The common strings are made available to front-end code,.  A helper function on the front-end makes it convenient to retrieve a common string's rich text document representation by its id.  The front-end can then render them using the `@contentful/rich-text-react-renderer` package.

Note that at present, _all_ locales of the common strings are passed to the front-end, which means that there's a slight amount of bloat--but it shouldn't be _too_ bad since we have very few common strings.

The back-end caches the common strings from Contentful for a short period of time--at the time of this writing, it's 5 seconds--to ensure minimal latency during periods of high load.  (Note that the back-end is responsible for retrieving the Contentful strings to ensure that they are properly rendered during server-side rendering.)
  • Loading branch information
toolness authored Jul 13, 2021
1 parent 88227f3 commit bc47ac8
Show file tree
Hide file tree
Showing 12 changed files with 333 additions and 39 deletions.
2 changes: 2 additions & 0 deletions frontend/initial_props.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions frontend/lib/app-context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<FormInput = any, FormOutput = any> {
Expand Down Expand Up @@ -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.
Expand Down
21 changes: 21 additions & 0 deletions frontend/lib/contentful.tsx
Original file line number Diff line number Diff line change
@@ -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);
}
1 change: 1 addition & 0 deletions frontend/lib/tests/util.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export const FakeServerInfo: Readonly<AppServerInfo> = {
enableWipLocales: false,
facebookAppId: "",
nycGeoSearchOrigin: "https://myfunky.geosearch.nyc",
contentfulCommonStrings: null,
extraDevLinks: [],
};

Expand Down
77 changes: 45 additions & 32 deletions frontend/lib/ui/covid-banners.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> = {
en:
Expand All @@ -27,54 +31,63 @@ const getRoutesWithMoratoriumBanner = () => [
JustfixRoutes.locale.home,
];

const RENDER_NODE: RenderNode = {
[INLINES.HYPERLINK]: (node, children) => (
<a rel="noreferrer noopener" target="_blank" href={node.data.uri}>
{children}
</a>
),
};

/**
* 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.
const includeBanner = useDebouncedValue(
props.pathname && getRoutesWithMoratoriumBanner().includes(props.pathname),
10
);

const [isVisible, setVisibility] = useState(true);

const show = !!includeBanner && isVisible;
const document = useContentfulCommonString("covidMoratoriumBanner");

return (
<CSSTransition
in={show}
unmountOnExit
classNames="jf-slide-500px-200ms"
timeout={200}
>
<section
className={classnames(
"jf-moratorium-banner",
"hero",
"is-warning",
"is-small"
)}
document && (
<CSSTransition
in={show}
unmountOnExit
classNames="jf-slide-500px-200ms"
timeout={200}
>
<div className="hero-body">
<div className="container">
<SimpleProgressiveEnhancement>
<button
className="delete is-medium is-pulled-right"
onClick={() => setVisibility(false)}
/>
</SimpleProgressiveEnhancement>
<p>
<CovidMoratoriumBanner locale={li18n.language} />
</p>
<section
className={classnames(
"jf-moratorium-banner",
"hero",
"is-warning",
"is-small"
)}
>
<div className="hero-body">
<div className="container">
<SimpleProgressiveEnhancement>
<button
className="delete is-medium is-pulled-right"
onClick={() => setVisibility(false)}
/>
</SimpleProgressiveEnhancement>
{documentToReactComponents(document, {
renderNode: RENDER_NODE,
})}
</div>
</div>
</div>
</section>
</CSSTransition>
</section>
</CSSTransition>
)
);
};

Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
74 changes: 74 additions & 0 deletions project/contentful.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 4 additions & 0 deletions project/justfix_environment.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand Down
10 changes: 10 additions & 0 deletions project/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions project/settings_pytest.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down
94 changes: 94 additions & 0 deletions project/tests/test_contentful.py
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit bc47ac8

Please sign in to comment.