Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelog/8151-image-upload-and-paths-editor-ux.yaml
Original file line number Diff line number Diff line change
@@ -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: []
43 changes: 25 additions & 18 deletions clients/admin-ui/src/features/properties/PathsEditor.tsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -10,17 +10,18 @@ export const PathsEditor = ({ value, onChange }: PathsEditorProps) => {
const [draft, setDraft] = useState("");
const [error, setError] = useState<string | null>(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("");
};

Expand All @@ -29,24 +30,30 @@ export const PathsEditor = ({ value, onChange }: PathsEditorProps) => {
};

return (
<Space direction="vertical" style={{ width: "100%" }}>
<Space orientation="vertical" style={{ width: "100%" }}>
<Space wrap>
{value.map((path) => (
<Tag key={path} closable onClose={() => handleRemove(path)}>
{path}
</Tag>
))}
</Space>
<Input
placeholder="Add a path (e.g. /privacy)"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onPressEnter={(e) => {
e.preventDefault();
handleAdd();
}}
/>
{error && <Alert type="error" message={error} closable />}
<Space.Compact className="w-full">
<Input
placeholder="Add a path (e.g. /privacy)"
value={draft}
onChange={(e) => setDraft(e.target.value.replace(/\s+/g, "-"))}
onPressEnter={(e) => {
e.preventDefault();
commitDraft();
}}
onBlur={commitDraft}
/>
<Button onClick={commitDraft} disabled={!draft.trim()}>
Add
</Button>
</Space.Compact>
{error && <Alert type="error" description={error} closable />}
</Space>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -95,11 +97,11 @@ export const ActionEditModal = ({
<Input.TextArea autoSize />
</Form.Item>
<Form.Item
label="Icon path"
label="Icon"
name="icon_path"
rules={[{ required: true }]}
rules={[{ required: true, message: "Please upload an icon" }]}
>
<Input placeholder="/icon.svg" />
<ImageUploadField ariaLabel="Icon" />
</Form.Item>
</Form>
</Modal>
Expand Down
Original file line number Diff line number Diff line change
@@ -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<string>((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<string | null>(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 (
<Flex vertical gap="small">
<Upload.Dragger
accept="image/*"
beforeUpload={handleBeforeUpload}
showUploadList={false}
multiple={false}
aria-label={ariaLabel}
>
{value ? (
<Flex vertical align="center" gap="small" className="p-2">
<img
src={value}
alt=""
className="max-h-24 max-w-full object-contain"
/>
<Typography.Text type="secondary">
Click or drag to replace
</Typography.Text>
</Flex>
) : (
<Flex vertical align="center" gap="small" className="p-4">
<Icons.Upload size={32} />
<Typography.Text>
Click or drag an image here to upload
</Typography.Text>
<Typography.Text type="secondary" className="text-xs">
PNG, JPG, or SVG up to 2MB
</Typography.Text>
</Flex>
)}
</Upload.Dragger>
{value && (
<Flex justify="flex-end">
<Button size="small" onClick={handleRemove}>
Remove
</Button>
</Flex>
)}
{errorMessage && (
<Typography.Text type="danger">{errorMessage}</Typography.Text>
)}
</Flex>
);
};
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -131,11 +132,12 @@ export const PrivacyCenterConfigSection = ({
}
/>
</Form.Item>
<Form.Item label="Logo path">
<Input
<Form.Item label="Logo">
<ImageUploadField
ariaLabel="Logo"
value={value?.logo_path ?? ""}
onChange={(e) =>
onChange?.({ ...(value ?? {}), logo_path: e.target.value })
onChange={(next) =>
onChange?.({ ...(value ?? {}), logo_path: next })
}
/>
</Form.Item>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}) => (
<input
aria-label={ariaLabel}
value={value ?? ""}
onChange={(e) => 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
Expand Down Expand Up @@ -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(
Expand Down
Loading