From 29823ee0a8b1196539b42bbf48f31457b3f61c2f Mon Sep 17 00:00:00 2001 From: Nate Smith Date: Wed, 20 May 2026 14:15:11 -0700 Subject: [PATCH 1/6] empty commit to start release 2.86.0 From 94f95771e9edc68f25ecdd6935f30ae1e5b1f011 Mon Sep 17 00:00:00 2001 From: jpople Date: Thu, 21 May 2026 11:48:13 -0500 Subject: [PATCH 2/6] ENG-3523: Add new custom field types to privacy center form (#8070) --- .../8070-privacy-center-custom-fields.yaml | 4 + .../__tests__/common/validation.test.ts | 2 +- .../privacy-request-modal/fileUpload.test.ts | 232 ++++++++++++++++++ clients/privacy-center/common/validation.ts | 53 +++- .../components/common/CustomFieldRenderer.tsx | 117 ++++++++- .../common/buildCustomFieldProps.ts | 84 +++++++ .../ConsentRequestForm.tsx | 85 +++---- .../PrivacyRequestForm.tsx | 48 +--- .../privacy-request-modal/fileUploadUtils.ts | 91 +++++++ .../usePrivacyRequestForm.ts | 108 ++++++-- .../hooks/useCustomFieldsForm.ts | 59 ++++- clients/privacy-center/types/api/index.ts | 5 + .../types/api/models/ConditionGroup.ts | 17 ++ .../types/api/models/ConditionLeaf.ts | 20 ++ .../FileUploadCustomPrivacyRequestField.ts | 22 ++ .../types/api/models/GroupOperator.ts | 8 + .../types/api/models/Operator.ts | 23 ++ ...enter_config__CustomPrivacyRequestField.ts | 21 +- ..._redis_cache__CustomPrivacyRequestField.ts | 2 +- clients/privacy-center/types/config.ts | 28 +++ clients/privacy-center/types/forms.ts | 22 +- .../privacy_request_attachments_exceptions.py | 6 +- 22 files changed, 914 insertions(+), 143 deletions(-) create mode 100644 changelog/8070-privacy-center-custom-fields.yaml create mode 100644 clients/privacy-center/__tests__/components/modals/privacy-request-modal/fileUpload.test.ts create mode 100644 clients/privacy-center/components/common/buildCustomFieldProps.ts create mode 100644 clients/privacy-center/components/modals/privacy-request-modal/fileUploadUtils.ts create mode 100644 clients/privacy-center/types/api/models/ConditionGroup.ts create mode 100644 clients/privacy-center/types/api/models/ConditionLeaf.ts create mode 100644 clients/privacy-center/types/api/models/FileUploadCustomPrivacyRequestField.ts create mode 100644 clients/privacy-center/types/api/models/GroupOperator.ts create mode 100644 clients/privacy-center/types/api/models/Operator.ts diff --git a/changelog/8070-privacy-center-custom-fields.yaml b/changelog/8070-privacy-center-custom-fields.yaml new file mode 100644 index 00000000000..e51913259cf --- /dev/null +++ b/changelog/8070-privacy-center-custom-fields.yaml @@ -0,0 +1,4 @@ +type: Added +description: Support for checkbox, checkbox_group, textarea, and file upload custom field types in the privacy center request form +pr: 8070 +labels: [] diff --git a/clients/privacy-center/__tests__/common/validation.test.ts b/clients/privacy-center/__tests__/common/validation.test.ts index 65bbcef1053..fd99ffd2863 100644 --- a/clients/privacy-center/__tests__/common/validation.test.ts +++ b/clients/privacy-center/__tests__/common/validation.test.ts @@ -59,7 +59,7 @@ describe("validateConfig", () => { expected: { isValid: false, message: - "A default_value or query_param_key is required for hidden field(s) 'tenant_id' in the action with policy_key 'default_access_policy', 'tenant_id' in the action with policy_key 'default_erasure_policy'", + "A default_value or query_param_key is required for hidden field(s) 'tenant_id' in the action with policy_key 'default_access_policy'; A default_value or query_param_key is required for hidden field(s) 'tenant_id' in the action with policy_key 'default_erasure_policy'", }, }, // Required field validation diff --git a/clients/privacy-center/__tests__/components/modals/privacy-request-modal/fileUpload.test.ts b/clients/privacy-center/__tests__/components/modals/privacy-request-modal/fileUpload.test.ts new file mode 100644 index 00000000000..78db84bca98 --- /dev/null +++ b/clients/privacy-center/__tests__/components/modals/privacy-request-modal/fileUpload.test.ts @@ -0,0 +1,232 @@ +import type { UploadFile } from "fidesui"; + +import { + uploadAllFiles, + uploadFieldFiles, + uploadFile, +} from "~/components/modals/privacy-request-modal/fileUploadUtils"; + +const API_URL = "http://localhost:8080/api/v1"; + +const defaultContext = { + propertyId: "prop_123", + policyKey: "default_access_policy", + fieldName: "documents", +}; + +const mockFile = new File(["hello"], "hello.txt", { type: "text/plain" }); + +/** Helper to build a minimal UploadFile-like object. */ +const makeUploadFile = (uid: string, originFileObj?: File): UploadFile => + ({ + uid, + name: originFileObj?.name ?? uid, + originFileObj, + }) as UploadFile; + +// --------------------------------------------------------------------------- +// uploadFile +// --------------------------------------------------------------------------- +describe("uploadFile", () => { + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("returns the attachment id on success", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: "abc123" }), + }); + + const id = await uploadFile(mockFile, API_URL, defaultContext); + expect(id).toBe("abc123"); + + const [url, init] = (global.fetch as jest.Mock).mock.calls[0]; + expect(url).toBe(`${API_URL}/privacy-request/attachment`); + expect(init.method).toBe("POST"); + expect(init.body).toBeInstanceOf(FormData); + }); + + it("throws with the detail message when the response is not ok", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 413, + json: async () => ({ detail: "Too large" }), + }); + + await expect(uploadFile(mockFile, API_URL, defaultContext)).rejects.toThrow( + "Too large", + ); + }); + + it("throws with a generic message when the error body is unparseable", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => { + throw new Error("not json"); + }, + }); + + await expect(uploadFile(mockFile, API_URL, defaultContext)).rejects.toThrow( + "File upload failed (500)", + ); + }); + + it("does not append property_id to FormData when propertyId is empty", async () => { + (global.fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: "id1" }), + }); + + await uploadFile(mockFile, API_URL, { + ...defaultContext, + propertyId: "", + }); + + const { body } = (global.fetch as jest.Mock).mock.calls[0][1]; + expect(body.get("property_id")).toBeNull(); + expect(body.get("policy_key")).toBe("default_access_policy"); + }); +}); + +// --------------------------------------------------------------------------- +// uploadFieldFiles +// --------------------------------------------------------------------------- +describe("uploadFieldFiles", () => { + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + it("filters out entries that lack originFileObj", async () => { + (global.fetch as jest.Mock).mockResolvedValue({ + ok: true, + json: async () => ({ id: "id1" }), + }); + + const fileList: UploadFile[] = [ + makeUploadFile("no-file"), + makeUploadFile("has-file", mockFile), + ]; + + const ids = await uploadFieldFiles(fileList, API_URL, defaultContext); + expect(ids).toEqual(["id1"]); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it("returns IDs in order for files that have originFileObj", async () => { + const file1 = new File(["a"], "a.txt"); + const file2 = new File(["b"], "b.txt"); + + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ ok: true, json: async () => ({ id: "id-a" }) }) + .mockResolvedValueOnce({ ok: true, json: async () => ({ id: "id-b" }) }); + + const fileList: UploadFile[] = [ + makeUploadFile("1", file1), + makeUploadFile("2", file2), + ]; + + const ids = await uploadFieldFiles(fileList, API_URL, defaultContext); + expect(ids).toEqual(["id-a", "id-b"]); + }); + + it("propagates error if one upload fails mid-sequence", async () => { + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ ok: true, json: async () => ({ id: "id-ok" }) }) + .mockResolvedValueOnce({ + ok: false, + status: 500, + json: async () => ({ detail: "server error" }), + }); + + const fileList: UploadFile[] = [ + makeUploadFile("1", new File(["a"], "a.txt")), + makeUploadFile("2", new File(["b"], "b.txt")), + ]; + + await expect( + uploadFieldFiles(fileList, API_URL, defaultContext), + ).rejects.toThrow("server error"); + }); +}); + +// --------------------------------------------------------------------------- +// uploadAllFiles +// --------------------------------------------------------------------------- +describe("uploadAllFiles", () => { + beforeEach(() => { + global.fetch = jest.fn(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + }); + + const baseContext = { propertyId: "prop_1", policyKey: "policy_1" }; + + it("skips fields with empty file lists", async () => { + const values = { docs: [] as UploadFile[] }; + const fields = { docs: { field_type: "file" } }; + + const result = await uploadAllFiles(values, fields, API_URL, baseContext); + expect(result).toEqual({}); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("skips non-file-type fields", async () => { + const values = { reason: "some text" }; + const fields = { reason: { field_type: "text" } }; + + const result = await uploadAllFiles(values, fields, API_URL, baseContext); + expect(result).toEqual({}); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it("returns correct fieldKey to id[] map for populated file fields", async () => { + (global.fetch as jest.Mock) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: "att-1" }), + }) + .mockResolvedValueOnce({ + ok: true, + json: async () => ({ id: "att-2" }), + }); + + const values = { + docs: [ + makeUploadFile("f1", new File(["a"], "a.pdf")), + makeUploadFile("f2", new File(["b"], "b.pdf")), + ] as UploadFile[], + reason: "some text", + }; + + const fields = { + docs: { field_type: "file" }, + reason: { field_type: "text" }, + }; + + const result = await uploadAllFiles(values, fields, API_URL, baseContext); + expect(result).toEqual({ docs: ["att-1", "att-2"] }); + }); + + it("omits fields from result when no IDs are produced", async () => { + // All entries lack originFileObj, so no uploads happen and no IDs are produced. + const values = { + docs: [makeUploadFile("no-origin")] as UploadFile[], + }; + const fields = { docs: { field_type: "file" } }; + + const result = await uploadAllFiles(values, fields, API_URL, baseContext); + expect(result).toEqual({}); + }); +}); diff --git a/clients/privacy-center/common/validation.ts b/clients/privacy-center/common/validation.ts index 481860133bd..29162de2305 100644 --- a/clients/privacy-center/common/validation.ts +++ b/clients/privacy-center/common/validation.ts @@ -147,13 +147,14 @@ export const validateConfig = ( } const invalidFieldMessages = (config.actions ?? []).flatMap((action) => { + const messages: string[] = []; + const fields = Object.entries(action.custom_privacy_request_fields || {}); + /* Validate that hidden fields must have a default_value or a query_param_key defined, otherwise the field would never get a value assigned. */ - const invalidFields = Object.entries( - action.custom_privacy_request_fields || {}, - ) + const hiddenWithoutValue = fields .filter( ([, field]) => field.hidden && @@ -162,21 +163,47 @@ export const validateConfig = ( ) .map(([key]) => `'${key}'`); - return invalidFields.length > 0 - ? [ - `${invalidFields.join(", ")} in the action with policy_key '${ - action.policy_key - }'`, - ] - : []; + if (hiddenWithoutValue.length > 0) { + messages.push( + `A default_value or query_param_key is required for hidden field(s) ${hiddenWithoutValue.join(", ")} in the action with policy_key '${action.policy_key}'`, + ); + } + + /* + Validate field_type constraints: + - checkbox_group and multiselect require options + - checkbox and textarea must not have options + */ + fields.forEach(([key, field]) => { + const ft = field.field_type; + if ( + (ft === "checkbox_group" || ft === "multiselect") && + (!("options" in field) || + !Array.isArray(field.options) || + field.options.length === 0) + ) { + messages.push( + `'${key}' in the action with policy_key '${action.policy_key}' is a ${ft} field and requires options`, + ); + } + if ( + (ft === "checkbox" || ft === "textarea") && + "options" in field && + field.options + ) { + messages.push( + `'${key}' in the action with policy_key '${action.policy_key}' is a ${ft} field and must not have options`, + ); + } + }); + + return messages; }); if (invalidFieldMessages.length > 0) { return { isValid: false, - message: `A default_value or query_param_key is required for hidden field(s) ${invalidFieldMessages.join( - ", ", - )}`, + message: invalidFieldMessages.join("; "), }; } diff --git a/clients/privacy-center/components/common/CustomFieldRenderer.tsx b/clients/privacy-center/components/common/CustomFieldRenderer.tsx index 700a9978969..8817d91aafc 100644 --- a/clients/privacy-center/components/common/CustomFieldRenderer.tsx +++ b/clients/privacy-center/components/common/CustomFieldRenderer.tsx @@ -1,16 +1,31 @@ import dayjs from "dayjs"; -import { DatePicker, Input, LocationSelect, Select } from "fidesui"; +import { + Button, + Checkbox, + DatePicker, + Input, + LocationSelect, + Select, + Upload, + UploadFile, +} from "fidesui"; import { ReactNode } from "react"; import { + CustomCheckboxField, + CustomCheckboxGroupField, CustomDateField, + CustomFileUploadField, CustomLocationField, CustomMultiSelectField, CustomSelectField, + CustomTextareaField, CustomTextField, ICustomField, } from "~/types/config"; +const DEFAULT_MAX_FILE_COUNT = 10; + interface ICustomFieldProps extends ICustomField { onBlur: () => void; fieldKey: string; @@ -33,6 +48,30 @@ interface ICustomMultiSelectFieldProps onChange: (value: Array) => void; } +interface ICustomCheckboxFieldProps + extends CustomCheckboxField, ICustomFieldProps { + value: boolean; + onChange: (value: boolean) => void; +} + +interface ICustomCheckboxGroupFieldProps + extends CustomCheckboxGroupField, ICustomFieldProps { + value: Array; + onChange: (value: Array) => void; +} + +interface ICustomTextareaFieldProps + extends CustomTextareaField, ICustomFieldProps { + value: string; + onChange: (value: string) => void; +} + +interface ICustomFileUploadFieldProps + extends CustomFileUploadField, ICustomFieldProps { + value: UploadFile[]; + onChange: (fileList: UploadFile[]) => void; +} + interface ICustomLocationFieldProps extends CustomLocationField, ICustomFieldProps { value: string; @@ -48,6 +87,10 @@ export type CustomFieldRendererProps = | ICustomTextFieldProps | ICustomSelectFieldProps | ICustomMultiSelectFieldProps + | ICustomCheckboxFieldProps + | ICustomCheckboxGroupFieldProps + | ICustomTextareaFieldProps + | ICustomFileUploadFieldProps | ICustomLocationFieldProps | ICustomDateFieldProps; @@ -131,6 +174,78 @@ const CustomFieldRenderer = ({ /> ); + case "checkbox": + return ( + { + props.onChange(e.target.checked); + onBlur(); + }} + aria-label={label} + aria-describedby={`${fieldKey}-error`} + aria-required={required !== false} + > + {label} + + ); + + case "checkbox_group": + return ( + { + props.onChange(checkedValues as string[]); + onBlur(); + }} + options={props.options?.map((option) => ({ + label: option, + value: option, + }))} + aria-label={label} + aria-describedby={`${fieldKey}-error`} + /> + ); + + case "textarea": + return ( + props.onChange(e.target.value)} + onBlur={onBlur} + value={props.value} + rows={4} + aria-label={label} + aria-describedby={`${fieldKey}-error`} + aria-required={required !== false} + /> + ); + + case "file": + return ( + { + props.onChange(newFileList); + onBlur(); + }} + beforeUpload={() => false} + multiple + maxCount={props.max_file_count ?? DEFAULT_MAX_FILE_COUNT} + accept={props.allowed_file_types?.join(",")} + data-testid={`file-upload-${fieldKey}`} + > + + + ); + case "location": return ( void; + handleBlur: (e: { target: { name: string } }) => void; + touched: FormikTouched; + errors: FormikErrors; +} + +export const buildCustomFieldProps = ( + key: string, + value: FormFieldValue, + fieldConfig: CustomConfigField, + { setFieldValue, handleBlur, touched, errors }: FieldFormContext, +): CustomFieldRendererProps => { + const sharedProps = { + fieldKey: key, + onBlur: () => handleBlur({ target: { name: key } }), + error: touched[key] && errors[key] ? (errors[key] as string) : undefined, + }; + + switch (fieldConfig.field_type) { + case "multiselect": + case "checkbox_group": { + let arrayValue: string[]; + if (typeof value === "string") { + arrayValue = [value]; + } else if (Array.isArray(value)) { + arrayValue = value as string[]; + } else { + arrayValue = []; + } + return { + ...fieldConfig, + ...sharedProps, + value: arrayValue, + onChange: (v: Array) => setFieldValue(key, v), + }; + } + case "checkbox": + return { + ...fieldConfig, + ...sharedProps, + value: Boolean(value), + onChange: (v: boolean) => setFieldValue(key, v), + }; + case "file": + return { + ...fieldConfig, + ...sharedProps, + value: Array.isArray(value) ? (value as UploadFile[]) : [], + onChange: (fileList: UploadFile[]) => setFieldValue(key, fileList), + }; + case "textarea": + return { + ...fieldConfig, + ...sharedProps, + value: typeof value === "string" ? value : "", + onChange: (v: string) => setFieldValue(key, v), + }; + default: { + let stringValue: string; + if (typeof value === "string") { + stringValue = value; + } else if (Array.isArray(value) && value.length > 0) { + stringValue = value[0] as string; + } else { + stringValue = ""; + } + return { + ...fieldConfig, + ...sharedProps, + value: stringValue, + onChange: (v: string) => setFieldValue(key, v), + }; + } + } +}; diff --git a/clients/privacy-center/components/modals/consent-request-modal/ConsentRequestForm.tsx b/clients/privacy-center/components/modals/consent-request-modal/ConsentRequestForm.tsx index f20c7200895..2e2158b43c6 100644 --- a/clients/privacy-center/components/modals/consent-request-modal/ConsentRequestForm.tsx +++ b/clients/privacy-center/components/modals/consent-request-modal/ConsentRequestForm.tsx @@ -1,13 +1,11 @@ import { Button, Flex, Form, Input, Text } from "fidesui"; import React, { useEffect } from "react"; -import CustomFieldRenderer, { - CustomFieldRendererProps, -} from "~/components/common/CustomFieldRenderer"; +import { buildCustomFieldProps } from "~/components/common/buildCustomFieldProps"; +import CustomFieldRenderer from "~/components/common/CustomFieldRenderer"; import { ModalViews } from "~/components/modals/types"; import { PhoneInput } from "~/components/phone-input"; import { useConfig } from "~/features/common/config.slice"; -import { CustomConfigField } from "~/types/config"; import useConsentRequestForm from "./useConsentRequestForm"; @@ -72,7 +70,7 @@ const ConsentRequestForm = ({ validateStatus={ touched.email && !!errors.email ? "error" : undefined } - help={touched.email && errors.email} + help={touched.email && (errors.email as string)} required={emailInput === "required"} hasFeedback={touched.email && !!errors.email} label="Email" @@ -94,7 +92,7 @@ const ConsentRequestForm = ({ validateStatus={ touched.phone && !!errors.phone ? "error" : undefined } - help={touched.phone && errors.phone} + help={touched.phone && (errors.phone as string)} required={phoneInput === "required"} hasFeedback={touched.phone && !!errors.phone} label="Phone" @@ -111,58 +109,31 @@ const ConsentRequestForm = ({ /> )} - {Object.entries(customPrivacyRequestFields).map(([key, item]) => { - const customFieldProps = ( - value: string | string[], - fieldConfig: CustomConfigField, - ): CustomFieldRendererProps => { - const sharedProps = { - fieldKey: key, - onBlur: () => handleBlur({ target: { name: key } }), - error: touched[key] && errors[key] ? errors[key] : undefined, - }; - - switch (fieldConfig.field_type) { - case "multiselect": - return { - ...fieldConfig, - ...sharedProps, - value: typeof value === "string" ? [value] : value, - onChange: (v: Array) => { - setFieldValue(key, v); - }, - }; - default: - return { - ...fieldConfig, - ...sharedProps, - value: typeof value === "string" ? value : value?.[0], - onChange: (v: string) => { - setFieldValue(key, v); - }, - }; + {Object.entries(customPrivacyRequestFields).map(([key, item]) => ( + - - - ); - })} + help={touched[key] && (errors[key] as string)} + required={item.required !== false} + hasFeedback={ + item.field_type === "text" && touched[key] && !!errors[key] + } + label={item.label} + htmlFor={key} + > + + + ))}