@windstream/react-shared-components 0.2.60 → 0.2.62

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 (39) hide show
  1. package/dist/contentful/index.d.ts +48 -2
  2. package/dist/contentful/index.esm.js +5 -5
  3. package/dist/contentful/index.esm.js.map +1 -1
  4. package/dist/contentful/index.js +4 -4
  5. package/dist/contentful/index.js.map +1 -1
  6. package/dist/core.d.ts +1 -1
  7. package/dist/index.d.ts +3 -3
  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/styles.css +1 -1
  13. package/package.json +1 -1
  14. package/src/components/brand-button/index.tsx +1 -1
  15. package/src/components/video-link/index.tsx +28 -8
  16. package/src/components/video-link/youtube-embed.test.tsx +11 -7
  17. package/src/components/video-link/youtube-embed.tsx +58 -58
  18. package/src/contentful/blocks/anchored-bottom-banner/index.test.tsx +248 -0
  19. package/src/contentful/blocks/anchored-bottom-banner/index.tsx +197 -29
  20. package/src/contentful/blocks/anchored-bottom-banner/types.ts +46 -0
  21. package/src/contentful/blocks/cards/product-card/index.test.tsx +14 -1
  22. package/src/contentful/blocks/cards/product-card/index.tsx +193 -135
  23. package/src/contentful/blocks/cards/product-card/types.ts +1 -1
  24. package/src/contentful/blocks/carousel/Carousel.stories.tsx +59 -0
  25. package/src/contentful/blocks/carousel/helper.test.tsx +23 -0
  26. package/src/contentful/blocks/carousel/helper.tsx +18 -5
  27. package/src/contentful/blocks/carousel/types.ts +3 -0
  28. package/src/contentful/blocks/dynamic-form/conditional-fields.test.tsx +3 -0
  29. package/src/contentful/blocks/dynamic-form/conditional-fields.tsx +30 -4
  30. package/src/contentful/blocks/dynamic-form/context.ts +2 -0
  31. package/src/contentful/blocks/dynamic-form/field-map.test.tsx +21 -0
  32. package/src/contentful/blocks/dynamic-form/field-map.tsx +15 -0
  33. package/src/contentful/blocks/dynamic-form/form-field.tsx +2 -0
  34. package/src/contentful/blocks/dynamic-form/index.tsx +48 -16
  35. package/src/contentful/blocks/dynamic-form/render.tsx +1 -0
  36. package/src/contentful/blocks/dynamic-form/types.ts +26 -0
  37. package/src/contentful/blocks/dynamic-form/utils.test.ts +86 -0
  38. package/src/contentful/blocks/dynamic-form/utils.ts +57 -0
  39. package/src/contentful/index.ts +5 -1
@@ -189,6 +189,27 @@ describe("field-map", () => {
189
189
  fireEvent.click(screen.getAllByRole("checkbox")[0]);
190
190
  expect(handleInputChange).toHaveBeenCalledWith("b");
191
191
  });
192
+
193
+ it("gives each option a unique id so labels target the correct input", () => {
194
+ render(
195
+ <Checkbox {...baseProps({ dataSource, formData: {} })} />
196
+ );
197
+ const ids = screen
198
+ .getAllByRole("checkbox")
199
+ .map(input => input.getAttribute("id"));
200
+ expect(new Set(ids).size).toBe(ids.length);
201
+ });
202
+
203
+ it("selects a non-first option when its label is clicked", () => {
204
+ const handleInputChange = jest.fn();
205
+ render(
206
+ <Checkbox
207
+ {...baseProps({ dataSource, formData: {}, handleInputChange })}
208
+ />
209
+ );
210
+ fireEvent.click(screen.getByText("B"));
211
+ expect(handleInputChange).toHaveBeenCalledWith("b");
212
+ });
192
213
  });
193
214
 
194
215
  describe("DatePickerField", () => {
@@ -45,8 +45,22 @@ const TextInputField: React.FC<FieldRenderProps> = args => {
45
45
  handleInputChange,
46
46
  fieldId,
47
47
  errorClass,
48
+ enableAddressAutocomplete,
48
49
  } = args;
49
50
 
51
+ const context = useContext(FormContext);
52
+ const AddressAutocomplete = context?.renderAddressAutocomplete;
53
+ const shouldUseAddressAutocomplete =
54
+ !!enableAddressAutocomplete &&
55
+ !!AddressAutocomplete &&
56
+ textInputType !== "phone" &&
57
+ textInputType !== "email" &&
58
+ textInputType !== "multiline";
59
+
60
+ if (shouldUseAddressAutocomplete && AddressAutocomplete) {
61
+ return <AddressAutocomplete {...args} />;
62
+ }
63
+
50
64
  if (textInputType === "phone") {
51
65
  return (
52
66
  <div className="w-full">
@@ -160,6 +174,7 @@ const CheckboxInputField: React.FC<FieldRenderProps> = args => {
160
174
  <div key={`${fieldId}-${idx}`} className="w-full lg:w-[384px]">
161
175
  <Checkbox
162
176
  name={fieldId}
177
+ id={`${fieldId}-${idx}`}
163
178
  label={data.label}
164
179
  value={data.value}
165
180
  checked={selectedValues.includes(data.value)}
@@ -55,6 +55,7 @@ export const FormFieldElement: React.FC<FormFieldElementProps> = props => {
55
55
  validationType,
56
56
  applyRegularExpression,
57
57
  regexPattern,
58
+ enableAddressAutocomplete,
58
59
  } = props;
59
60
 
60
61
  const [error, setError] = useState<FieldError>({
@@ -203,6 +204,7 @@ export const FormFieldElement: React.FC<FormFieldElementProps> = props => {
203
204
  hasError={hasError}
204
205
  existingDataSource={existingDataSource}
205
206
  isConsentCheckbox={isConsentCheckbox}
207
+ enableAddressAutocomplete={enableAddressAutocomplete}
206
208
  data-testid="form-component"
207
209
  />
208
210
  </div>
@@ -21,10 +21,12 @@ import {
21
21
  import {
22
22
  arrayForFieldValidations,
23
23
  buildSftpMap,
24
+ extractErrorDetails,
24
25
  filterActionsForEmailTemplate,
25
26
  formPayloadCreation,
26
27
  generateSubmissionId,
27
28
  getInvalidFields,
29
+ sanitizeError,
28
30
  shouldShowPopup,
29
31
  } from "./utils";
30
32
 
@@ -117,6 +119,8 @@ export const DynamicForm: React.FC<DynamicFormProps> = props => {
117
119
  onError,
118
120
  fieldComponentOverrides,
119
121
  resolveExistingDataSource,
122
+ renderAddressAutocomplete,
123
+ renderSubmitButton,
120
124
  } = props;
121
125
 
122
126
  const submissionIdRef = useRef<string>(generateSubmissionId());
@@ -182,6 +186,7 @@ export const DynamicForm: React.FC<DynamicFormProps> = props => {
182
186
  setFiles: updateFiles,
183
187
  resolveExistingDataSource,
184
188
  fieldComponentOverrides,
189
+ renderAddressAutocomplete,
185
190
  onError,
186
191
  }),
187
192
  [
@@ -194,6 +199,7 @@ export const DynamicForm: React.FC<DynamicFormProps> = props => {
194
199
  updateFiles,
195
200
  resolveExistingDataSource,
196
201
  fieldComponentOverrides,
202
+ renderAddressAutocomplete,
197
203
  onError,
198
204
  ]
199
205
  );
@@ -266,25 +272,38 @@ export const DynamicForm: React.FC<DynamicFormProps> = props => {
266
272
  });
267
273
  } catch (error) {
268
274
  errorActions.push(action);
275
+ const { errorMessage, errorStatus, errorCode } =
276
+ extractErrorDetails(error);
269
277
  onTrack?.(`${segmentEventId}_${action}_failure`, {
270
278
  ...payload,
271
279
  eventSuccess: false,
280
+ errorMessage,
281
+ errorStatus,
282
+ errorCode,
272
283
  });
273
284
  onError?.({
274
285
  type: "action_failure",
275
- error,
286
+ error: sanitizeError(error),
276
287
  metadata: { action, submissionId: submissionIdRef.current },
277
288
  });
278
289
  }
279
290
  } else {
280
291
  errorActions.push(action);
292
+ const unsupportedError = new Error(
293
+ `Action "${action}" is not supported`
294
+ );
295
+ const { errorMessage, errorStatus, errorCode } =
296
+ extractErrorDetails(unsupportedError);
281
297
  onTrack?.(`${segmentEventId}_${action}_failure`, {
282
298
  ...payload,
283
299
  eventSuccess: false,
300
+ errorMessage,
301
+ errorStatus,
302
+ errorCode,
284
303
  });
285
304
  onError?.({
286
305
  type: "unsupported_action",
287
- error: new Error(`Action "${action}" is not supported`),
306
+ error: unsupportedError,
288
307
  metadata: { action },
289
308
  });
290
309
  }
@@ -302,7 +321,11 @@ export const DynamicForm: React.FC<DynamicFormProps> = props => {
302
321
  const newFormData = applyPresetValidations();
303
322
 
304
323
  try {
305
- const extractedArray = arrayForFieldValidations(fields, false, newFormData);
324
+ const extractedArray = arrayForFieldValidations(
325
+ fields,
326
+ false,
327
+ newFormData
328
+ );
306
329
  const sftpMap = buildSftpMap(fields, newFormData);
307
330
  const invalidFields = getInvalidFields(extractedArray, newFormData);
308
331
  setSubmitErrorFields(invalidFields);
@@ -433,7 +456,7 @@ export const DynamicForm: React.FC<DynamicFormProps> = props => {
433
456
 
434
457
  onError?.({
435
458
  type: errorType,
436
- error,
459
+ error: sanitizeError(error),
437
460
  metadata: {
438
461
  formName,
439
462
  submissionId: submissionIdRef.current,
@@ -522,18 +545,27 @@ export const DynamicForm: React.FC<DynamicFormProps> = props => {
522
545
  </Text>
523
546
  )}
524
547
  <div className="flex justify-center">
525
- <Button
526
- type="submit"
527
- disabled={hasError || isLoading}
528
- aria-label="Submit Form"
529
- className={cx(
530
- "button-submit mt-3 inline-flex h-[64px] w-full items-center justify-center gap-2 whitespace-nowrap rounded-full bg-bg-fill-brand px-[60px] text-base font-semibold text-text-brand-on-bg-fill transition hover:bg-bg-fill-brand-hover disabled:bg-bg-fill-brand-disabled md:mt-8 md:w-auto",
531
- ctaClassName
532
- )}
533
- >
534
- {isLoading && <Spinner />}
535
- {submitLabel}
536
- </Button>
548
+ {renderSubmitButton ? (
549
+ renderSubmitButton({
550
+ isLoading,
551
+ disabled: hasError || isLoading,
552
+ submitLabel,
553
+ onSubmit: handleSubmit,
554
+ })
555
+ ) : (
556
+ <Button
557
+ type="submit"
558
+ disabled={hasError || isLoading}
559
+ aria-label="Submit Form"
560
+ className={cx(
561
+ "button-submit mt-3 inline-flex h-[64px] w-full items-center justify-center gap-2 whitespace-nowrap rounded-full bg-bg-fill-brand px-[60px] text-base font-semibold text-text-brand-on-bg-fill transition hover:bg-bg-fill-brand-hover disabled:bg-bg-fill-brand-disabled md:mt-8 md:w-auto",
562
+ ctaClassName
563
+ )}
564
+ >
565
+ {isLoading && <Spinner />}
566
+ {submitLabel}
567
+ </Button>
568
+ )}
537
569
  </div>
538
570
  </form>
539
571
  </FormContext.Provider>
@@ -42,6 +42,7 @@ const renderItem = (
42
42
  formData={formData}
43
43
  setFormData={setFormData}
44
44
  submitErrorFields={submitErrorFields}
45
+ renderConditionalFields={dynamicRenderingWithGroupings}
45
46
  />
46
47
  );
47
48
  }
@@ -51,6 +51,8 @@ export interface DynamicFormFieldConfig {
51
51
  regexPattern?: string;
52
52
  applyPresetValidations?: boolean;
53
53
  selectPresetValidationsToApply?: string;
54
+ /** When true, a Text field renders the app-injected address autocomplete. */
55
+ enableAddressAutocomplete?: boolean;
54
56
  }
55
57
 
56
58
  export interface ConditionalFieldConfig {
@@ -60,6 +62,11 @@ export interface ConditionalFieldConfig {
60
62
  dataSource?: DynamicFormFieldOption[] | null;
61
63
  note?: string;
62
64
  formFieldsToShow: DynamicFormItem[];
65
+ /**
66
+ * When true, the app has opted this field into the grouped layout styling.
67
+ * Injected by the consuming app so the shared package stays route-agnostic.
68
+ */
69
+ grouped?: boolean;
63
70
  }
64
71
 
65
72
  /**
@@ -128,6 +135,7 @@ export interface FieldRenderProps {
128
135
  hasError: boolean;
129
136
  existingDataSource?: string;
130
137
  isConsentCheckbox?: boolean;
138
+ enableAddressAutocomplete?: boolean;
131
139
  [key: string]: any;
132
140
  }
133
141
 
@@ -208,4 +216,22 @@ export interface DynamicFormProps {
208
216
  Record<DynamicFormFieldType, React.FC<FieldRenderProps>>
209
217
  >;
210
218
  resolveExistingDataSource?: ExistingDataSourceResolver;
219
+
220
+ /**
221
+ * App-injected renderer for address-autocomplete Text fields. The package is
222
+ * transport-agnostic, so address suggestion fetching lives in the app.
223
+ */
224
+ renderAddressAutocomplete?: React.FC<FieldRenderProps>;
225
+
226
+ /**
227
+ * App-injected submit button, so the app can attach its own CTA analytics,
228
+ * query-param preservation and animation. When omitted a default button is
229
+ * rendered.
230
+ */
231
+ renderSubmitButton?: (args: {
232
+ isLoading: boolean;
233
+ disabled: boolean;
234
+ submitLabel: string;
235
+ onSubmit: (event: any) => void;
236
+ }) => React.ReactNode;
211
237
  }
@@ -2,12 +2,14 @@ import { DynamicFormItem } from "./types";
2
2
  import {
3
3
  arrayForFieldValidations,
4
4
  buildSftpMap,
5
+ extractErrorDetails,
5
6
  filterActionsForEmailTemplate,
6
7
  formPayloadCreation,
7
8
  generateSubmissionId,
8
9
  getInvalidFields,
9
10
  groupFieldsForLayout,
10
11
  processFields,
12
+ sanitizeErrorMessage,
11
13
  shouldShowPopup,
12
14
  } from "./utils";
13
15
 
@@ -235,4 +237,88 @@ describe("dynamic-form/utils", () => {
235
237
  }
236
238
  });
237
239
  });
240
+
241
+ describe("sanitizeErrorMessage", () => {
242
+ it("redacts bearer tokens including +, /, and = characters", () => {
243
+ const credential = "ab+cd/ef=gh";
244
+ const result = sanitizeErrorMessage(
245
+ `Request failed with ${"Bearer"} ${credential} and retry`
246
+ );
247
+ expect(result).toBe("Request failed with Bearer [REDACTED] and retry");
248
+ expect(result).not.toContain(credential);
249
+ });
250
+
251
+ it("redacts Authorization headers", () => {
252
+ const secret = "dXNlcjpwYXNzd29yZA==";
253
+ const result = sanitizeErrorMessage(`Authorization: ${secret}`);
254
+ expect(result).toBe("Authorization: [REDACTED]");
255
+ expect(result).not.toContain(secret);
256
+ });
257
+
258
+ it("redacts api key, token, secret, and password values", () => {
259
+ expect(sanitizeErrorMessage("api_key=abc123&foo=bar")).toBe(
260
+ "api_key=[REDACTED]&foo=bar"
261
+ );
262
+ expect(sanitizeErrorMessage('password: "s3cr3t!"')).toBe(
263
+ "password: [REDACTED]"
264
+ );
265
+ expect(sanitizeErrorMessage("access-token: 'tok/en+val='")).toBe(
266
+ "access-token: [REDACTED]"
267
+ );
268
+ });
269
+
270
+ it("redacts a full quoted value through the closing quote", () => {
271
+ const result = sanitizeErrorMessage('secret="my secret with spaces"');
272
+ expect(result).toBe("secret=[REDACTED]");
273
+ expect(result).not.toContain("my secret with spaces");
274
+ });
275
+
276
+ it("leaves messages without secrets untouched", () => {
277
+ expect(sanitizeErrorMessage("Network timeout")).toBe("Network timeout");
278
+ });
279
+ });
280
+
281
+ describe("extractErrorDetails", () => {
282
+ it("extracts a sanitized message from a native Error", () => {
283
+ const credential = "xyz+123/456=";
284
+ const { errorMessage } = extractErrorDetails(
285
+ new Error(`Failed with ${"Bearer"} ${credential}`)
286
+ );
287
+ expect(errorMessage).toBe("Failed with Bearer [REDACTED]");
288
+ expect(errorMessage).not.toContain(credential);
289
+ });
290
+
291
+ it("stringifies non-Error values", () => {
292
+ expect(extractErrorDetails("boom")).toEqual({
293
+ errorMessage: "boom",
294
+ errorStatus: undefined,
295
+ errorCode: undefined,
296
+ });
297
+ });
298
+
299
+ it("extracts status from an Axios-style response", () => {
300
+ const { errorStatus } = extractErrorDetails({
301
+ message: "Request failed",
302
+ response: { status: 401 },
303
+ });
304
+ expect(errorStatus).toBe(401);
305
+ });
306
+
307
+ it("extracts status and code from a wrapped error cause", () => {
308
+ const error = Object.assign(new Error("Wrapped failure"), {
309
+ cause: { response: { status: 503 }, code: "ETIMEDOUT" },
310
+ });
311
+ const { errorStatus, errorCode } = extractErrorDetails(error);
312
+ expect(errorStatus).toBe(503);
313
+ expect(errorCode).toBe("ETIMEDOUT");
314
+ });
315
+
316
+ it("omits a non-string code so objects cannot leak downstream", () => {
317
+ const { errorCode } = extractErrorDetails({
318
+ message: "bad",
319
+ code: { secret: "value" },
320
+ });
321
+ expect(errorCode).toBeUndefined();
322
+ });
323
+ });
238
324
  });
@@ -252,4 +252,61 @@ export const filterActionsForEmailTemplate = (
252
252
  return actions;
253
253
  };
254
254
 
255
+ /**
256
+ * Redact common secret/PII patterns (bearer tokens, api keys, auth headers,
257
+ * token/key values) from an error message before it is forwarded to analytics,
258
+ * so credentials never leak into downstream tracking events.
259
+ */
260
+ export const sanitizeErrorMessage = (message: string): string => {
261
+ return message
262
+ .replace(/Bearer\s+\S+/gi, "Bearer [REDACTED]")
263
+ .replace(
264
+ /["']?Authorization["']?\s*[:=]\s*(?:"[^"]*"|'[^']*'|\S+)/gi,
265
+ "Authorization: [REDACTED]"
266
+ )
267
+ .replace(
268
+ /(api(?:[_-]|\s)?key|apikey|access(?:[_-]|\s)?token|token|secret|password|key)(\s*["']?\s*[:=]\s*)(?:"[^"]*"|'[^']*'|[^\s&"']+)/gi,
269
+ "$1$2[REDACTED]"
270
+ );
271
+ };
272
+
273
+ /**
274
+ * Safely extract a serializable, sanitized message/status/code from an unknown
275
+ * caught error (Axios error, native Error, or wrapped ApiError with `.cause`)
276
+ * so it can be attached to failure tracking events without leaking secrets.
277
+ */
278
+ export const extractErrorDetails = (
279
+ error: unknown
280
+ ): { errorMessage: string; errorStatus?: number; errorCode?: string } => {
281
+ const rawMessage = error instanceof Error ? error.message : String(error);
282
+ const errorMessage = sanitizeErrorMessage(rawMessage);
283
+ const cause = (error as any)?.cause;
284
+ const errorStatus =
285
+ (error as any)?.response?.status ??
286
+ (error as any)?.status ??
287
+ cause?.response?.status ??
288
+ undefined;
289
+ const errorCode = [error, cause]
290
+ .map(source => (source as any)?.code)
291
+ .find((code): code is string => typeof code === "string");
292
+ return { errorMessage, errorStatus, errorCode };
293
+ };
294
+
295
+ /**
296
+ * Build a serializable, sanitized error object from an unknown caught error so
297
+ * it can be handed to app-injected error/logging sinks (`onError`) without
298
+ * exposing raw messages, responses, or causes that may carry credentials.
299
+ */
300
+ export const sanitizeError = (
301
+ error: unknown
302
+ ): { name: string; message: string; status?: number; code?: string } => {
303
+ const { errorMessage, errorStatus, errorCode } = extractErrorDetails(error);
304
+ return {
305
+ name: error instanceof Error ? error.name : "Error",
306
+ message: errorMessage,
307
+ ...(errorStatus !== undefined ? { status: errorStatus } : {}),
308
+ ...(errorCode !== undefined ? { code: errorCode } : {}),
309
+ };
310
+ };
311
+
255
312
  export { constants, isFormField, isConditional };
@@ -88,7 +88,11 @@ export { DynamicTabs } from "./blocks/dynamic-tabs";
88
88
  export type { DynamicTabsT } from "./blocks/dynamic-tabs/types";
89
89
 
90
90
  export { AnchoredBottomBanner } from "./blocks/anchored-bottom-banner";
91
- export type { AnchoredBottomBannerProps } from "./blocks/anchored-bottom-banner/types";
91
+ export type {
92
+ AnchoredBottomBannerProps,
93
+ ScheduledMessage,
94
+ ScheduledMessageDay,
95
+ } from "./blocks/anchored-bottom-banner/types";
92
96
 
93
97
  export { default as CookieBanner } from "./blocks/cookiebanner";
94
98
  export type { TypeComponentRichTextFields as CookieBannerProps } from "./blocks/cookiebanner/type";