Skip to content

Commit

Permalink
LJ-310: Support rendering and saving consent from custom notices in T…
Browse files Browse the repository at this point in the history
…CF (#5742)

Co-authored-by: Jason Gill <[email protected]>
  • Loading branch information
eastandwestwind and gilluminate authored Feb 11, 2025
1 parent 5764759 commit 5329927
Show file tree
Hide file tree
Showing 22 changed files with 3,322 additions and 69 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ Changes can also be flagged with a GitHub label for tracking purposes. The URL o
- Added editing support for categories of consent on discovered assets [#5739](https://github.com/ethyca/fides/pull/5739)
- Added a read-only consent category cell to Action Center aggregate system results table [#5737](https://github.com/ethyca/fides/pull/5737)
- Added detail trays to items in data catalog view [#5729](https://github.com/ethyca/fides/pull/5729)
- Support rendering and saving consent from custom notices in TCF Overlay [#5742](https://github.com/ethyca/fides/pull/5742)

### Changed
- Added frequency field to DataHubSchema integration config [#5716](https://github.com/ethyca/fides/pull/5716)
Expand Down
11 changes: 11 additions & 0 deletions clients/admin-ui/src/features/common/ScrollableList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const ScrollableListItem = <T extends unknown>({
label,
draggable,
onDeleteItem,
tooltip,
onRowClick,
maxH = 10,
rowTestId,
Expand All @@ -29,6 +30,7 @@ const ScrollableListItem = <T extends unknown>({
label: string;
draggable?: boolean;
onDeleteItem?: (item: T) => void;
tooltip?: string;
onRowClick?: (item: T) => void;
maxH?: number;
rowTestId: string;
Expand Down Expand Up @@ -83,6 +85,7 @@ const ScrollableListItem = <T extends unknown>({
>
{label}
</Text>
{tooltip ? <QuestionTooltip label={tooltip} /> : null}
</Flex>
{onDeleteItem && (
<Button
Expand Down Expand Up @@ -157,6 +160,7 @@ const ScrollableList = <T extends unknown>({
values,
setValues,
canDeleteItem,
getTooltip,
onRowClick,
selectOnAdd,
getItemLabel,
Expand All @@ -174,6 +178,7 @@ const ScrollableList = <T extends unknown>({
values: T[];
setValues: (newOrder: T[]) => void;
canDeleteItem?: (item: T) => boolean;
getTooltip?: (item: T) => string | undefined;
onRowClick?: (item: T) => void;
selectOnAdd?: boolean;
getItemLabel?: (item: T) => string;
Expand Down Expand Up @@ -261,6 +266,9 @@ const ScrollableList = <T extends unknown>({
draggable
maxH={maxHeight}
rowTestId={`${baseTestId}-row-${itemId}`}
tooltip={
getTooltip && getTooltip(item) ? getTooltip(item) : undefined
}
/>
);
})}
Expand All @@ -278,6 +286,9 @@ const ScrollableList = <T extends unknown>({
label={getItemDisplayName(item)}
onRowClick={onRowClick}
onDeleteItem={handleDeleteItem}
tooltip={
getTooltip && getTooltip(item) ? getTooltip(item) : undefined
}
maxH={maxHeight}
rowTestId={`${baseTestId}-row-${itemId}`}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,11 @@ const ConfigurePrivacyExperience = ({
: defaultInitialValues;

const handleSubmit = async (values: ExperienceConfigCreate) => {
// Ignore placeholder TCF notice. It is used only as a UX cue that TCF purposes will always exist
// eslint-disable-next-line no-param-reassign
values.privacy_notice_ids = values.privacy_notice_ids?.filter(
(item) => item !== "tcf_purposes_placeholder",
);
const valuesToSubmit = {
...values,
disabled: passedInExperience?.disabled ?? true,
Expand Down
5 changes: 4 additions & 1 deletion clients/admin-ui/src/features/privacy-experience/Preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,10 @@ const Preview = ({
};

useEffect(() => {
if (values.privacy_notice_ids) {
if (
values.privacy_notice_ids &&
values.component !== ComponentType.TCF_OVERLAY
) {
Promise.all(
values.privacy_notice_ids!.map((id) => getPrivacyNotice(id)),
).then((data) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
} from "fidesui";
import { useFormikContext } from "formik";
import { useRouter } from "next/router";
import { useMemo } from "react";

import { useAppSelector } from "~/app/hooks";
import { CustomSwitch, CustomTextInput } from "~/features/common/form/inputs";
Expand Down Expand Up @@ -38,6 +39,7 @@ import {
} from "~/features/properties/property.slice";
import {
ComponentType,
ConsentMechanism,
ExperienceConfigCreate,
ExperienceTranslation,
LimitedPrivacyNoticeResponseSchema,
Expand Down Expand Up @@ -73,6 +75,8 @@ const buttonLayoutOptions: SelectProps["options"] = [
},
];

const TCF_PLACEHOLDER_ID = "tcf_purposes_placeholder";

export const PrivacyExperienceConfigColumnLayout = ({
buttonPanel,
children,
Expand All @@ -90,6 +94,17 @@ export const PrivacyExperienceConfigColumnLayout = ({
</Flex>
);

function privacyNoticeIdsWithTcfId(values: ExperienceConfigCreate): string[] {
if (!values.privacy_notice_ids) {
return [TCF_PLACEHOLDER_ID];
}
const noticeIdsWithTcfId = values.privacy_notice_ids;
if (!noticeIdsWithTcfId.includes(TCF_PLACEHOLDER_ID)) {
noticeIdsWithTcfId.push(TCF_PLACEHOLDER_ID);
}
return noticeIdsWithTcfId;
}

export const PrivacyExperienceForm = ({
allPrivacyNotices,
translationsEnabled,
Expand All @@ -109,21 +124,36 @@ export const PrivacyExperienceForm = ({
const noticePageSize = useAppSelector(selectNoticePageSize);
useGetAllPrivacyNoticesQuery({ page: noticePage, size: noticePageSize });

const allPrivacyNoticesWithTcfPlaceholder: LimitedPrivacyNoticeResponseSchema[] =
useMemo(() => {
const noticesWithTcfPlaceholder = [...allPrivacyNotices];
if (!noticesWithTcfPlaceholder.some((n) => n.id === TCF_PLACEHOLDER_ID)) {
noticesWithTcfPlaceholder.push({
name: "TCF Purposes",
id: TCF_PLACEHOLDER_ID,
notice_key: TCF_PLACEHOLDER_ID,
data_uses: [],
consent_mechanism: ConsentMechanism.NOTICE_ONLY,
disabled: false,
});
}
return noticesWithTcfPlaceholder;
}, [allPrivacyNotices]);

const getPrivacyNoticeName = (id: string) => {
const notice = allPrivacyNotices.find((n) => n.id === id);
const notice = allPrivacyNoticesWithTcfPlaceholder.find((n) => n.id === id);
return notice?.name ?? id;
};

const filterNoticesForOnlyParentNotices =
(): LimitedPrivacyNoticeResponseSchema[] => {
const childrenNoticeIds: FlatArray<(string[] | undefined)[], 1>[] =
allPrivacyNotices
.map((n) => n.children?.map((child) => child.id))
.flat();
return (
allPrivacyNotices.filter((n) => !childrenNoticeIds.includes(n.id)) ?? []
);
};
const filterNoticesForOnlyParentNotices = (
allNotices: LimitedPrivacyNoticeResponseSchema[],
): LimitedPrivacyNoticeResponseSchema[] => {
const childrenNoticeIds: FlatArray<(string[] | undefined)[], 1>[] =
allNotices.map((n) => n.children?.map((child) => child.id)).flat();
return (
allPrivacyNotices.filter((n) => !childrenNoticeIds.includes(n.id)) ?? []
);
};

useGetLocationsRegulationsQuery();
const locationsRegulations = useAppSelector(selectLocationsRegulations);
Expand Down Expand Up @@ -232,17 +262,43 @@ export const PrivacyExperienceForm = ({
<Heading fontSize="md" fontWeight="semibold">
Privacy notices
</Heading>
<ScrollableList
addButtonLabel="Add privacy notice"
allItems={filterNoticesForOnlyParentNotices().map((n) => n.id)}
values={values.privacy_notice_ids ?? []}
setValues={(newValues) =>
setFieldValue("privacy_notice_ids", newValues)
}
getItemLabel={getPrivacyNoticeName}
draggable
baseTestId="privacy-notice"
/>
{values.component === ComponentType.TCF_OVERLAY ? (
<ScrollableList<string>
addButtonLabel="Add privacy notice"
allItems={allPrivacyNoticesWithTcfPlaceholder.map((n) => n.id)}
values={privacyNoticeIdsWithTcfId(values)}
setValues={(newValues) =>
setFieldValue("privacy_notice_ids", newValues)
}
// @ts-ignore
canDeleteItem={(item: string): boolean => {
return Boolean(item !== TCF_PLACEHOLDER_ID);
}}
getTooltip={(item: string): string | undefined => {
if (item === TCF_PLACEHOLDER_ID) {
return "TCF Purposes are required by the framework and cannot be deleted.";
}
return undefined;
}}
getItemLabel={getPrivacyNoticeName}
draggable
baseTestId="privacy-notice"
/>
) : (
<ScrollableList<string>
addButtonLabel="Add privacy notice"
allItems={filterNoticesForOnlyParentNotices(allPrivacyNotices).map(
(n) => n.id,
)}
values={values.privacy_notice_ids ?? []}
setValues={(newValues) =>
setFieldValue("privacy_notice_ids", newValues)
}
getItemLabel={getPrivacyNoticeName}
draggable
baseTestId="privacy-notice"
/>
)}
{values.component === ComponentType.BANNER_AND_MODAL ? (
<>
<Collapse in={!!values.privacy_notice_ids?.length} animateOpacity>
Expand Down
2 changes: 1 addition & 1 deletion clients/fides-js/rollup.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ const GZIP_SIZE_ERROR_KB = 45; // fail build if bundle size exceeds this
const GZIP_SIZE_WARN_KB = 35; // log a warning if bundle size exceeds this

// TCF
const GZIP_SIZE_TCF_ERROR_KB = 85.5;
const GZIP_SIZE_TCF_ERROR_KB = 86;
const GZIP_SIZE_TCF_WARN_KB = 75;

const preactAliases = {
Expand Down
11 changes: 7 additions & 4 deletions clients/fides-js/src/components/DataUseToggle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,12 @@ const DataUseToggle = ({
onToggle: toggleDescription,
} = useDisclosure({ id: noticeKey });

const handleKeyDown = (event: KeyboardEvent) => {
const handleKeyDown = (event: KeyboardEvent, isClickable: boolean) => {
if (event.code === "Space" || event.code === "Enter") {
toggleDescription();
event.preventDefault();
if (isClickable) {
toggleDescription();
}
}
};

Expand All @@ -58,7 +61,7 @@ const DataUseToggle = ({
<span
role="button"
tabIndex={0}
onKeyDown={isClickable ? handleKeyDown : undefined}
onKeyDown={(e) => handleKeyDown(e, isClickable)}
{...buttonProps}
className={
isHeader
Expand All @@ -70,7 +73,7 @@ const DataUseToggle = ({
{title}
</span>
</span>
<span>
<span className="fides-notice-toggle-controls">
{gpcBadge}
{badge ? <span className="fides-notice-badge">{badge}</span> : null}
{includeToggle ? (
Expand Down
5 changes: 5 additions & 0 deletions clients/fides-js/src/components/fides.css
Original file line number Diff line number Diff line change
Expand Up @@ -780,6 +780,11 @@ div#fides-overlay-wrapper .fides-toggle .fides-toggle-display {
font-weight: 600;
}

.fides-notice-toggle-controls {
white-space: nowrap;
margin-left: 8px;
}

/* GPC */
.fides-gpc-banner {
border: 1px solid var(--fides-overlay-primary-color);
Expand Down
33 changes: 22 additions & 11 deletions clients/fides-js/src/components/tcf/RecordsList.tsx
Original file line number Diff line number Diff line change
@@ -1,33 +1,35 @@
import { h, VNode } from "preact";

import { PrivacyNoticeTranslation } from "../../lib/consent-types";
import { DEFAULT_LOCALE, getCurrentLocale } from "../../lib/i18n";
import { useI18n } from "../../lib/i18n/i18n-context";
import DataUseToggle from "../DataUseToggle";

export type RecordListType =
| "purposes"
| "purposes" // Sometimes includes custom purposes
| "specialPurposes"
| "features"
| "specialFeatures"
| "vendors";

interface Item {
export interface RecordListItem {
id: string | number;
name?: string;
bestTranslation?: PrivacyNoticeTranslation | null; // only used for custom purposes
}

interface Props<T extends Item> {
interface Props<T extends RecordListItem> {
items: T[];
type: RecordListType;
title: string;
enabledIds: string[];
renderToggleChild: (item: T) => VNode;
onToggle: (payload: string[]) => void;
renderToggleChild?: (item: T, isCustomPurpose?: boolean) => VNode;
onToggle: (payload: string[], item: T) => void;
renderBadgeLabel?: (item: T) => string | undefined;
hideToggles?: boolean;
}

const RecordsList = <T extends Item>({
const RecordsList = <T extends RecordListItem>({
items,
type,
title,
Expand All @@ -45,9 +47,12 @@ const RecordsList = <T extends Item>({
const handleToggle = (item: T) => {
const purposeId = `${item.id}`;
if (enabledIds.indexOf(purposeId) !== -1) {
onToggle(enabledIds.filter((e) => e !== purposeId));
onToggle(
enabledIds.filter((e) => e !== purposeId),
item,
);
} else {
onToggle([...enabledIds, purposeId]);
onToggle([...enabledIds, purposeId], item);
}
};

Expand All @@ -59,7 +64,7 @@ const RecordsList = <T extends Item>({
toggleOffLabel = "Off";
}

const getNameForItem = (item: Item) => {
const getNameForItem = (item: RecordListItem) => {
if (type === "vendors") {
// Return the (non-localized!) name for vendors
return item.name as string;
Expand All @@ -74,7 +79,11 @@ const RecordsList = <T extends Item>({
{items.map((item) => (
<DataUseToggle
key={item.id}
title={getNameForItem(item)}
title={
item.bestTranslation
? item.bestTranslation.title || ""
: getNameForItem(item)
}
noticeKey={`${item.id}`}
onToggle={() => {
handleToggle(item);
Expand All @@ -85,7 +94,9 @@ const RecordsList = <T extends Item>({
onLabel={toggleOnLabel}
offLabel={toggleOffLabel}
>
{renderToggleChild(item)}
{renderToggleChild
? renderToggleChild(item, Boolean(item.bestTranslation))
: ""}
</DataUseToggle>
))}
</div>
Expand Down
Loading

0 comments on commit 5329927

Please sign in to comment.