diff --git a/.fides/db_dataset.yml b/.fides/db_dataset.yml index cf7390f546a..a805b7f15cd 100644 --- a/.fides/db_dataset.yml +++ b/.fides/db_dataset.yml @@ -3728,6 +3728,8 @@ dataset: data_categories: [system.operations] - name: trigger_source data_categories: [system.operations] + - name: details + data_categories: [system.operations] - name: stagedresourceancestor fields: - name: id 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/changelog/8163-conditional-field-validation.yaml b/changelog/8163-conditional-field-validation.yaml new file mode 100644 index 00000000000..daebb3464d5 --- /dev/null +++ b/changelog/8163-conditional-field-validation.yaml @@ -0,0 +1,4 @@ +type: Added +description: Added client-side conditional validation for privacy center custom form fields, allowing fields to show/hide based on display_condition rules +pr: 8163 +labels: [] diff --git a/changelog/8259-fix-vendors-table-filter.yaml b/changelog/8259-fix-vendors-table-filter.yaml new file mode 100644 index 00000000000..22434b76886 --- /dev/null +++ b/changelog/8259-fix-vendors-table-filter.yaml @@ -0,0 +1,4 @@ +type: Fixed +description: Fixed source filter on the Vendors table silently doing nothing +pr: 8259 +labels: [] diff --git a/changelog/8261-fix-custom-field-template-populate.yaml b/changelog/8261-fix-custom-field-template-populate.yaml new file mode 100644 index 00000000000..297172dfd68 --- /dev/null +++ b/changelog/8261-fix-custom-field-template-populate.yaml @@ -0,0 +1,3 @@ +type: Fixed +description: Fixed the Custom Fields edit form not populating the Template select when editing a taxonomy-backed custom field. +pr: 8261 diff --git a/clients/admin-ui/src/features/custom-fields/CustomFieldForm.tsx b/clients/admin-ui/src/features/custom-fields/CustomFieldForm.tsx index 9b252ace0fb..73a5a9f93fa 100644 --- a/clients/admin-ui/src/features/custom-fields/CustomFieldForm.tsx +++ b/clients/admin-ui/src/features/custom-fields/CustomFieldForm.tsx @@ -185,12 +185,13 @@ const CustomFieldForm = ({ return undefined; } const fieldType = getCustomFieldType(field); - const template = + const isCustomFieldType = fieldType === FieldTypes.OPEN_TEXT || fieldType === FieldTypes.SINGLE_SELECT || - fieldType === FieldTypes.MULTIPLE_SELECT - ? CUSTOM_TEMPLATE_VALUE - : undefined; + fieldType === FieldTypes.MULTIPLE_SELECT; + const template = isCustomFieldType + ? CUSTOM_TEMPLATE_VALUE + : field.field_type; return { ...field, value_type: field.field_type, diff --git a/clients/admin-ui/src/features/system/add-multiple-systems/AddMultipleSystems.tsx b/clients/admin-ui/src/features/system/add-multiple-systems/AddMultipleSystems.tsx index 8e5cbd516e8..77ab45b1260 100644 --- a/clients/admin-ui/src/features/system/add-multiple-systems/AddMultipleSystems.tsx +++ b/clients/admin-ui/src/features/system/add-multiple-systems/AddMultipleSystems.tsx @@ -303,7 +303,7 @@ export const AddMultipleSystems = ({ redirectRoute }: Props) => { const vendorFilter = tableInstance .getState() - .columnFilters.find((c) => c.id === "vendor_id"); + .columnFilters.find((c) => c.id === "source"); const totalFilters = vendorFilter && Array.isArray(vendorFilter.value) ? vendorFilter.value.length diff --git a/clients/admin-ui/src/features/system/add-multiple-systems/MultipleSystemsFilterModal.tsx b/clients/admin-ui/src/features/system/add-multiple-systems/MultipleSystemsFilterModal.tsx index 0c31ca46bc4..e29dc26f44b 100644 --- a/clients/admin-ui/src/features/system/add-multiple-systems/MultipleSystemsFilterModal.tsx +++ b/clients/admin-ui/src/features/system/add-multiple-systems/MultipleSystemsFilterModal.tsx @@ -74,7 +74,7 @@ const MultipleSystemsFilterModal = ({ id: string; value: string[]; } = { - id: "vendor_id", + id: "source", value: [], }; if (filters.GVL) { 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/__tests__/lib/condition-evaluator.test.ts b/clients/privacy-center/__tests__/lib/condition-evaluator.test.ts new file mode 100644 index 00000000000..deb6215ddd5 --- /dev/null +++ b/clients/privacy-center/__tests__/lib/condition-evaluator.test.ts @@ -0,0 +1,480 @@ +/** + * @jest-environment jsdom + */ + +import { + evaluateCondition, + resolveApplicableFields, +} from "~/lib/condition-evaluator"; +import type { + Condition, + ConditionGroup, + ConditionLeaf, + CustomConfigField, +} from "~/types/config"; + +// Helper to create test field records with proper typing +const makeFields = ( + fields: Record & { label: string }>, +) => fields as Record; + +describe("evaluateCondition", () => { + describe("eq operator", () => { + it("returns true when values match", () => { + const leaf: ConditionLeaf = { + field_address: "country", + operator: "eq", + value: "US", + }; + expect(evaluateCondition(leaf, { country: "US" })).toBe(true); + }); + + it("returns false when values differ", () => { + const leaf: ConditionLeaf = { + field_address: "country", + operator: "eq", + value: "US", + }; + expect(evaluateCondition(leaf, { country: "CA" })).toBe(false); + }); + + it("treats both null-ish as equal", () => { + const leaf: ConditionLeaf = { + field_address: "field", + operator: "eq", + value: null, + }; + expect(evaluateCondition(leaf, { field: undefined })).toBe(true); + expect(evaluateCondition(leaf, {})).toBe(true); + expect(evaluateCondition(leaf, { field: "" })).toBe(true); + }); + + it("works with boolean values", () => { + const leaf: ConditionLeaf = { + field_address: "agreed", + operator: "eq", + value: true, + }; + expect(evaluateCondition(leaf, { agreed: true })).toBe(true); + expect(evaluateCondition(leaf, { agreed: false })).toBe(false); + }); + + it("works with number values", () => { + const leaf: ConditionLeaf = { + field_address: "count", + operator: "eq", + value: 5, + }; + expect(evaluateCondition(leaf, { count: 5 })).toBe(true); + expect(evaluateCondition(leaf, { count: 3 })).toBe(false); + }); + }); + + describe("neq operator", () => { + it("returns true when values differ", () => { + const leaf: ConditionLeaf = { + field_address: "country", + operator: "neq", + value: "US", + }; + expect(evaluateCondition(leaf, { country: "CA" })).toBe(true); + }); + + it("returns false when values match", () => { + const leaf: ConditionLeaf = { + field_address: "country", + operator: "neq", + value: "US", + }; + expect(evaluateCondition(leaf, { country: "US" })).toBe(false); + }); + + it("treats both null-ish as equal (returns false)", () => { + const leaf: ConditionLeaf = { + field_address: "field", + operator: "neq", + value: null, + }; + expect(evaluateCondition(leaf, {})).toBe(false); + }); + }); + + describe("exists operator", () => { + it("returns true when field has a value", () => { + const leaf: ConditionLeaf = { + field_address: "name", + operator: "exists", + }; + expect(evaluateCondition(leaf, { name: "Alice" })).toBe(true); + }); + + it("returns false for empty string", () => { + const leaf: ConditionLeaf = { + field_address: "name", + operator: "exists", + }; + expect(evaluateCondition(leaf, { name: "" })).toBe(false); + }); + + it("returns false for null/undefined/missing", () => { + const leaf: ConditionLeaf = { + field_address: "name", + operator: "exists", + }; + expect(evaluateCondition(leaf, { name: null })).toBe(false); + expect(evaluateCondition(leaf, { name: undefined })).toBe(false); + expect(evaluateCondition(leaf, {})).toBe(false); + }); + + it("returns false for empty array", () => { + const leaf: ConditionLeaf = { + field_address: "tags", + operator: "exists", + }; + expect(evaluateCondition(leaf, { tags: [] })).toBe(false); + }); + + it("returns true for non-empty array", () => { + const leaf: ConditionLeaf = { + field_address: "tags", + operator: "exists", + }; + expect(evaluateCondition(leaf, { tags: ["a"] })).toBe(true); + }); + + it("returns true for boolean false (it exists)", () => { + const leaf: ConditionLeaf = { + field_address: "agreed", + operator: "exists", + }; + expect(evaluateCondition(leaf, { agreed: false })).toBe(true); + }); + + it("returns true for zero (it exists)", () => { + const leaf: ConditionLeaf = { + field_address: "count", + operator: "exists", + }; + expect(evaluateCondition(leaf, { count: 0 })).toBe(true); + }); + }); + + describe("not_exists operator", () => { + it("returns true for missing field", () => { + const leaf: ConditionLeaf = { + field_address: "name", + operator: "not_exists", + }; + expect(evaluateCondition(leaf, {})).toBe(true); + }); + + it("returns true for empty string", () => { + const leaf: ConditionLeaf = { + field_address: "name", + operator: "not_exists", + }; + expect(evaluateCondition(leaf, { name: "" })).toBe(true); + }); + + it("returns false for present value", () => { + const leaf: ConditionLeaf = { + field_address: "name", + operator: "not_exists", + }; + expect(evaluateCondition(leaf, { name: "Alice" })).toBe(false); + }); + }); + + describe("list_contains operator", () => { + it("returns true when data array contains expected value", () => { + const leaf: ConditionLeaf = { + field_address: "departments", + operator: "list_contains", + value: "Engineering", + }; + expect( + evaluateCondition(leaf, { + departments: ["Engineering", "Marketing"], + }), + ).toBe(true); + }); + + it("returns false when data array does not contain expected value", () => { + const leaf: ConditionLeaf = { + field_address: "departments", + operator: "list_contains", + value: "Legal", + }; + expect( + evaluateCondition(leaf, { + departments: ["Engineering", "Marketing"], + }), + ).toBe(false); + }); + + it("returns true when expected array contains data value", () => { + const leaf: ConditionLeaf = { + field_address: "country", + operator: "list_contains", + value: ["US", "CA", "UK"], + }; + expect(evaluateCondition(leaf, { country: "US" })).toBe(true); + }); + + it("returns false when expected array does not contain data value", () => { + const leaf: ConditionLeaf = { + field_address: "country", + operator: "list_contains", + value: ["US", "CA", "UK"], + }; + expect(evaluateCondition(leaf, { country: "DE" })).toBe(false); + }); + + it("returns false for non-array values", () => { + const leaf: ConditionLeaf = { + field_address: "name", + operator: "list_contains", + value: "Alice", + }; + expect(evaluateCondition(leaf, { name: "Alice" })).toBe(false); + }); + }); + + describe("ConditionGroup - AND", () => { + it("returns true when all conditions pass", () => { + const group: ConditionGroup = { + logical_operator: "and", + conditions: [ + { field_address: "country", operator: "eq", value: "US" }, + { field_address: "state", operator: "exists" }, + ], + }; + expect(evaluateCondition(group, { country: "US", state: "CA" })).toBe( + true, + ); + }); + + it("returns false when any condition fails", () => { + const group: ConditionGroup = { + logical_operator: "and", + conditions: [ + { field_address: "country", operator: "eq", value: "US" }, + { field_address: "state", operator: "exists" }, + ], + }; + expect(evaluateCondition(group, { country: "US", state: "" })).toBe( + false, + ); + }); + }); + + describe("ConditionGroup - OR", () => { + it("returns true when any condition passes", () => { + const group: ConditionGroup = { + logical_operator: "or", + conditions: [ + { field_address: "country", operator: "eq", value: "US" }, + { field_address: "country", operator: "eq", value: "CA" }, + ], + }; + expect(evaluateCondition(group, { country: "CA" })).toBe(true); + }); + + it("returns false when all conditions fail", () => { + const group: ConditionGroup = { + logical_operator: "or", + conditions: [ + { field_address: "country", operator: "eq", value: "US" }, + { field_address: "country", operator: "eq", value: "CA" }, + ], + }; + expect(evaluateCondition(group, { country: "DE" })).toBe(false); + }); + }); + + describe("empty conditions array", () => { + it("AND with no conditions returns true (vacuous truth)", () => { + const group: ConditionGroup = { logical_operator: "and", conditions: [] }; + expect(evaluateCondition(group, {})).toBe(true); + }); + + it("OR with no conditions returns false", () => { + const group: ConditionGroup = { logical_operator: "or", conditions: [] }; + expect(evaluateCondition(group, {})).toBe(false); + }); + }); + + describe("nested groups", () => { + it("handles nested group conditions", () => { + const condition: Condition = { + logical_operator: "and", + conditions: [ + { field_address: "country", operator: "eq", value: "US" }, + { + logical_operator: "or", + conditions: [ + { field_address: "state", operator: "eq", value: "CA" }, + { field_address: "state", operator: "eq", value: "NY" }, + ], + }, + ], + }; + expect(evaluateCondition(condition, { country: "US", state: "CA" })).toBe( + true, + ); + expect(evaluateCondition(condition, { country: "US", state: "TX" })).toBe( + false, + ); + expect(evaluateCondition(condition, { country: "CA", state: "CA" })).toBe( + false, + ); + }); + }); + + it("returns false on unknown operator", () => { + const leaf = { + field_address: "x", + operator: "unknown_op" as any, + value: "y", + }; + expect(evaluateCondition(leaf, { x: "y" })).toBe(false); + }); +}); + +describe("resolveApplicableFields", () => { + it("returns an empty set when fields record is empty", () => { + expect(resolveApplicableFields({}, {})).toEqual(new Set()); + }); + + it("returns all fields when none have display_condition", () => { + const fields = makeFields({ + name: { label: "Name" }, + email: { label: "Email" }, + }); + const result = resolveApplicableFields(fields, { name: "", email: "" }); + expect(result).toEqual(new Set(["name", "email"])); + }); + + it("hides a field when its condition is false", () => { + const fields = makeFields({ + country: { label: "Country", field_type: "select" }, + state: { + label: "State", + field_type: "select", + display_condition: { + field_address: "country", + operator: "eq", + value: "US", + }, + }, + }); + const result = resolveApplicableFields(fields, { + country: "CA", + state: "", + }); + expect(result.has("country")).toBe(true); + expect(result.has("state")).toBe(false); + }); + + it("shows a field when its condition is true", () => { + const fields = makeFields({ + country: { label: "Country", field_type: "select" }, + state: { + label: "State", + field_type: "select", + display_condition: { + field_address: "country", + operator: "eq", + value: "US", + }, + }, + }); + const result = resolveApplicableFields(fields, { + country: "US", + state: "CA", + }); + expect(result.has("country")).toBe(true); + expect(result.has("state")).toBe(true); + }); + + it("handles cascading dependencies (A → B → C)", () => { + const fields = makeFields({ + toggle: { label: "Toggle" }, + level1: { + label: "Level 1", + display_condition: { + field_address: "toggle", + operator: "eq", + value: "on", + }, + }, + level2: { + label: "Level 2", + display_condition: { + field_address: "level1", + operator: "exists", + }, + }, + }); + + // toggle=on → level1 visible → level2 visible + const allVisible = resolveApplicableFields(fields, { + toggle: "on", + level1: "something", + level2: "deep", + }); + expect(allVisible).toEqual(new Set(["toggle", "level1", "level2"])); + + // toggle=off → level1 hidden → level2 hidden (cascade) + const cascadeHidden = resolveApplicableFields(fields, { + toggle: "off", + level1: "something", + level2: "deep", + }); + expect(cascadeHidden).toEqual(new Set(["toggle"])); + }); + + it("handles fields hidden by another hidden field", () => { + // B depends on A, C depends on B. A is hidden → B is hidden → C is hidden. + const fields = makeFields({ + a: { + label: "A", + display_condition: { + field_address: "external", + operator: "exists", + }, + }, + b: { + label: "B", + display_condition: { + field_address: "a", + operator: "exists", + }, + }, + }); + const result = resolveApplicableFields(fields, { + external: "", + a: "val", + b: "val", + }); + // external is empty → a hidden → b hidden + expect(result.size).toBe(0); + }); + + it("converges in one pass when there are no dependencies", () => { + const fields = makeFields({ + a: { label: "A" }, + b: { + label: "B", + display_condition: { + field_address: "a", + operator: "eq", + value: "x", + }, + }, + }); + // Simple case - single dependency + const result = resolveApplicableFields(fields, { a: "x", b: "y" }); + expect(result).toEqual(new Set(["a", "b"])); + }); +}); 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/common/visibility.ts b/clients/privacy-center/common/visibility.ts deleted file mode 100644 index 653196479ce..00000000000 --- a/clients/privacy-center/common/visibility.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { CustomConfigField, VisibilityCondition } from "~/types/config"; - -const isEmpty = (v: unknown) => - v === undefined || - v === null || - v === "" || - (Array.isArray(v) && v.length === 0); - -const evalCondition = ( - condition: VisibilityCondition, - values: Record, -): boolean => { - const sourceValue = values[condition.source_field]; - switch (condition.operator) { - case "set": - return !isEmpty(sourceValue); - case "empty": - return isEmpty(sourceValue); - case "eq": - return String(sourceValue ?? "") === String(condition.value ?? ""); - case "ne": - return String(sourceValue ?? "") !== String(condition.value ?? ""); - case "contains": - if (Array.isArray(sourceValue)) { - return sourceValue.includes(condition.value as never); - } - return String(sourceValue ?? "").includes(String(condition.value ?? "")); - default: - return true; - } -}; - -/** - * Evaluate a field's `visible_when` conditions against a values map. - * Returns true when the field should render (no conditions, or every - * condition passes — AND semantics). - */ -export const isFieldVisible = ( - field: Pick, - values: Record, -): boolean => { - if (!field.visible_when || field.visible_when.length === 0) { - return true; - } - return field.visible_when.every((c) => evalCondition(c, values)); -}; 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..4e121935e62 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 { Alert, 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"; @@ -40,6 +38,8 @@ const ConsentRequestForm = ({ resetForm, identityInputs: { email: emailInput, phone: phoneInput }, customPrivacyRequestFields, + applicableFields, + validationError, } = useConsentRequestForm({ onClose, setCurrentView, @@ -67,12 +67,19 @@ const ConsentRequestForm = ({ layout="vertical" data-testid="consent-request-form" > + {validationError && ( + + )} {!!emailInput && ( )} - {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); - }, - }; - } - }; - - return ( + {Object.entries(customPrivacyRequestFields) + .filter(([key, field]) => !field?.hidden && applicableFields.has(key)) + .map(([key, item]) => ( - + - ); - })} + ))}