@windstream/react-shared-components 0.2.45 → 0.2.46

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.
@@ -46,6 +46,12 @@ jest.mock("@shared/components/checklist", () => ({
46
46
  ),
47
47
  }));
48
48
 
49
+ jest.mock("@shared/components/video-link", () => ({
50
+ VideoLink: ({ videoLink }: any) => (
51
+ <div data-testid="video-link" data-video-link={JSON.stringify(videoLink)} />
52
+ ),
53
+ }));
54
+
49
55
  // Helper to build a Contentful Document
50
56
  function makeDoc(...content: any[]): Document {
51
57
  return {
@@ -334,6 +340,22 @@ describe("renderContentfulRichText", () => {
334
340
  const emptySpan = Array.from(spans).find(s => s.textContent === "");
335
341
  expect(emptySpan).toBeDefined();
336
342
  });
343
+
344
+ it("renders componentVideoLink inline entry (REST shape)", () => {
345
+ const doc = makeDoc({
346
+ nodeType: BLOCKS.PARAGRAPH,
347
+ data: {},
348
+ content: [
349
+ makeInlineEntry("componentVideoLink", {
350
+ link: "https://vimeo.com/123",
351
+ }),
352
+ ],
353
+ });
354
+ const { container } = render(<>{renderContentfulRichText(doc)}</>);
355
+ expect(
356
+ container.querySelector('[data-testid="video-link"]')
357
+ ).toBeInTheDocument();
358
+ });
337
359
  });
338
360
 
339
361
  describe("Embedded entry rendering via defaultOptions", () => {
@@ -354,6 +376,74 @@ describe("renderContentfulRichText", () => {
354
376
  const { container } = render(<>{renderContentfulRichText(doc)}</>);
355
377
  expect(container.querySelector("aside")).not.toBeInTheDocument();
356
378
  });
379
+
380
+ it("renders componentVideoLink block entry (REST shape)", () => {
381
+ const doc = makeDoc(
382
+ makeEmbeddedEntry("componentVideoLink", {
383
+ image: { fields: { file: { url: "https://cdn.com/poster.jpg" } } },
384
+ link: "https://youtube.com/watch?v=123",
385
+ videoPopup: true,
386
+ })
387
+ );
388
+ const { container } = render(<>{renderContentfulRichText(doc)}</>);
389
+ const el = container.querySelector('[data-testid="video-link"]')!;
390
+ expect(el).toBeInTheDocument();
391
+ const videoLink = JSON.parse(el.getAttribute("data-video-link")!);
392
+ expect(videoLink).toMatchObject({
393
+ image: "https://cdn.com/poster.jpg",
394
+ link: "https://youtube.com/watch?v=123",
395
+ videoPopup: true,
396
+ });
397
+ });
398
+
399
+ it("renders ComponentVideoLink (GraphQL __typename) block entry resolved from links", () => {
400
+ const unresolvedNode: any = {
401
+ nodeType: BLOCKS.EMBEDDED_ENTRY,
402
+ data: { target: { sys: { id: "video-1" } } },
403
+ content: [],
404
+ };
405
+ const doc = makeDoc(unresolvedNode);
406
+ const links = {
407
+ entries: {
408
+ block: [
409
+ {
410
+ __typename: "ComponentVideoLink",
411
+ sys: { id: "video-1" },
412
+ videoSourceType: "contentful",
413
+ videoAssetUrl: "https://videos.ctfassets.net/a/b/c.mp4",
414
+ videoAssetType: "video/mp4",
415
+ title: "My video",
416
+ },
417
+ ],
418
+ inline: [],
419
+ },
420
+ };
421
+ const { container } = render(
422
+ <>{renderContentfulRichText(doc, undefined, "body1", "body1 font-bold", undefined, links)}</>
423
+ );
424
+ const el = container.querySelector('[data-testid="video-link"]')!;
425
+ expect(el).toBeInTheDocument();
426
+ const videoLink = JSON.parse(el.getAttribute("data-video-link")!);
427
+ expect(videoLink).toMatchObject({
428
+ videoSourceType: "contentful",
429
+ videoAssetUrl: "https://videos.ctfassets.net/a/b/c.mp4",
430
+ videoAssetType: "video/mp4",
431
+ title: "My video",
432
+ });
433
+ });
434
+
435
+ it("returns null for a GraphQL-style entry that cannot be resolved from links", () => {
436
+ const unresolvedNode: any = {
437
+ nodeType: BLOCKS.EMBEDDED_ENTRY,
438
+ data: { target: { sys: { id: "missing" } } },
439
+ content: [],
440
+ };
441
+ const doc = makeDoc(unresolvedNode);
442
+ const { container } = render(<>{renderContentfulRichText(doc)}</>);
443
+ expect(
444
+ container.querySelector('[data-testid="video-link"]')
445
+ ).not.toBeInTheDocument();
446
+ });
357
447
  });
358
448
 
359
449
  describe("Embedded asset via defaultOptions", () => {
@@ -659,6 +749,34 @@ describe("useContentfulRichText", () => {
659
749
  expect(container.querySelector("aside")).not.toBeInTheDocument();
660
750
  });
661
751
 
752
+ it("resolves a GraphQL-style componentVideoLink entry via config.links", () => {
753
+ const unresolvedNode: any = {
754
+ nodeType: BLOCKS.EMBEDDED_ENTRY,
755
+ data: { target: { sys: { id: "video-2" } } },
756
+ content: [],
757
+ };
758
+ const doc = makeDoc(unresolvedNode);
759
+ const links = {
760
+ entries: {
761
+ block: [
762
+ {
763
+ __typename: "ComponentVideoLink",
764
+ sys: { id: "video-2" },
765
+ link: "https://youtube.com/watch?v=abc",
766
+ },
767
+ ],
768
+ inline: [],
769
+ },
770
+ };
771
+ const { result } = renderHook(() =>
772
+ useContentfulRichText(doc, undefined, { links })
773
+ );
774
+ const { container } = render(<>{result.current}</>);
775
+ expect(
776
+ container.querySelector('[data-testid="video-link"]')
777
+ ).toBeInTheDocument();
778
+ });
779
+
662
780
  it("renders inline entry with componentCheckList type", () => {
663
781
  const doc = makeDoc({
664
782
  nodeType: BLOCKS.PARAGRAPH,
@@ -14,6 +14,8 @@ import { Checklist } from "@shared/components/checklist";
14
14
  import { Link } from "@shared/components/link";
15
15
  import { MaterialIcon } from "@shared/components/material-icon";
16
16
  import { Text } from "@shared/components/text";
17
+ import { VideoLink } from "@shared/components/video-link";
18
+ import type { VideoLinkProps } from "@shared/components/video-link";
17
19
  import { toDocument } from "@shared/utils/contentful/to-document";
18
20
 
19
21
  const isEmptyParagraph = (node: any): boolean =>
@@ -23,6 +25,49 @@ const isEmptyParagraph = (node: any): boolean =>
23
25
  child?.nodeType === "text" && (!child.value || child.value.trim() === "")
24
26
  );
25
27
 
28
+ // Resolves an embedded entry node's data. REST/CDA responses already resolve
29
+ // `data.target` to the full entry (fields + sys.contentType). GraphQL responses
30
+ // only include the sys.id reference, so the real entry must be looked up in the
31
+ // `links.entries.block`/`links.entries.inline` arrays returned alongside `json`.
32
+ const resolveEmbeddedEntry = (node: any, links?: any): any => {
33
+ const target = node?.data?.target;
34
+ if (target?.fields || target?.sys?.contentType) return target;
35
+ const id = target?.sys?.id;
36
+ if (!id) return null;
37
+ const pool = [
38
+ ...(links?.entries?.block ?? []),
39
+ ...(links?.entries?.inline ?? []),
40
+ ];
41
+ return pool.find((entry: any) => entry?.sys?.id === id) ?? null;
42
+ };
43
+
44
+ // Supports both REST (sys.contentType.sys.id) and GraphQL (__typename) shapes.
45
+ const getEntryTypeId = (entry: any): string =>
46
+ entry?.sys?.contentType?.sys?.id ?? entry?.__typename ?? "";
47
+
48
+ // REST entries expose data under `fields`; GraphQL entries have fields flattened.
49
+ const getEntryFields = (entry: any): any => entry?.fields ?? entry ?? {};
50
+
51
+ const toVideoLinkProps = (fields: any): VideoLinkProps => ({
52
+ image:
53
+ fields?.image?.fields?.file?.url ??
54
+ fields?.image?.url ??
55
+ fields?.image,
56
+ link: fields?.link,
57
+ videoPopup: Boolean(fields?.videoPopup),
58
+ videoSourceType: fields?.videoSourceType,
59
+ videoAssetUrl:
60
+ fields?.videoAssetUrl?.fields?.file?.url ??
61
+ fields?.videoAssetUrl?.url ??
62
+ fields?.videoAssetUrl,
63
+ videoAssetType:
64
+ fields?.videoAssetUrl?.fields?.file?.contentType ??
65
+ fields?.videoAssetUrl?.contentType ??
66
+ fields?.videoAssetType,
67
+ title: fields?.title ?? fields?.entryTitle,
68
+ description: fields?.description,
69
+ });
70
+
26
71
  // A link is "external" only when it belongs to a different app than the one
27
72
  // currently rendering this shared component (business vs. main/www are separate
28
73
  // apps). Matching is environment-agnostic so a prod link like
@@ -80,7 +125,7 @@ const isExternalUrl = (url: string): boolean => {
80
125
  }
81
126
  };
82
127
 
83
- const defaultOptions: Options = {
128
+ const getDefaultOptions = (links?: any): Options => ({
84
129
  renderMark: {
85
130
  [MARKS.BOLD]: text => <strong className="font-black">{text}</strong>,
86
131
  [MARKS.ITALIC]: text => <em>{text}</em>,
@@ -158,26 +203,33 @@ const defaultOptions: Options = {
158
203
  },
159
204
 
160
205
  [BLOCKS.EMBEDDED_ENTRY]: node => {
161
- const entry = (node as any)?.data?.target;
162
- const ctid = entry?.sys?.contentType?.sys?.id;
206
+ const entry = resolveEmbeddedEntry(node, links);
207
+ if (!entry) return null;
208
+ const ctid = getEntryTypeId(entry);
209
+ const fields = getEntryFields(entry);
163
210
  switch (ctid) {
164
211
  case "callout":
165
212
  return (
166
213
  <aside
167
214
  style={{ border: "1px solid #ddd", padding: 12, borderRadius: 8 }}
168
215
  >
169
- <strong>{entry.fields.title}</strong>
170
- <div>{entry.fields.body}</div>
216
+ <strong>{fields.title}</strong>
217
+ <div>{fields.body}</div>
171
218
  </aside>
172
219
  );
220
+ case "componentVideoLink":
221
+ case "ComponentVideoLink":
222
+ return <VideoLink videoLink={toVideoLinkProps(fields)} />;
173
223
  default:
174
224
  return null;
175
225
  }
176
226
  },
177
227
 
178
228
  [INLINES.EMBEDDED_ENTRY]: node => {
179
- const entry = (node as any)?.data?.target;
180
- const ctid = entry?.sys?.contentType?.sys?.id;
229
+ const entry = resolveEmbeddedEntry(node, links);
230
+ if (!entry) return null;
231
+ const ctid = getEntryTypeId(entry);
232
+ const fields = getEntryFields(entry);
181
233
  switch (ctid) {
182
234
  case "componentCheckList":
183
235
  return (
@@ -193,15 +245,18 @@ const defaultOptions: Options = {
193
245
  fontSize: "0.9em",
194
246
  }}
195
247
  >
196
- ✅ {entry.fields.title}
248
+ ✅ {fields.title}
197
249
  </span>
198
250
  );
251
+ case "componentVideoLink":
252
+ case "ComponentVideoLink":
253
+ return <VideoLink videoLink={toVideoLinkProps(fields)} />;
199
254
  default:
200
- return <span>{entry?.fields?.title ?? ""}</span>;
255
+ return <span>{fields?.title ?? ""}</span>;
201
256
  }
202
257
  },
203
258
  },
204
- };
259
+ });
205
260
  /**
206
261
  * Utility function to render Contentful Rich Text.
207
262
  * Safe to use inside .map() loops.
@@ -211,10 +266,12 @@ export function renderContentfulRichText(
211
266
  target?: boolean,
212
267
  className: string = "body1",
213
268
  linkClassName: string = "body1 font-bold",
214
- options?: Options
269
+ options?: Options,
270
+ links?: any
215
271
  ): ReactNode | null {
216
272
  if (!doc || !Array.isArray(doc.content)) return null;
217
273
 
274
+ const defaultOptions = getDefaultOptions(links);
218
275
  const merged: Options = {
219
276
  ...defaultOptions,
220
277
  ...options,
@@ -260,10 +317,11 @@ export function renderContentfulRichText(
260
317
  export function useContentfulRichText(
261
318
  doc: Document | null | undefined,
262
319
  options?: Options,
263
- config?: { boldClassName?: string }
320
+ config?: { boldClassName?: string; links?: any }
264
321
  ): ReactNode | null {
265
322
  return useMemo(() => {
266
323
  if (!doc || !Array.isArray(doc.content)) return null;
324
+ const defaultOptions = getDefaultOptions(config?.links);
267
325
  const merged: Options = {
268
326
  ...defaultOptions,
269
327
  renderMark: {
@@ -280,7 +338,7 @@ export function useContentfulRichText(
280
338
  renderNode: { ...defaultOptions.renderNode, ...options?.renderNode },
281
339
  };
282
340
  return documentToReactComponents(doc, merged);
283
- }, [doc, options, config?.boldClassName]);
341
+ }, [doc, options, config?.boldClassName, config?.links]);
284
342
  }
285
343
  // 1. Remove useMemo and React Hook imports from this specific function's logic
286
344
  export function renderContentfulRichTextTable(
@@ -289,6 +347,7 @@ export function renderContentfulRichTextTable(
289
347
  ) {
290
348
  if (!doc || !Array.isArray(doc.content)) return null;
291
349
 
350
+ const defaultOptions = getDefaultOptions(links);
292
351
  const options: Options = {
293
352
  ...defaultOptions, // Spread defaultOptions so <p> and <strong> still work!
294
353
  renderMark: {
package/src/index.ts CHANGED
@@ -29,6 +29,9 @@ export type { SpinnerProps } from "./components/spinner";
29
29
  export { Text } from "./components/text";
30
30
  export type { TextProps, TextVariant } from "./components/text";
31
31
 
32
+ export { VideoLink } from "./components/video-link";
33
+ export type { VideoLinkProps } from "./components/video-link";
34
+
32
35
  export { CallButton } from "./components/call-button";
33
36
  export type { CallButtonProps } from "./components/call-button";
34
37
 
@@ -83,6 +86,23 @@ export type { SkeletonProps } from "./components/skeleton";
83
86
  export { Tooltip } from "./components/tooltip";
84
87
  export type { ToolTipProps } from "./components/tooltip";
85
88
 
89
+ export {
90
+ FiberForm,
91
+ fiberFormAddressCollectionFormSchema,
92
+ fiberFormInputInitialValues,
93
+ fiberFormInputList,
94
+ } from "./contentful/blocks/fiber-form";
95
+ export type {
96
+ FiberAddress,
97
+ FiberAddressAutocompleteProps,
98
+ FiberAddressAutocompleteResult,
99
+ FiberAddressCandidate,
100
+ FiberAddressUnit,
101
+ FiberFormInputList,
102
+ FiberFormProps,
103
+ FiberFormValues,
104
+ } from "./contentful/blocks/fiber-form";
105
+
86
106
  export { AnimationWrapper } from "./components/animation-wrapper";
87
107
  export { DEFAULT_BUTTON_ANIMATION_TYPE } from "./components/animation-wrapper";
88
108
  export type {