@jobber/components-native 0.45.0 → 0.46.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { Match } from "autolinker";
2
- import { MessageDescriptor } from "react-intl";
2
+ import { useAtlantisI18nValue } from "../hooks/useAtlantisI18n";
3
3
  export declare function shouldIgnoreURL(text: string, match: Match): boolean;
4
4
  export declare function getUrl(match: Match, immediateOpen?: boolean): string;
5
- export declare function onLongPressLink(match: Match, bottomTabsVisible: boolean, formatMessage: (message: MessageDescriptor) => string): void;
5
+ export declare function onLongPressLink(match: Match, bottomTabsVisible: boolean, t: useAtlantisI18nValue["t"]): void;
6
6
  export declare function onPressLink(match: Match): void;
@@ -1,6 +1,24 @@
1
1
  import en from "./locales/en.json";
2
+ export type I18nKeys = keyof typeof en;
2
3
  export interface useAtlantisI18nValue {
4
+ /**
5
+ * The set locale based on the AtlantisContext.
6
+ */
3
7
  readonly locale: string;
4
- readonly t: (message: keyof typeof en, values?: Record<string, string>) => string;
8
+ /**
9
+ * Returns the translated string depending on the locale. This accepts a 2nd
10
+ * param for string interpolation.
11
+ */
12
+ readonly t: (message: I18nKeys, values?: Record<string, string>) => string;
13
+ /**
14
+ * Returns a formatted date string based on the locale and the `dateFormat`
15
+ * set in AtlantisContext.
16
+ */
17
+ readonly formatDate: (date: Date) => string;
18
+ /**
19
+ * Returns a formatted time string based on the locale and the `timeFormat`
20
+ * set in AtlantisContext.
21
+ */
22
+ readonly formatTime: (date: Date) => string;
5
23
  }
6
24
  export declare function useAtlantisI18n(): useAtlantisI18nValue;
@@ -0,0 +1,6 @@
1
+ interface DateFormatterOptions {
2
+ readonly locale: string;
3
+ readonly timeZone: string;
4
+ }
5
+ export declare function dateFormatter(date: Date, dateTimeFormat: string, { locale, timeZone }: DateFormatterOptions): string;
6
+ export {};
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jobber/components-native",
3
- "version": "0.45.0",
3
+ "version": "0.46.0",
4
4
  "license": "MIT",
5
5
  "description": "React Native implementation of Atlantis",
6
6
  "repository": {
@@ -84,5 +84,5 @@
84
84
  "react-native-reanimated": "^2.17.0",
85
85
  "react-native-safe-area-context": "^4.5.2"
86
86
  },
87
- "gitHead": "90e9d2a124e1d65d678364063f6ef57e066939af"
87
+ "gitHead": "6ff59b16b38fdf784fa15bcfa41eb97181d2f6b0"
88
88
  }
@@ -2,7 +2,6 @@ import React from "react";
2
2
  import { fireEvent, render } from "@testing-library/react-native";
3
3
  import { copyTextToClipboard } from "./clipboard";
4
4
  import { AutoLink } from "./AutoLink";
5
- import { messages } from "./messages";
6
5
 
7
6
  const mockOpenUrl = jest.fn();
8
7
  jest.mock("react-native/Libraries/Linking/Linking", () => ({
@@ -48,7 +47,7 @@ describe("AutoLink", () => {
48
47
  fireEvent(getByText(linkText), "onLongPress");
49
48
 
50
49
  const expectedToastConfig = {
51
- message: messages.urlCopied.defaultMessage,
50
+ message: "URL copied",
52
51
  bottomTabsVisible: true,
53
52
  };
54
53
  expect(copyTextToClipboard).toHaveBeenCalledWith(
@@ -104,7 +103,7 @@ describe("AutoLink", () => {
104
103
  fireEvent(getByText(emailText), "onLongPress");
105
104
 
106
105
  const expectedToastConfig = {
107
- message: messages.emailCopied.defaultMessage,
106
+ message: "Email copied",
108
107
  bottomTabsVisible: true,
109
108
  };
110
109
  expect(copyTextToClipboard).toHaveBeenCalledWith(
@@ -158,7 +157,7 @@ describe("AutoLink", () => {
158
157
  fireEvent(getByText(phoneText), "onLongPress");
159
158
 
160
159
  const expectedToastConfig = {
161
- message: messages.phoneCopied.defaultMessage,
160
+ message: "Phone number copied",
162
161
  bottomTabsVisible: true,
163
162
  };
164
163
  expect(copyTextToClipboard).toHaveBeenCalledWith(
@@ -1,10 +1,10 @@
1
1
  import React from "react";
2
- import { useIntl } from "react-intl";
3
2
  import { Platform } from "react-native";
4
3
  import { ComposeTextWithLinksProps } from "../../types";
5
4
  import { onLongPressLink, onPressLink } from "../../utils";
6
5
  import { Link } from "../Link/Link";
7
6
  import { Text } from "../../../Text";
7
+ import { useAtlantisI18n } from "../../../hooks/useAtlantisI18n";
8
8
 
9
9
  export function ComposeTextWithLinks({
10
10
  part,
@@ -13,7 +13,7 @@ export function ComposeTextWithLinks({
13
13
  bottomTabsVisible,
14
14
  selectable = true,
15
15
  }: ComposeTextWithLinksProps): JSX.Element {
16
- const { formatMessage } = useIntl();
16
+ const { t } = useAtlantisI18n();
17
17
 
18
18
  const isLink = match?.getType();
19
19
 
@@ -26,7 +26,7 @@ export function ComposeTextWithLinks({
26
26
  if (selectable && Platform.OS === "android") {
27
27
  return;
28
28
  }
29
- onLongPressLink(match, bottomTabsVisible, formatMessage);
29
+ onLongPressLink(match, bottomTabsVisible, t);
30
30
  }}
31
31
  >
32
32
  {match.getAnchorText()}
@@ -1,9 +1,7 @@
1
1
  import { EmailMatch, Match, PhoneMatch } from "autolinker";
2
- import { MessageDescriptor } from "react-intl";
3
2
  import { Linking } from "react-native";
4
- import { messages } from "./messages";
5
- import { LinkType } from "./types";
6
3
  import { copyTextToClipboard } from "./clipboard";
4
+ import { I18nKeys, useAtlantisI18nValue } from "../hooks/useAtlantisI18n";
7
5
 
8
6
  function hasPrefix(text: string, prefixes: string[]): boolean {
9
7
  return prefixes.some(prefix => text.includes(prefix));
@@ -46,12 +44,12 @@ export function getUrl(match: Match, immediateOpen = true): string {
46
44
  export function onLongPressLink(
47
45
  match: Match,
48
46
  bottomTabsVisible: boolean,
49
- formatMessage: (message: MessageDescriptor) => string,
47
+ t: useAtlantisI18nValue["t"],
50
48
  ): void {
51
49
  const linkUrl = getUrl(match, false);
52
50
 
53
51
  const toastConfig = {
54
- message: formatMessage(messages[`${match.getType() as LinkType}Copied`]),
52
+ message: t(getMessageKey(match)),
55
53
  bottomTabsVisible,
56
54
  };
57
55
  copyTextToClipboard(linkUrl, toastConfig);
@@ -61,3 +59,16 @@ export function onPressLink(match: Match): void {
61
59
  const linkUrl = getUrl(match);
62
60
  Linking.openURL(linkUrl);
63
61
  }
62
+
63
+ function getMessageKey(match: Match): I18nKeys {
64
+ switch (match.getType()) {
65
+ case "email":
66
+ return "AutoLink.emailCopied";
67
+ case "phone":
68
+ return "AutoLink.phoneCopied";
69
+ case "url":
70
+ return "AutoLink.urlCopied";
71
+ default:
72
+ return "copied";
73
+ }
74
+ }
@@ -3,12 +3,9 @@ import DateTimePicker from "react-native-modal-datetime-picker";
3
3
  import { Platform } from "react-native";
4
4
  import { FieldError, UseControllerProps } from "react-hook-form";
5
5
  import { XOR } from "ts-xor";
6
- import { utcToZonedTime } from "date-fns-tz";
7
- import { format as formatDate } from "date-fns";
8
6
  import { Clearable, InputFieldWrapperProps } from "../InputFieldWrapper";
9
7
  import { FormField } from "../FormField";
10
8
  import { InputPressable } from "../InputPressable";
11
- import { useAtlantisContext } from "../AtlantisContext";
12
9
  import { useAtlantisI18n } from "../hooks/useAtlantisI18n";
13
10
 
14
11
  interface BaseInputDateProps
@@ -153,8 +150,7 @@ function InternalInputDate({
153
150
  accessibilityHint,
154
151
  }: InputDateProps): JSX.Element {
155
152
  const [showPicker, setShowPicker] = useState(false);
156
- const { t, locale } = useAtlantisI18n();
157
- const { timeZone, dateFormat } = useAtlantisContext();
153
+ const { t, locale, formatDate } = useAtlantisI18n();
158
154
 
159
155
  const date = useMemo(() => {
160
156
  if (typeof value === "string") return new Date(value);
@@ -163,12 +159,11 @@ function InternalInputDate({
163
159
 
164
160
  const formattedDate = useMemo(() => {
165
161
  if (date) {
166
- const zonedTime = utcToZonedTime(date, timeZone);
167
- return formatDate(zonedTime, dateFormat);
162
+ return formatDate(date);
168
163
  }
169
164
 
170
165
  return emptyValueLabel;
171
- }, [date, emptyValueLabel, timeZone, dateFormat]);
166
+ }, [date, emptyValueLabel]);
172
167
 
173
168
  const canClearDate = formattedDate === emptyValueLabel ? "never" : clearable;
174
169
 
@@ -3,8 +3,6 @@ import { FieldError, UseControllerProps } from "react-hook-form";
3
3
  import { XOR } from "ts-xor";
4
4
  import DateTimePicker from "react-native-modal-datetime-picker";
5
5
  import { View } from "react-native";
6
- import { utcToZonedTime } from "date-fns-tz";
7
- import { format as formatTime } from "date-fns";
8
6
  import { styles } from "./InputTime.style";
9
7
  import { getTimeZoneOffsetInMinutes, roundUpToNearestMinutes } from "./utils";
10
8
  import { useAtlantisContext } from "../AtlantisContext";
@@ -134,9 +132,9 @@ function InternalInputTime({
134
132
  showIcon = true,
135
133
  }: InputTimeProps): JSX.Element {
136
134
  const [showPicker, setShowPicker] = useState(false);
137
- const { t } = useAtlantisI18n();
138
-
135
+ const { t, formatTime } = useAtlantisI18n();
139
136
  const { timeZone, timeFormat } = useAtlantisContext();
137
+
140
138
  const is24Hour = timeFormat === "HH:mm";
141
139
 
142
140
  const dateTime = useMemo(
@@ -146,12 +144,11 @@ function InternalInputTime({
146
144
 
147
145
  const formattedTime = useMemo(() => {
148
146
  if (dateTime) {
149
- const zonedTime = utcToZonedTime(dateTime, timeZone);
150
- return formatTime(zonedTime, timeFormat);
147
+ return formatTime(dateTime);
151
148
  }
152
149
 
153
150
  return emptyValueLabel;
154
- }, [dateTime, emptyValueLabel, timeZone, timeFormat]);
151
+ }, [dateTime, emptyValueLabel]);
155
152
 
156
153
  const canClearTime = formattedTime === emptyValueLabel ? "never" : clearable;
157
154
 
@@ -1,6 +1,10 @@
1
1
  {
2
+ "AutoLink.emailCopied": "Email copied",
3
+ "AutoLink.phoneCopied": "Phone number copied",
4
+ "AutoLink.urlCopied": "URL copied",
2
5
  "cancel": "Cancel",
3
6
  "confirm": "Confirm",
7
+ "copied": "Copied",
4
8
  "date": "Date",
5
9
  "dismiss": "Dismiss",
6
10
  "done": "Done",
@@ -1,6 +1,10 @@
1
1
  {
2
+ "AutoLink.emailCopied": "Correo electrónico copiado",
3
+ "AutoLink.phoneCopied": "Número de teléfono copiado",
4
+ "AutoLink.urlCopied": "URL copiada",
2
5
  "cancel": "Cancelar",
3
6
  "confirm": "Confirmar",
7
+ "copied": "Copiado",
4
8
  "date": "Fecha",
5
9
  "dismiss": "Descartar",
6
10
  "done": "Listo",
@@ -10,6 +10,15 @@ jest.mock("../../AtlantisContext", () => ({
10
10
  ...jest.requireActual("../../AtlantisContext"),
11
11
  }));
12
12
 
13
+ const spy = jest.spyOn(context, "useAtlantisContext");
14
+ const testDate = new Date("2020-01-01T00:00:00.000Z");
15
+ const dateAfterSpringForward = new Date("2020-04-10T00:00:00.000Z");
16
+
17
+ beforeEach(() => {
18
+ jest.useFakeTimers();
19
+ jest.setSystemTime(testDate);
20
+ });
21
+
13
22
  describe("useAtlantisI18n", () => {
14
23
  it("should return english by default", () => {
15
24
  const { result } = renderHook(useAtlantisI18n);
@@ -27,7 +36,6 @@ describe("useAtlantisI18n", () => {
27
36
 
28
37
  describe("Español", () => {
29
38
  it("should return español", () => {
30
- const spy = jest.spyOn(context, "useAtlantisContext");
31
39
  spy.mockReturnValueOnce({ ...context.defaultValues, locale: "es" });
32
40
  const { result } = renderHook(useAtlantisI18n);
33
41
 
@@ -37,7 +45,6 @@ describe("useAtlantisI18n", () => {
37
45
 
38
46
  describe("Unsupported language", () => {
39
47
  it("should return the english translation", () => {
40
- const spy = jest.spyOn(context, "useAtlantisContext");
41
48
  spy.mockReturnValueOnce({ ...context.defaultValues, locale: "fr" });
42
49
  const { result } = renderHook(useAtlantisI18n);
43
50
 
@@ -50,4 +57,77 @@ describe("useAtlantisI18n", () => {
50
57
  expect(Object.keys(en)).toEqual(Object.keys(es));
51
58
  });
52
59
  });
60
+
61
+ describe("formatDate", () => {
62
+ it("should return the formatted date", () => {
63
+ const { result } = renderHook(useAtlantisI18n);
64
+ expect(result.current.formatDate(testDate)).toBe("Jan 1, 2020");
65
+ });
66
+
67
+ it("should return the date formatted for es", () => {
68
+ spy.mockReturnValueOnce({ ...context.defaultValues, locale: "es" });
69
+
70
+ const { result } = renderHook(useAtlantisI18n);
71
+ expect(result.current.formatDate(testDate)).toBe("1 ene 2020");
72
+ });
73
+
74
+ describe("Timezone", () => {
75
+ it.each([
76
+ ["America/New_York", "Dec 31, 2019"],
77
+ ["America/Chicago", "Dec 31, 2019"],
78
+ ["America/Denver", "Dec 31, 2019"],
79
+ ["Europe/London", "Jan 1, 2020"],
80
+ ["Australia/Sydney", "Jan 1, 2020"],
81
+ ])("should return the %s time", (timeZone, expected) => {
82
+ spy.mockReturnValueOnce({ ...context.defaultValues, timeZone });
83
+
84
+ const { result } = renderHook(useAtlantisI18n);
85
+ expect(result.current.formatDate(testDate)).toBe(expected);
86
+ });
87
+ });
88
+ });
89
+
90
+ describe("formatTime", () => {
91
+ it("should return the formatted time", () => {
92
+ const { result } = renderHook(useAtlantisI18n);
93
+ expect(result.current.formatTime(testDate)).toBe("12:00 AM");
94
+ });
95
+
96
+ it("should return the date formatted for es", () => {
97
+ spy.mockReturnValueOnce({ ...context.defaultValues, locale: "es" });
98
+
99
+ const { result } = renderHook(useAtlantisI18n);
100
+ expect(result.current.formatTime(testDate)).toBe("00:00");
101
+ });
102
+
103
+ describe("Timezone", () => {
104
+ it.each([
105
+ ["America/New_York", "7:00 PM"],
106
+ ["America/Chicago", "6:00 PM"],
107
+ ["America/Denver", "5:00 PM"],
108
+ ["Europe/London", "12:00 AM"],
109
+ ["Australia/Sydney", "11:00 AM"],
110
+ ])("should return the %s zoned time", (timeZone, expected) => {
111
+ spy.mockReturnValueOnce({ ...context.defaultValues, timeZone });
112
+
113
+ const { result } = renderHook(useAtlantisI18n);
114
+ expect(result.current.formatTime(testDate)).toBe(expected);
115
+ });
116
+
117
+ it.each([
118
+ ["America/New_York", "8:00 PM"],
119
+ ["America/Chicago", "7:00 PM"],
120
+ ["America/Denver", "6:00 PM"],
121
+ ["Europe/London", "1:00 AM"],
122
+ ["Australia/Sydney", "10:00 AM"],
123
+ ])("should return the %s spring zoned time", (timeZone, expected) => {
124
+ spy.mockReturnValueOnce({ ...context.defaultValues, timeZone });
125
+
126
+ const { result } = renderHook(useAtlantisI18n);
127
+ expect(result.current.formatTime(dateAfterSpringForward)).toBe(
128
+ expected,
129
+ );
130
+ });
131
+ });
132
+ });
53
133
  });
@@ -1,21 +1,56 @@
1
+ import { useCallback } from "react";
1
2
  import en from "./locales/en.json";
2
3
  import es from "./locales/es.json";
4
+ import { dateFormatter } from "./utils/dateFormatter";
3
5
  import { useAtlantisContext } from "../../AtlantisContext";
4
6
 
7
+ export type I18nKeys = keyof typeof en;
8
+
5
9
  export interface useAtlantisI18nValue {
10
+ /**
11
+ * The set locale based on the AtlantisContext.
12
+ */
6
13
  readonly locale: string;
7
- readonly t: (
8
- message: keyof typeof en,
9
- values?: Record<string, string>,
10
- ) => string;
14
+
15
+ /**
16
+ * Returns the translated string depending on the locale. This accepts a 2nd
17
+ * param for string interpolation.
18
+ */
19
+ readonly t: (message: I18nKeys, values?: Record<string, string>) => string;
20
+
21
+ /**
22
+ * Returns a formatted date string based on the locale and the `dateFormat`
23
+ * set in AtlantisContext.
24
+ */
25
+ readonly formatDate: (date: Date) => string;
26
+
27
+ /**
28
+ * Returns a formatted time string based on the locale and the `timeFormat`
29
+ * set in AtlantisContext.
30
+ */
31
+ readonly formatTime: (date: Date) => string;
11
32
  }
12
33
 
13
34
  export function useAtlantisI18n(): useAtlantisI18nValue {
14
- const { locale } = useAtlantisContext();
15
- const t = (messageKey: keyof typeof en, values?: Record<string, string>) =>
16
- formatMessage(messageKey, values, locale);
35
+ const { locale, dateFormat, timeFormat, timeZone } = useAtlantisContext();
36
+
37
+ const t = useCallback(
38
+ (messageKey: keyof typeof en, values?: Record<string, string>) =>
39
+ formatMessage(messageKey, values, locale),
40
+ [formatMessage, locale],
41
+ );
42
+
43
+ const formatDate = useCallback(
44
+ (date: Date) => dateFormatter(date, dateFormat, { locale, timeZone }),
45
+ [dateFormatter, locale],
46
+ );
47
+
48
+ const formatTime = useCallback(
49
+ (date: Date) => dateFormatter(date, timeFormat, { locale, timeZone }),
50
+ [dateFormatter, locale],
51
+ );
17
52
 
18
- return { locale, t };
53
+ return { locale, t, formatDate, formatTime };
19
54
  }
20
55
 
21
56
  function getLocalizedStrings(locale: string): typeof en {
@@ -0,0 +1,31 @@
1
+ import { format } from "date-fns";
2
+ import { utcToZonedTime } from "date-fns-tz";
3
+ import { es } from "date-fns/locale";
4
+
5
+ interface DateFormatterOptions {
6
+ readonly locale: string;
7
+ readonly timeZone: string;
8
+ }
9
+
10
+ export function dateFormatter(
11
+ date: Date,
12
+ dateTimeFormat: string,
13
+ { locale, timeZone }: DateFormatterOptions,
14
+ ): string {
15
+ const zonedTime = utcToZonedTime(date, timeZone);
16
+ return format(zonedTime, dateTimeFormat, {
17
+ locale: getDateFnsLocale(locale),
18
+ });
19
+ }
20
+
21
+ /**
22
+ * Change locale string to date-fns locale object.
23
+ */
24
+ function getDateFnsLocale(locale?: string): Locale | undefined {
25
+ switch (locale) {
26
+ case "es":
27
+ return es;
28
+ default:
29
+ return;
30
+ }
31
+ }
@@ -1,18 +0,0 @@
1
- import { defineMessages } from "react-intl";
2
- export const messages = defineMessages({
3
- phoneCopied: {
4
- id: "phoneCopied",
5
- defaultMessage: "Phone number copied",
6
- description: "Message shown after copying a phone number",
7
- },
8
- emailCopied: {
9
- id: "emailCopied",
10
- defaultMessage: "Email copied",
11
- description: "Message shown after copying an email",
12
- },
13
- urlCopied: {
14
- id: "urlCopied",
15
- defaultMessage: "URL copied",
16
- description: "Message shown after copying a URL",
17
- },
18
- });
@@ -1,17 +0,0 @@
1
- export declare const messages: {
2
- phoneCopied: {
3
- id: string;
4
- defaultMessage: string;
5
- description: string;
6
- };
7
- emailCopied: {
8
- id: string;
9
- defaultMessage: string;
10
- description: string;
11
- };
12
- urlCopied: {
13
- id: string;
14
- defaultMessage: string;
15
- description: string;
16
- };
17
- };
@@ -1,19 +0,0 @@
1
- import { defineMessages } from "react-intl";
2
-
3
- export const messages = defineMessages({
4
- phoneCopied: {
5
- id: "phoneCopied",
6
- defaultMessage: "Phone number copied",
7
- description: "Message shown after copying a phone number",
8
- },
9
- emailCopied: {
10
- id: "emailCopied",
11
- defaultMessage: "Email copied",
12
- description: "Message shown after copying an email",
13
- },
14
- urlCopied: {
15
- id: "urlCopied",
16
- defaultMessage: "URL copied",
17
- description: "Message shown after copying a URL",
18
- },
19
- });