@windstream/react-shared-components 0.2.92 → 0.2.94

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.
Files changed (72) hide show
  1. package/dist/contentful/index.d.ts +46 -9
  2. package/dist/contentful/index.esm.js +4 -4
  3. package/dist/contentful/index.esm.js.map +1 -1
  4. package/dist/contentful/index.js +3 -3
  5. package/dist/contentful/index.js.map +1 -1
  6. package/dist/core.d.ts +6 -3
  7. package/dist/index.d.ts +4 -1
  8. package/dist/index.esm.js +2 -2
  9. package/dist/index.esm.js.map +1 -1
  10. package/dist/index.js +2 -2
  11. package/dist/index.js.map +1 -1
  12. package/dist/utils/index.d.ts +2 -1
  13. package/dist/utils/index.esm.js +1 -1
  14. package/dist/utils/index.esm.js.map +1 -1
  15. package/dist/utils/index.js +1 -1
  16. package/dist/utils/index.js.map +1 -1
  17. package/package.json +1 -1
  18. package/src/components/checklist/index.test.tsx +18 -1
  19. package/src/components/checklist/index.tsx +25 -21
  20. package/src/components/checklist/types.ts +8 -2
  21. package/src/contentful/blocks/address-input-banner/index.test.tsx +14 -0
  22. package/src/contentful/blocks/address-input-banner/index.tsx +3 -1
  23. package/src/contentful/blocks/address-input-banner/types.ts +1 -1
  24. package/src/contentful/blocks/anchored-bottom-banner/index.test.tsx +91 -0
  25. package/src/contentful/blocks/anchored-bottom-banner/index.tsx +18 -11
  26. package/src/contentful/blocks/anchored-bottom-banner/types.ts +17 -3
  27. package/src/contentful/blocks/blogs-grid/index.test.tsx +21 -1
  28. package/src/contentful/blocks/blogs-grid/index.tsx +1 -0
  29. package/src/contentful/blocks/blogs-grid/types.ts +3 -0
  30. package/src/contentful/blocks/blogs-grid-base/index.test.tsx +49 -3
  31. package/src/contentful/blocks/blogs-grid-base/index.tsx +11 -2
  32. package/src/contentful/blocks/blogs-grid-base/types.ts +7 -1
  33. package/src/contentful/blocks/cards/blog-card/index.test.tsx +29 -0
  34. package/src/contentful/blocks/cards/blog-card/index.tsx +18 -4
  35. package/src/contentful/blocks/cards/blog-card/types.ts +3 -1
  36. package/src/contentful/blocks/cards/product-card/index.test.tsx +43 -0
  37. package/src/contentful/blocks/cards/product-card/index.tsx +37 -9
  38. package/src/contentful/blocks/cards/product-card/types.ts +3 -0
  39. package/src/contentful/blocks/carousel/helper.test.tsx +13 -0
  40. package/src/contentful/blocks/carousel/helper.tsx +1 -0
  41. package/src/contentful/blocks/carousel/types.ts +2 -0
  42. package/src/contentful/blocks/dynamic-form/conditional-fields.tsx +1 -0
  43. package/src/contentful/blocks/dynamic-form/form-field.tsx +1 -0
  44. package/src/contentful/blocks/dynamic-form/index.test.tsx +48 -0
  45. package/src/contentful/blocks/dynamic-form/index.tsx +19 -7
  46. package/src/contentful/blocks/dynamic-form/render.tsx +13 -7
  47. package/src/contentful/blocks/dynamic-form/types.ts +28 -2
  48. package/src/contentful/blocks/dynamic-tabs/index.test.tsx +52 -2
  49. package/src/contentful/blocks/dynamic-tabs/index.tsx +9 -2
  50. package/src/contentful/blocks/dynamic-tabs/ordered-tab-views.tsx +16 -6
  51. package/src/contentful/blocks/dynamic-tabs/tab-views.tsx +10 -6
  52. package/src/contentful/blocks/dynamic-tabs/types.ts +6 -0
  53. package/src/contentful/blocks/email-input-block/index.test.tsx +51 -3
  54. package/src/contentful/blocks/email-input-block/index.tsx +88 -31
  55. package/src/contentful/blocks/email-input-block/types.ts +14 -1
  56. package/src/contentful/blocks/fiber-form/index.test.tsx +40 -2
  57. package/src/contentful/blocks/fiber-form/index.tsx +32 -13
  58. package/src/contentful/blocks/fiber-form/types.ts +18 -0
  59. package/src/contentful/blocks/iframe-embed/index.test.tsx +44 -7
  60. package/src/contentful/blocks/iframe-embed/index.tsx +3 -2
  61. package/src/contentful/blocks/iframe-embed/types.ts +1 -0
  62. package/src/contentful/blocks/navigation/{Navigation.stories.tsx → navigation.stories.tsx} +1 -1
  63. package/src/contentful/blocks/ordered-list/index.test.tsx +54 -4
  64. package/src/contentful/blocks/ordered-list/index.tsx +14 -2
  65. package/src/contentful/blocks/ordered-list/types.ts +15 -2
  66. package/src/contentful/blocks/primary-hero/index.tsx +5 -5
  67. package/src/contentful/blocks/search-block/index.test.tsx +36 -2
  68. package/src/contentful/blocks/search-block/index.tsx +24 -4
  69. package/src/contentful/blocks/search-block/types.ts +10 -0
  70. package/src/utils/index.ts +1 -1
  71. package/src/utils/replace-tokens.test.ts +40 -0
  72. package/src/utils/replace-tokens.ts +13 -0
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import React, { useEffect, useRef } from "react";
4
- import { IframeEmbedProps, bgColorMap } from "./types";
4
+ import { bgColorMap, IframeEmbedProps } from "./types";
5
5
 
6
6
  /**
7
7
  * IframeEmbed — A generic, responsive iframe component for embedding third-party content
@@ -30,6 +30,7 @@ export const IframeEmbed: React.FC<IframeEmbedProps> = ({
30
30
  anchorId,
31
31
  backgroundColor,
32
32
  height,
33
+ fieldProps,
33
34
  }) => {
34
35
  const iframeRef = useRef<HTMLIFrameElement>(null);
35
36
 
@@ -56,7 +57,7 @@ export const IframeEmbed: React.FC<IframeEmbedProps> = ({
56
57
  if (!iframeUrl) return null;
57
58
 
58
59
  return (
59
- <section id={anchorId} className="w-full">
60
+ <section id={anchorId} className="w-full" {...fieldProps?.iframeUrl}>
60
61
  <iframe
61
62
  ref={iframeRef}
62
63
  src={iframeUrl}
@@ -15,4 +15,5 @@ export type IframeEmbedProps = {
15
15
  backgroundColor?: BackgroundColor;
16
16
  /** Optional initial/minimum height (in px) for the iframe before postMessage auto-resize kicks in. */
17
17
  height?: number;
18
+ fieldProps?: { iframeUrl?: Record<string, unknown> };
18
19
  };
@@ -7,7 +7,7 @@ import {
7
7
  UTILITY_LINKS,
8
8
  ACCOUNT_LINKS,
9
9
  SUPPORT_LINKS,
10
- } from "./Navigation.stories.mocks";
10
+ } from "./navigation.stories.mocks";
11
11
 
12
12
  const searchFormStyle = `
13
13
  form[name="searchForm"] {
@@ -6,16 +6,24 @@ import { OrderedList } from "./index";
6
6
  import { render, screen, within } from "@testing-library/react";
7
7
 
8
8
  jest.mock("@shared/components/link", () => ({
9
- Link: ({ children, href, title, className }: any) => (
10
- <a href={href} title={title} className={className} data-testid="ol-link">
9
+ Link: ({ children, href, title, className, ...rest }: any) => (
10
+ <a
11
+ href={href}
12
+ title={title}
13
+ className={className}
14
+ data-testid="ol-link"
15
+ {...rest}
16
+ >
11
17
  {children}
12
18
  </a>
13
19
  ),
14
20
  }));
15
21
 
16
22
  jest.mock("@shared/components/text", () => ({
17
- Text: ({ children, as: Tag = "span", className }: any) => (
18
- <Tag className={className}>{children}</Tag>
23
+ Text: ({ children, as: Tag = "span", className, ...rest }: any) => (
24
+ <Tag className={className} {...rest}>
25
+ {children}
26
+ </Tag>
19
27
  ),
20
28
  }));
21
29
 
@@ -83,6 +91,48 @@ describe("OrderedList", () => {
83
91
  expect(screen.getByText("Find availability near you")).toBeInTheDocument();
84
92
  });
85
93
 
94
+ it("tags headings and child names and links independently", () => {
95
+ renderComponent({
96
+ fieldProps: {
97
+ title: { "data-contentful-field-id": "title" },
98
+ description: { "data-contentful-field-id": "description" },
99
+ },
100
+ tabs: [
101
+ {
102
+ items: {
103
+ tabName: "Cities",
104
+ tabContent: [
105
+ {
106
+ itemName: "new york",
107
+ itemSlug: "/ny",
108
+ fieldProps: {
109
+ itemName: { "data-contentful-field-id": "cityName" },
110
+ itemSlug: { "data-contentful-field-id": "slug" },
111
+ },
112
+ },
113
+ ],
114
+ },
115
+ },
116
+ ],
117
+ });
118
+ expect(screen.getByText("Browse locations")).toHaveAttribute(
119
+ "data-contentful-field-id",
120
+ "title"
121
+ );
122
+ expect(screen.getByText("Find availability near you")).toHaveAttribute(
123
+ "data-contentful-field-id",
124
+ "description"
125
+ );
126
+ expect(screen.getByText("New York")).toHaveAttribute(
127
+ "data-contentful-field-id",
128
+ "cityName"
129
+ );
130
+ expect(screen.getByTestId("ol-link")).toHaveAttribute(
131
+ "data-contentful-field-id",
132
+ "slug"
133
+ );
134
+ });
135
+
86
136
  it("renders the section with the provided anchor id and testid", () => {
87
137
  const { container } = renderComponent({ anchorId: "locations" });
88
138
  const section = container.querySelector(
@@ -13,6 +13,7 @@ export const OrderedList = ({
13
13
  title,
14
14
  description,
15
15
  tabs,
16
+ fieldProps,
16
17
  }: OrderedListProps) => {
17
18
  const formatItemName = (name: string) =>
18
19
  name
@@ -31,6 +32,7 @@ export const OrderedList = ({
31
32
  <Text
32
33
  key={`ordered-item-${index}`}
33
34
  className="body1 relative text-left font-semibold leading-7"
35
+ {...item.fieldProps?.itemName}
34
36
  >
35
37
  {formatItemName(item.itemName)}
36
38
  </Text>
@@ -41,10 +43,14 @@ export const OrderedList = ({
41
43
  <Link
42
44
  key={`ordered-item-${index}`}
43
45
  href={item.itemSlug}
46
+ {...item.fieldProps?.itemSlug}
44
47
  title={item.itemName.toUpperCase()}
45
48
  className="flex items-center underline underline-offset-4"
46
49
  >
47
- <Text className="body1 relative cursor-pointer text-left font-semibold leading-7">
50
+ <Text
51
+ className="body1 relative cursor-pointer text-left font-semibold leading-7"
52
+ {...item.fieldProps?.itemName}
53
+ >
48
54
  {formatItemName(item.itemName)}
49
55
  </Text>
50
56
  </Link>
@@ -56,6 +62,7 @@ export const OrderedList = ({
56
62
  const dynamicTabs = {
57
63
  items: tabs.map((tab, index) => ({
58
64
  tabName: tab.items.tabName,
65
+ fieldProps: tab.items.fieldProps,
59
66
  anchorId: `tab-${index}`,
60
67
  tabContent: tab.items.tabContent.map(renderChild),
61
68
  })),
@@ -69,12 +76,17 @@ export const OrderedList = ({
69
76
  >
70
77
  <div className="mx-auto w-full max-w-120 px-0 py-12 md:px-6 md:py-20 lg:px-3">
71
78
  <div className="mb-11 px-4 md:px-0">
72
- <Text as={"h2"} className="heading2 text-center md:heading1">
79
+ <Text
80
+ as={"h2"}
81
+ className="heading2 text-center md:heading1"
82
+ {...fieldProps?.title}
83
+ >
73
84
  {title}
74
85
  </Text>
75
86
  <Text
76
87
  as={"h3"}
77
88
  className="subheading3 mt-2 text-center md:subheading1"
89
+ {...fieldProps?.description}
78
90
  >
79
91
  {description}
80
92
  </Text>
@@ -1,9 +1,22 @@
1
1
  import { ReactNode } from "react";
2
2
 
3
+ import type { FieldProps } from "@shared/types/field-props";
4
+
3
5
  export interface OrderedListProps {
4
6
  anchorId?: string;
5
7
  backgroundColor?: "white" | "cream";
6
8
  title: ReactNode;
7
9
  description: ReactNode;
8
- tabs: any[];
9
- }
10
+ fieldProps?: FieldProps<"title" | "description">;
11
+ tabs: {
12
+ items: {
13
+ tabName: string;
14
+ fieldProps?: FieldProps<"tabName">;
15
+ tabContent: {
16
+ itemName: string;
17
+ itemSlug?: string;
18
+ fieldProps?: FieldProps<"itemName" | "itemSlug">;
19
+ }[];
20
+ };
21
+ }[];
22
+ }
@@ -324,7 +324,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
324
324
  "check availability" && (
325
325
  <Text
326
326
  as="span"
327
- className="body2 mr-1"
327
+ className="body3 mr-1"
328
328
  {...fieldProps?.secondaryCtaPrefix}
329
329
  >
330
330
  {secondaryCtaPrefix}
@@ -333,7 +333,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
333
333
  {secondaryCta && (
334
334
  <Button
335
335
  {...secondaryCta}
336
- linkClassName={`body2 ${textColorClass}`}
336
+ linkClassName={`body3 ${textColorClass}`}
337
337
  renderCheckPlans={renderCheckPlans}
338
338
  onModalButtonClick={onModalButtonClick}
339
339
  />
@@ -346,7 +346,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
346
346
  <div className="hidden lg:block">
347
347
  <Button
348
348
  {...bottomLink}
349
- linkClassName={`text-footnote ${textColorClass}`}
349
+ linkClassName={`body3 ${textColorClass}`}
350
350
  renderCheckPlans={renderCheckPlans}
351
351
  onModalButtonClick={onModalButtonClick}
352
352
  />
@@ -423,7 +423,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
423
423
  !secondaryCta?.preDefinedFunctionExecution && (
424
424
  <Text
425
425
  as="span"
426
- className="body2 mr-1"
426
+ className="body3 mr-1"
427
427
  {...fieldProps?.secondaryCtaPrefix}
428
428
  >
429
429
  {secondaryCtaPrefix}
@@ -434,7 +434,7 @@ export const PrimaryHero: React.FC<PrimaryHeroProps> = props => {
434
434
  {...secondaryCta}
435
435
  renderCheckPlans={renderCheckPlans}
436
436
  onModalButtonClick={onModalButtonClick}
437
- linkClassName={`${textColorClass}`}
437
+ linkClassName={`body3 ${textColorClass}`}
438
438
  />
439
439
  )}
440
440
  </div>
@@ -43,8 +43,10 @@ jest.mock("@shared/components/material-icon", () => ({
43
43
  }));
44
44
 
45
45
  jest.mock("@shared/components/text", () => ({
46
- Text: ({ as: Tag = "span", children, className }: any) => (
47
- <Tag className={className}>{children}</Tag>
46
+ Text: ({ as: Tag = "span", children, className, ...rest }: any) => (
47
+ <Tag className={className} {...rest}>
48
+ {children}
49
+ </Tag>
48
50
  ),
49
51
  }));
50
52
 
@@ -59,6 +61,38 @@ const defaultProps: SearchBlockProps = {
59
61
  };
60
62
 
61
63
  describe("SearchBlock", () => {
64
+ it("tags each authored field on its own element", () => {
65
+ const tag = (field: string) => ({ "data-contentful-field-id": field });
66
+ const { container } = render(
67
+ <SearchBlock
68
+ {...defaultProps}
69
+ subTitle="Subtitle"
70
+ searchDescription="Description"
71
+ caption={<p>Caption</p>}
72
+ basicCaption={<p>Basic</p>}
73
+ fieldProps={{
74
+ title: tag("title"),
75
+ subTitle: tag("subTitle"),
76
+ searchDescription: tag("description"),
77
+ searchInputPlaceholder: tag("searchInputPlaceholder"),
78
+ caption: tag("caption"),
79
+ basicCaption: tag("basicCaption"),
80
+ }}
81
+ />
82
+ );
83
+ for (const field of [
84
+ "title",
85
+ "subTitle",
86
+ "description",
87
+ "searchInputPlaceholder",
88
+ "caption",
89
+ "basicCaption",
90
+ ]) {
91
+ expect(
92
+ container.querySelector(`[data-contentful-field-id="${field}"]`)
93
+ ).not.toBeNull();
94
+ }
95
+ });
62
96
  describe("Rendering", () => {
63
97
  it("renders with data-testid", () => {
64
98
  render(<SearchBlock {...defaultProps} />);
@@ -20,6 +20,7 @@ export const SearchBlock: React.FC<SearchBlockProps> = props => {
20
20
  backgroundColor,
21
21
  color,
22
22
  onSubmit,
23
+ fieldProps,
23
24
  } = props;
24
25
  const [searchText, setSearchText] = useState("");
25
26
  const themeColorClasses = {
@@ -45,17 +46,27 @@ export const SearchBlock: React.FC<SearchBlockProps> = props => {
45
46
  <Text
46
47
  as={enableHeading ? "h1" : "h2"}
47
48
  className={cx("heading2 md:heading1")}
49
+ {...fieldProps?.title}
48
50
  >
49
51
  {title}
50
52
  </Text>
51
53
  {subTitle && (
52
- <Text as="div" className={cx("subheading2 mt-2 md:subheading1")}>
54
+ <Text
55
+ as="div"
56
+ className={cx("subheading2 mt-2 md:subheading1")}
57
+ {...fieldProps?.subTitle}
58
+ >
53
59
  {subTitle}
54
60
  </Text>
55
61
  )}
56
62
  </div>
57
63
  {searchDescription ? (
58
- <Text className={`heading6 text-center`}>{searchDescription}</Text>
64
+ <Text
65
+ className={`heading6 text-center`}
66
+ {...fieldProps?.searchDescription}
67
+ >
68
+ {searchDescription}
69
+ </Text>
59
70
  ) : null}
60
71
 
61
72
  <span className="w-full">
@@ -73,14 +84,23 @@ export const SearchBlock: React.FC<SearchBlockProps> = props => {
73
84
  target: { value: React.SetStateAction<string> };
74
85
  }) => setSearchText(e.target.value)}
75
86
  placeholder={searchInputPlaceholder}
87
+ {...fieldProps?.searchInputPlaceholder}
76
88
  containerClassName="px-4 pl-0 rounded-md flex-grow border-none"
77
89
  className={"body3 rounded-md text-text"}
78
90
  />
79
91
  </div>
80
92
  </form>
81
93
  </span>
82
- {caption ? caption : null}
83
- {basicCaption}
94
+ {caption && fieldProps?.caption ? (
95
+ <div {...fieldProps.caption}>{caption}</div>
96
+ ) : (
97
+ caption || null
98
+ )}
99
+ {basicCaption && fieldProps?.basicCaption ? (
100
+ <div {...fieldProps.basicCaption}>{basicCaption}</div>
101
+ ) : (
102
+ basicCaption
103
+ )}
84
104
  </div>
85
105
  </div>
86
106
  </div>
@@ -1,6 +1,16 @@
1
1
  import React from "react";
2
2
 
3
+ import type { FieldProps } from "@shared/types/field-props";
4
+
3
5
  export type SearchBlockProps = {
6
+ fieldProps?: FieldProps<
7
+ | "title"
8
+ | "subTitle"
9
+ | "searchDescription"
10
+ | "searchInputPlaceholder"
11
+ | "caption"
12
+ | "basicCaption"
13
+ >;
4
14
  anchorId?: string;
5
15
  title?: string;
6
16
  subTitle?: string;
@@ -34,7 +34,7 @@ export {
34
34
 
35
35
  // Token replacement — framework-agnostic string / rich-text interpolation.
36
36
  // No dependency on Contentful or any particular CMS/tool.
37
- export { replaceTokens } from "./replace-tokens";
37
+ export { replaceTokens, getTokenField } from "./replace-tokens";
38
38
  export type {
39
39
  TokenEntry,
40
40
  TokenSource,
@@ -1,4 +1,5 @@
1
1
  import {
2
+ getTokenField,
2
3
  hasUnresolvedTokens,
3
4
  replaceTokens,
4
5
  type RichTextDocument,
@@ -229,3 +230,42 @@ describe("hasUnresolvedTokens", () => {
229
230
  ]);
230
231
  });
231
232
  });
233
+
234
+ describe("getTokenField", () => {
235
+ it("trims the token key before matching", () => {
236
+ expect(getTokenField(" price_tier1_300mbps ", tokens, "value")).toBe(
237
+ "44.99"
238
+ );
239
+ });
240
+
241
+ it("parses a JSON string token source", () => {
242
+ expect(
243
+ getTokenField("price_tier1_300mbps", JSON.stringify(tokens), "value")
244
+ ).toBe("44.99");
245
+ });
246
+
247
+ it("returns value (strikethrough) and promo (plan price) separately", () => {
248
+ expect(getTokenField("price_tier1_300mbps", tokens, "value")).toBe("44.99");
249
+ expect(getTokenField("price_tier1_300mbps", tokens, "promo")).toBe("39.99");
250
+ });
251
+
252
+ it("returns undefined for a missing field, unknown token or empty key", () => {
253
+ expect(getTokenField("price_value_only", tokens, "promo")).toBeUndefined();
254
+ expect(getTokenField("price_unknown", tokens, "value")).toBeUndefined();
255
+ expect(getTokenField("", tokens, "value")).toBeUndefined();
256
+ expect(getTokenField(undefined, tokens, "value")).toBeUndefined();
257
+ expect(getTokenField("price_tier1_300mbps", null, "value")).toBeUndefined();
258
+ expect(
259
+ getTokenField("price_tier1_300mbps", "{bad", "value")
260
+ ).toBeUndefined();
261
+ });
262
+
263
+ it("ignores blank or non-string field values", () => {
264
+ const source = [
265
+ { token: "blank", value: " " },
266
+ { token: "numeric", value: 44 as unknown as string },
267
+ ];
268
+ expect(getTokenField("blank", source, "value")).toBeUndefined();
269
+ expect(getTokenField("numeric", source, "value")).toBeUndefined();
270
+ });
271
+ });
@@ -44,6 +44,19 @@ const buildTokenMap = (tokens: TokenSource): Record<string, string> =>
44
44
  return acc;
45
45
  }, {});
46
46
 
47
+ export const getTokenField = (
48
+ token: string | null | undefined,
49
+ tokens: TokenSource,
50
+ field: "promo" | "value"
51
+ ): string | undefined => {
52
+ if (typeof token !== "string" || !token.trim()) return undefined;
53
+ const key = token.trim();
54
+ const resolved = normalizeEntries(tokens).find(
55
+ entry => entry?.token === key
56
+ )?.[field];
57
+ return typeof resolved === "string" && resolved.trim() ? resolved : undefined;
58
+ };
59
+
47
60
  const replaceInString = (input: string, map: Record<string, string>): string =>
48
61
  input.replace(TOKEN_PATTERN, (match, token) =>
49
62
  Object.prototype.hasOwnProperty.call(map, token) ? map[token] : match