Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[#173171577] Extract front-matter CTA data from message content #1936

Merged
merged 10 commits into from
Jun 23, 2020
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"color": "^3.0.0",
"date-fns": "^1.29.0",
"fp-ts": "1.12.0",
"front-matter": "^4.0.2",
"hoist-non-react-statics": "^3.0.1",
"instabug-reactnative": "8.7.0",
"io-ts": "1.8.5",
Expand Down
2 changes: 1 addition & 1 deletion ts/components/ui/Markdown/handlers/internalLink.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ function replaceOldRoute(routeName: string): string {
}
}

function getInternalRoute(href: string): Option<string> {
export function getInternalRoute(href: string): Option<string> {
return some(href.split(IO_INTERNAL_LINK_PREFIX))
.filter(_ => _.length === 2 && _[0] === "")
.chain(_ =>
Expand Down
28 changes: 28 additions & 0 deletions ts/types/MessageCTA.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* this type models 2 cta that could be nested inside the markdown content of a message
* see https://www.pivotaltracker.com/story/show/173171577
*/
import * as t from "io-ts";

const CTA = t.interface({
text: t.string,
action: t.string
});
export type CTA = t.TypeOf<typeof CTA>;
const CTASR = t.interface({
cta_1: CTA
});

const CTASO = t.partial({
cta_2: CTA
});

export const CTAS = t.intersection([CTASR, CTASO], "CTAS");

const MessageCTA = t.partial({
it: CTAS,
en: CTAS
});
export type CTAS = t.TypeOf<typeof CTAS>;

export type MessageCTA = t.TypeOf<typeof MessageCTA>;
210 changes: 210 additions & 0 deletions ts/utils/__tests__/messages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,210 @@
import { Option } from "fp-ts/lib/Option";
import { CreatedMessageWithContent } from "../../../definitions/backend/CreatedMessageWithContent";
import { FiscalCode } from "../../../definitions/backend/FiscalCode";
import { MessageBodyMarkdown } from "../../../definitions/backend/MessageBodyMarkdown";
import { MessageContent } from "../../../definitions/backend/MessageContent";
import { Timestamp } from "../../../definitions/backend/Timestamp";
import { CTA, CTAS } from "../../types/MessageCTA";
import { cleanMarkdownFromCTAs, getCTA, isCtaActionValid } from "../messages";

const messageBody = `### this is a message

this is a body`;

const CTA_2 =
`---
it:
cta_1:
text: "premi"
action: "io://PROFILE_MAIN"
cta_2:
text: "premi2"
action: "io://PROFILE_MAIN2"
en:
cta_1:
text: "go1"
action: "io://PROFILE_MAIN"
cta_2:
text: "go2"
action: "io://PROFILE_MAIN2"
---
` + messageBody;

const messageWithContent = {
created_at: new Date() as Timestamp,
fiscal_code: "RSSMRA83A12H501D" as FiscalCode,
id: "93726BD8-D29C-48F2-AE6D-2F",
sender_service_id: "dev-service_0",
time_to_live: 3600,
content: {
subject: "Subject - test 1",
markdown: CTA_2,
due_date: new Date() as Timestamp,
payment_data: {
notice_number: "012345678912345678",
amount: 406,
invalid_after_due_date: false
}
} as MessageContent
} as CreatedMessageWithContent;

describe("getCTA", () => {
it("should have 2 valid CTA", () => {
const maybeCTAs = getCTA(messageWithContent, "it");
test2CTA(
maybeCTAs,
"premi",
"io://PROFILE_MAIN",
"premi2",
"io://PROFILE_MAIN2"
);
const maybeCTAsEn = getCTA(messageWithContent, "en");
test2CTA(
maybeCTAsEn,
"go1",
"io://PROFILE_MAIN",
"go2",
"io://PROFILE_MAIN2"
);
});

it("should have 1 valid CTA", () => {
const CTA_1 = `---
it:
cta_1:
text: "premi"
action: "io://PROFILE_MAIN"
---
some noise`;

const maybeCTA = getCTA(
{
...messageWithContent,
content: {
...messageWithContent.content,
markdown: CTA_1 as MessageBodyMarkdown
}
},
"it"
);
expect(maybeCTA.isSome()).toBeTruthy();
if (maybeCTA.isSome()) {
const ctas = maybeCTA.value;
expect(ctas.cta_1).toBeDefined();
expect(ctas.cta_2).not.toBeDefined();
}

const maybeCTAEn = getCTA(
{
...messageWithContent,
content: {
...messageWithContent.content,
markdown: CTA_1 as MessageBodyMarkdown
}
},
"en"
);
expect(maybeCTAEn.isNone()).toBeTruthy();
});

it("should not have a valid CTA", () => {
const maybeCTA = getCTA(
{
...messageWithContent,
content: {
...messageWithContent.content,
markdown: "nothing of nothing" as MessageBodyMarkdown
}
},
"it"
);
expect(maybeCTA.isNone()).toBeTruthy();
});
});

const test2CTA = (
maybeCTAS: Option<CTAS>,
text1: string,
action1: string,
text2: string,
action2: string
) => {
expect(maybeCTAS.isSome()).toBeTruthy();
if (maybeCTAS.isSome()) {
const ctas = maybeCTAS.value;
expect(ctas.cta_1).toBeDefined();
expect(ctas.cta_2).toBeDefined();
expect(ctas.cta_1.text).toEqual(text1);
expect(ctas.cta_1.action).toEqual(action1);
if (ctas.cta_2) {
expect(ctas.cta_2.text).toEqual(text2);
expect(ctas.cta_2.action).toEqual(action2);
}
}
};

describe("isCtaActionValid", () => {
it("should be a valid internal navigation action", async () => {
const valid: CTA = { text: "dummy", action: "ioit://PROFILE_MAIN" };
const isValid = await isCtaActionValid(valid);
expect(isValid).toBeTruthy();
});

it("should be not valid (wrong protocol)", async () => {
const invalidProtocol: CTA = {
text: "dummy",
action: "iosit://PROFILE_MAIN"
};
const isValid = await isCtaActionValid(invalidProtocol);
expect(isValid).toBeFalsy();
});

it("should be not valid (wrong route)", async () => {
const invalidRoute: CTA = { text: "dummy", action: "iosit://WRONG_ROUTE" };
const isValid = await isCtaActionValid(invalidRoute);
expect(isValid).toBeFalsy();
});

it("should be a valid RN Linking", async () => {
const phoneCtaValid: CTA = {
text: "dummy",
action: "iohandledlink://tel://3471615647"
};
const isValid = await isCtaActionValid(phoneCtaValid);
expect(isValid).toBeTruthy();
});

it("should be a valid RN Linking (web)", async () => {
const webCtaValid: CTA = {
text: "dummy",
action: "iohandledlink://https://www.google.it"
};
const isValid = await isCtaActionValid(webCtaValid);
expect(isValid).toBeTruthy();
});
});

describe("cleanMarkdownFromCTAs", () => {
it("should be the same", async () => {
const markdown = "simple text";
const cleaned = cleanMarkdownFromCTAs(markdown as MessageBodyMarkdown);
expect(cleaned).toEqual(markdown);
});

it("should be cleaned", async () => {
const withCTA = `---
it:
cta_1:
text: "premi"
action: "io://PROFILE_MAIN"
---
some noise`;
const cleaned = cleanMarkdownFromCTAs(withCTA as MessageBodyMarkdown);
expect(cleaned).toEqual("some noise");
});

it("should be cleaned (extended version)", async () => {
const cleaned = cleanMarkdownFromCTAs(CTA_2 as MessageBodyMarkdown);
expect(cleaned).toEqual(messageBody);
});
});
60 changes: 59 additions & 1 deletion ts/utils/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,24 @@
* Generic utilities for messages
*/

import { fromNullable, none, Option, some } from "fp-ts/lib/Option";
import {
fromNullable,
fromPredicate,
none,
Option,
some
} from "fp-ts/lib/Option";
import FM from "front-matter";
import { Linking } from "react-native";
import { CreatedMessageWithContent } from "../../definitions/backend/CreatedMessageWithContent";
import { CreatedMessageWithContentAndAttachments } from "../../definitions/backend/CreatedMessageWithContentAndAttachments";
import { MessageBodyMarkdown } from "../../definitions/backend/MessageBodyMarkdown";
import { PrescriptionData } from "../../definitions/backend/PrescriptionData";
import { Locales } from "../../locales/locales";
import { getInternalRoute } from "../components/ui/Markdown/handlers/internalLink";
import { deriveCustomHandledLink } from "../components/ui/Markdown/handlers/link";
import I18n from "../i18n";
import { CTA, CTAS, MessageCTA } from "../types/MessageCTA";
import { getExpireStatus } from "./dates";
import { isTextIncludedCaseInsensitive } from "./strings";

Expand Down Expand Up @@ -146,3 +160,47 @@ export const getPrescriptionDataFromName = (
return none;
});
};

/**
* extract the CTAs if they are nested inside the message markdown content
* if some CTAs are been found, the localized version will be returned
* @param message
* @param locale
*/
export const getCTA = (
message: CreatedMessageWithContent,
locale: Locales = I18n.currentLocale()
): Option<CTAS> => {
return fromPredicate((t: string) => FM.test(t))(message.content.markdown)
.map(m => FM<MessageCTA>(m).attributes)
.chain(attrs => fromNullable(attrs[locale]));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In case we are working on a locale that is missing from the CTA we received in the message we return a none and we won't show the cta even if it has been added to the message.

E.g. User changed to "EN" from preferences option in the message we received just the CTA on IT language -> no CTA displayed. Should we fallback on the received CTA?

Copy link
Contributor Author

@Undermaken Undermaken Jun 23, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CrisTofani
Yes it could happen.
What if we make it/en as required and not optional?

Copy link
Contributor

@CrisTofani CrisTofani Jun 23, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Undermaken
I think it could fail anyway cause we don't have the complete control of what we get since it is a remote content, maybe we should handle attrs as an array of string and fallback on the first we get?

Copy link
Contributor Author

@Undermaken Undermaken Jun 23, 2020

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@CrisTofani
starting from your warning I improved the logic and I added a test ae77f58:
now if the front-matter doesn't respect MessageCTA type the decoding will fail.
So if, from remote, we receive a data that doesn't contain it nor en the CTA won't be decoded.

Btw about the fallback we can do as follow: since at the moment the MessageCTA requires it/en, if the CTA for the current locale is not available we can fallback to the other one. if from remote we receive not it/en, fr for example, the decoding will fail

What do you think?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yep! I find it is a good point!

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update within 0dd7a0f

};

/**
* return a Promise indicating if the cta action is valid or not
* @param cta
*/
export const isCtaActionValid = async (cta: CTA): Promise<boolean> => {
// check if it is an internal navigation
if (getInternalRoute(cta.action).isSome()) {
return Promise.resolve(true);
}
const maybeCustomHandledAction = deriveCustomHandledLink(cta.action);
// check if it is a custom action (it should be composed in a specific format)
if (maybeCustomHandledAction.isSome()) {
return Linking.canOpenURL(maybeCustomHandledAction.value);
}
return Promise.resolve(false);
};

/**
* remove the cta front-matter if it is nested inside the markdown
* @param cta
*/
export const cleanMarkdownFromCTAs = (
markdown: MessageBodyMarkdown
): string => {
return fromPredicate((t: string) => FM.test(t))(markdown)
.map(m => FM(m).body)
.getOrElse(markdown as string);
};
7 changes: 7 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -4190,6 +4190,13 @@ from@^0.1.7:
version "0.1.7"
resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe"

front-matter@^4.0.2:
version "4.0.2"
resolved "https://registry.yarnpkg.com/front-matter/-/front-matter-4.0.2.tgz#b14e54dc745cfd7293484f3210d15ea4edd7f4d5"
integrity sha512-I8ZuJ/qG92NWX8i5x1Y8qyj3vizhXS31OxjKDu3LKP+7/qBgfIKValiZIEwoVoJKUHlhWtYrktkxV1XsX+pPlg==
dependencies:
js-yaml "^3.13.1"

fs-exists-sync@^0.1.0:
version "0.1.0"
resolved "https://registry.yarnpkg.com/fs-exists-sync/-/fs-exists-sync-0.1.0.tgz#982d6893af918e72d08dec9e8673ff2b5a8d6add"
Expand Down