diff --git a/changelog/8151-image-upload-and-paths-editor-ux.yaml b/changelog/8151-image-upload-and-paths-editor-ux.yaml new file mode 100644 index 0000000000..786c4be698 --- /dev/null +++ b/changelog/8151-image-upload-and-paths-editor-ux.yaml @@ -0,0 +1,4 @@ +type: Changed +description: Property form now accepts logo/icon images via drag-and-drop, and the paths editor commits typed paths on blur or via an explicit Add button +pr: 8151 +labels: [] diff --git a/clients/admin-ui/src/features/properties/PathsEditor.tsx b/clients/admin-ui/src/features/properties/PathsEditor.tsx index 77c9093389..fd7f903140 100644 --- a/clients/admin-ui/src/features/properties/PathsEditor.tsx +++ b/clients/admin-ui/src/features/properties/PathsEditor.tsx @@ -1,4 +1,4 @@ -import { Alert, Input, Space, Tag } from "fidesui"; +import { Alert, Button, Input, Space, Tag } from "fidesui"; import { useState } from "react"; interface PathsEditorProps { @@ -10,17 +10,18 @@ export const PathsEditor = ({ value, onChange }: PathsEditorProps) => { const [draft, setDraft] = useState(""); const [error, setError] = useState(null); - const handleAdd = () => { - const trimmed = draft.trim(); - if (!trimmed) { + const commitDraft = () => { + const cleaned = draft.trim().replace(/^-+|-+$/g, ""); + if (!cleaned) { return; } - if (value.includes(trimmed)) { - setError(`"${trimmed}" already added`); + const normalized = cleaned.startsWith("/") ? cleaned : `/${cleaned}`; + if (value.includes(normalized)) { + setError(`"${normalized}" already added`); return; } setError(null); - onChange([...value, trimmed]); + onChange([...value, normalized]); setDraft(""); }; @@ -29,7 +30,7 @@ export const PathsEditor = ({ value, onChange }: PathsEditorProps) => { }; return ( - + {value.map((path) => ( handleRemove(path)}> @@ -37,16 +38,22 @@ export const PathsEditor = ({ value, onChange }: PathsEditorProps) => { ))} - setDraft(e.target.value)} - onPressEnter={(e) => { - e.preventDefault(); - handleAdd(); - }} - /> - {error && } + + setDraft(e.target.value.replace(/\s+/g, "-"))} + onPressEnter={(e) => { + e.preventDefault(); + commitDraft(); + }} + onBlur={commitDraft} + /> + + + {error && } ); }; diff --git a/clients/admin-ui/src/features/properties/privacy-center-config/ActionEditModal.tsx b/clients/admin-ui/src/features/properties/privacy-center-config/ActionEditModal.tsx index 5d4b6b325b..abb8c9a864 100644 --- a/clients/admin-ui/src/features/properties/privacy-center-config/ActionEditModal.tsx +++ b/clients/admin-ui/src/features/properties/privacy-center-config/ActionEditModal.tsx @@ -3,6 +3,8 @@ import { useMemo } from "react"; import { useGetPoliciesQuery } from "~/features/policies/policy.slice"; +import { ImageUploadField } from "./ImageUploadField"; + export interface ActionFormValues { policy_key: string; title: string; @@ -95,11 +97,11 @@ export const ActionEditModal = ({ - + diff --git a/clients/admin-ui/src/features/properties/privacy-center-config/ImageUploadField.tsx b/clients/admin-ui/src/features/properties/privacy-center-config/ImageUploadField.tsx new file mode 100644 index 0000000000..3731add503 --- /dev/null +++ b/clients/admin-ui/src/features/properties/privacy-center-config/ImageUploadField.tsx @@ -0,0 +1,96 @@ +import type { UploadProps } from "fidesui"; +import { Button, Flex, Icons, Typography, Upload } from "fidesui"; +import { useState } from "react"; + +const MAX_FILE_SIZE_BYTES = 2 * 1024 * 1024; + +const readFileAsDataUrl = (file: File) => + new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result as string); + reader.onerror = () => reject(reader.error); + reader.readAsDataURL(file); + }); + +interface ImageUploadFieldProps { + value?: string; + onChange?: (value: string) => void; + ariaLabel?: string; +} + +export const ImageUploadField = ({ + value, + onChange, + ariaLabel, +}: ImageUploadFieldProps) => { + const [errorMessage, setErrorMessage] = useState(null); + + const handleBeforeUpload: UploadProps["beforeUpload"] = async (file) => { + setErrorMessage(null); + if (!file.type.startsWith("image/")) { + setErrorMessage("File must be an image."); + return false; + } + if (file.size > MAX_FILE_SIZE_BYTES) { + setErrorMessage("File must be 2MB or smaller."); + return false; + } + try { + const dataUrl = await readFileAsDataUrl(file); + onChange?.(dataUrl); + } catch { + setErrorMessage("Failed to read file."); + } + return false; + }; + + const handleRemove = () => { + setErrorMessage(null); + onChange?.(""); + }; + + return ( + + + {value ? ( + + + + Click or drag to replace + + + ) : ( + + + + Click or drag an image here to upload + + + PNG, JPG, or SVG up to 2MB + + + )} + + {value && ( + + + + )} + {errorMessage && ( + {errorMessage} + )} + + ); +}; diff --git a/clients/admin-ui/src/features/properties/privacy-center-config/PrivacyCenterConfigSection.tsx b/clients/admin-ui/src/features/properties/privacy-center-config/PrivacyCenterConfigSection.tsx index 865fb1f668..ff39624f97 100644 --- a/clients/admin-ui/src/features/properties/privacy-center-config/PrivacyCenterConfigSection.tsx +++ b/clients/admin-ui/src/features/properties/privacy-center-config/PrivacyCenterConfigSection.tsx @@ -3,6 +3,7 @@ import { useState } from "react"; import { ActionEditModal, ActionFormValues } from "./ActionEditModal"; import { ActionsTable } from "./ActionsTable"; +import { ImageUploadField } from "./ImageUploadField"; export interface PrivacyCenterConfigValue { title?: string; @@ -131,11 +132,12 @@ export const PrivacyCenterConfigSection = ({ } /> - - + - onChange?.({ ...(value ?? {}), logo_path: e.target.value }) + onChange={(next) => + onChange?.({ ...(value ?? {}), logo_path: next }) } /> diff --git a/clients/admin-ui/src/features/properties/privacy-center-config/__tests__/ActionEditModal.test.tsx b/clients/admin-ui/src/features/properties/privacy-center-config/__tests__/ActionEditModal.test.tsx index 0771806b39..1cb7d859d3 100644 --- a/clients/admin-ui/src/features/properties/privacy-center-config/__tests__/ActionEditModal.test.tsx +++ b/clients/admin-ui/src/features/properties/privacy-center-config/__tests__/ActionEditModal.test.tsx @@ -15,6 +15,27 @@ jest.mock("~/features/policies/policy.slice", () => ({ }), })); +// ImageUploadField wraps antd Upload.Dragger which uses FileReader and a +// hidden file input -- both awkward to drive in jsdom. Substitute a plain +// text input for unit tests so we still exercise the form-submission flow. +jest.mock("../ImageUploadField", () => ({ + ImageUploadField: ({ + value, + onChange, + ariaLabel, + }: { + value?: string; + onChange?: (next: string) => void; + ariaLabel?: string; + }) => ( + onChange?.(e.target.value)} + /> + ), +})); + // Test adaptation: antd's Select inside an antd Modal triggers a known jsdom + // nwsapi crash ("e.parentElement.querySelectorAll(...).includes is not a // function") when the dropdown's virtual list layer mounts. Other tests in this @@ -74,7 +95,7 @@ describe("ActionEditModal", () => { screen.getByLabelText(/description/i), "Request a copy.", ); - await userEvent.type(screen.getByLabelText(/icon path/i), "/icon.svg"); + await userEvent.type(screen.getByLabelText(/^icon$/i), "/icon.svg"); await userEvent.click(screen.getByRole("button", { name: /save/i })); expect(onOk).toHaveBeenCalledWith(