Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
9701797
ENG-2338: Add resurface_behavior to experience config model
tina-zimnicki Jan 13, 2026
d316699
Add UI resurfacing changes
tina-zimnicki Jan 14, 2026
5c62756
Add tests
tina-zimnicki Jan 15, 2026
b0c50fe
Fix enum value recommendations
tina-zimnicki Feb 12, 2026
564a26c
Merge branch 'main' into ENG-2338
tina-zimnicki Feb 13, 2026
b560e0b
Update migration to resolve conflict
tina-zimnicki Feb 13, 2026
14b77b2
Update TS doccumentation
tina-zimnicki Feb 13, 2026
b2b0665
Fix prettier formatting and add changelog entry
tina-zimnicki Feb 13, 2026
ab71470
Update migration to resolve head conflict
tina-zimnicki Feb 13, 2026
15fdd3d
add changelog entry
tina-zimnicki Feb 13, 2026
336e548
Add resurface_behavior data category annotations to db_dataset.yml
tina-zimnicki Feb 19, 2026
59e5845
Merge branch 'main' into ENG-2338
tina-zimnicki Feb 19, 2026
0571640
Resolve migration conflict
tina-zimnicki Feb 19, 2026
0ca86ff
Add native_enum=False to resurface_behavior
tina-zimnicki Feb 19, 2026
bb033da
Merge branch 'main' into ENG-2338
tina-zimnicki Feb 20, 2026
6452c62
Update migration revision
tina-zimnicki Feb 20, 2026
d7418d3
Update wording
tina-zimnicki Feb 26, 2026
573e908
Merge branch 'main' into ENG-2338
tina-zimnicki Mar 9, 2026
9818e5e
Update migration, switch off Nonone type
tina-zimnicki Mar 9, 2026
8ec5a31
Remove comment
tina-zimnicki Mar 9, 2026
b82715a
Change to default=list
tina-zimnicki Mar 9, 2026
035f469
Make resurface_behavior nonnullable for config tables
tina-zimnicki Mar 9, 2026
205685d
Make resurface_behavior non-nullable for config tables
tina-zimnicki Mar 10, 2026
63ff087
Merge branch 'main' into ENG-2338
tina-zimnicki Mar 10, 2026
ed19e84
Update migration
tina-zimnicki Mar 10, 2026
83040cd
Merge branch 'main' into ENG-2338
tina-zimnicki Mar 13, 2026
5db3ce5
Update migration
tina-zimnicki Mar 13, 2026
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
6 changes: 6 additions & 0 deletions .fides/db_dataset.yml
Original file line number Diff line number Diff line change
Expand Up @@ -1869,6 +1869,8 @@ dataset:
data_categories: [ system.operations ]
- name: asset_disclosure_include_types
data_categories: [ system.operations ]
- name: resurface_behavior
data_categories: [ system.operations ]
- name: privacyexperienceconfighistory
description: 'Historical table to store all versions of Experience Config History for record keeping'
fields:
Expand Down Expand Up @@ -1954,6 +1956,8 @@ dataset:
data_categories: [ system.operations ]
- name: asset_disclosure_include_types
data_categories: [ system.operations ]
- name: resurface_behavior
data_categories: [ system.operations ]
- name: privacynoticetemplate
data_categories: []
fields:
Expand Down Expand Up @@ -2973,6 +2977,8 @@ dataset:
data_categories: [ system.operations ]
- name: asset_disclosure_include_types
data_categories: [ system.operations ]
- name: resurface_behavior
data_categories: [ system.operations ]
- name: experiencenotices
description: 'The table that links Privacy Notices to Experience Configs (many-to-many)'
fields:
Expand Down
4 changes: 4 additions & 0 deletions changelog/7292.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type: Added
description: Added resurface_behavior configuration to privacy experience configs to control when consent banners are reshown after user interaction
pr: 7292
labels: ["db-migration"]
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ import {
Button,
ChakraArrowForwardIcon as ArrowForwardIcon,
ChakraBox as Box,
ChakraCheckbox as Checkbox,
ChakraCheckboxGroup as CheckboxGroup,
ChakraDivider as Divider,
ChakraFlex as Flex,
ChakraFormLabel as FormLabel,
ChakraHeading as Heading,
ChakraStack as Stack,
ChakraText as Text,
formatIsoLocation,
isoStringToEntry,
Expand Down Expand Up @@ -50,6 +53,7 @@ import {
PrivacyNoticeFramework,
Property,
RejectAllMechanism,
ResurfaceBehavior,
StagedResourceTypeValue,
SupportedLanguage,
} from "~/types/api";
Expand Down Expand Up @@ -96,6 +100,19 @@ const tcfRejectAllMechanismOptions: SelectProps["options"] = [
},
];

const resurfaceBehaviorOptions = [
{
label: "Reject",
value: ResurfaceBehavior.REJECT,
description: "Show the banner again when user rejects",
},
{
label: "Dismiss",
value: ResurfaceBehavior.DISMISS,
description: "Show the banner again when user dismisses",
},
];

const tcfBannerButtonOptions: SelectProps["options"] = [
{
label: "Banner and modal",
Expand Down Expand Up @@ -449,6 +466,52 @@ export const PrivacyExperienceForm = ({
/>
</Box>
)}
{(values.component === ComponentType.BANNER_AND_MODAL ||
values.component === ComponentType.TCF_OVERLAY) && (
<Box>
<FormLabel fontSize="sm" fontWeight="semibold" mb={2}>
Resurface banner
</FormLabel>
<Text fontSize="sm" color="gray.600" mb={3}>
Choose when to show the banner again after the user has interacted
with it. Leave unchecked for default behavior (only resurface on
cookie expiration, vendor changes, and other mandatory updates.)
</Text>
<CheckboxGroup
value={values.resurface_behavior ?? []}
onChange={(selectedValues) => {
setFieldValue(
"resurface_behavior",
selectedValues.length > 0 ? selectedValues : null,
);
}}
>
<Stack spacing={2}>
{resurfaceBehaviorOptions.map((option) => {
const isDisabled =
option.value === ResurfaceBehavior.DISMISS &&
!values.dismissable;
return (
<Checkbox
key={option.value}
value={option.value}
isDisabled={isDisabled}
>
<Box>
<Text fontSize="sm" fontWeight="medium">
{option.label}
</Text>
<Text fontSize="xs" color="gray.600">
{option.description}
</Text>
</Box>
</Checkbox>
);
Comment on lines +491 to +509
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Stale "dismiss" value when dismissable is toggled off

When dismissable is set to false, the "Dismiss" checkbox in the resurface_behavior group becomes disabled — but the value is not cleared from values.resurface_behavior. This means:

  1. If a user previously checked "Resurface on Dismiss", then toggles dismissable off, the checkbox appears checked-and-grayed (confusing UX).
  2. On form submission, resurface_behavior still includes "dismiss" even though dismissable is false — storing a logically inconsistent configuration in the database.

The form should clear "dismiss" from resurface_behavior when dismissable is set to false. One approach: in the onChange handler for the dismissable switch (or in a useEffect watching values.dismissable), remove ResurfaceBehavior.DISMISS from the array if it is present:

// When dismissable is turned off, clear "dismiss" from resurface_behavior
useEffect(() => {
  if (!values.dismissable && values.resurface_behavior?.includes(ResurfaceBehavior.DISMISS)) {
    setFieldValue(
      "resurface_behavior",
      values.resurface_behavior.filter((v) => v !== ResurfaceBehavior.DISMISS) || null,
    );
  }
}, [values.dismissable]);

})}
</Stack>
</CheckboxGroup>
</Box>
)}
Comment on lines +469 to +514
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Normalize resurface_behavior when dismissal is disabled.

isDisabled only blocks editing here; it does not clear an existing ResurfaceBehavior.DISMISS selection from Formik state. If someone edits a config that already has DISMISS selected and then turns dismissable off, this form can still save that now-invalid combination.

💡 Suggested fix
       {values.component !== ComponentType.PRIVACY_CENTER &&
         values.component !== ComponentType.HEADLESS && (
           <Box p="1px">
             <CustomSwitch
               name="dismissable"
               id="dismissable"
               label="Allow user to dismiss"
               variant="stacked"
+              onChange={(checked) => {
+                setFieldValue("dismissable", checked);
+                if (!checked) {
+                  const nextValues =
+                    values.resurface_behavior?.filter(
+                      (value) => value !== ResurfaceBehavior.DISMISS,
+                    ) ?? [];
+                  setFieldValue(
+                    "resurface_behavior",
+                    nextValues.length ? nextValues : null,
+                  );
+                }
+              }}
             />
           </Box>
         )}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clients/admin-ui/src/features/privacy-experience/PrivacyExperienceForm.tsx`
around lines 469 - 514, When dismissable is turned off in PrivacyExperienceForm,
existing values.resurface_behavior may still include ResurfaceBehavior.DISMISS;
add normalization to remove that value (or set resurface_behavior to null if no
values remain). Implement this by filtering values.resurface_behavior to exclude
ResurfaceBehavior.DISMISS whenever values.dismissable becomes false (e.g., in a
useEffect watching values.dismissable and values.resurface_behavior) and call
setFieldValue("resurface_behavior", filteredValuesOrNull). Also ensure any UI
handlers that toggle dismissable perform the same cleanup to prevent submitting
an invalid DISMISS selection.

<Divider />
<Heading fontSize="md" fontWeight="semibold">
Privacy notices
Expand Down
1 change: 1 addition & 0 deletions clients/admin-ui/src/types/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -534,6 +534,7 @@ export type { ResourceFilter } from "./models/ResourceFilter";
export { ResourceTypes } from "./models/ResourceTypes";
export { ResponseFormat } from "./models/ResponseFormat";
export type { ResponseWithMessage } from "./models/ResponseWithMessage";
export { ResurfaceBehavior } from "./models/ResurfaceBehavior";
export type { RevertAnswerRequest } from "./models/RevertAnswerRequest";
export type { ReviewPrivacyRequestIds } from "./models/ReviewPrivacyRequestIds";
export { RoleRegistryEnum } from "./models/RoleRegistryEnum";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import type { Layer1ButtonOption } from "./Layer1ButtonOption";
import type { MinimalProperty } from "./MinimalProperty";
import type { PrivacyNoticeRegion } from "./PrivacyNoticeRegion";
import type { RejectAllMechanism } from "./RejectAllMechanism";
import type { ResurfaceBehavior } from "./ResurfaceBehavior";

/**
* Schema for creating Experience Configs via the API
Expand Down Expand Up @@ -35,6 +36,7 @@ export type ExperienceConfigCreate = {
* Determines the behavior of the reject all button
*/
reject_all_mechanism?: RejectAllMechanism | null;
resurface_behavior?: Array<ResurfaceBehavior> | null;
privacy_notice_ids?: Array<string>;
translations?: Array<ExperienceTranslationCreate>;
properties?: Array<MinimalProperty>;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import type { Layer1ButtonOption } from "./Layer1ButtonOption";
import type { MinimalProperty } from "./MinimalProperty";
import type { PrivacyNoticeRegion } from "./PrivacyNoticeRegion";
import type { RejectAllMechanism } from "./RejectAllMechanism";
import type { ResurfaceBehavior } from "./ResurfaceBehavior";

/**
* The schema to update an ExperienceConfig via the API.
Expand Down Expand Up @@ -41,4 +42,5 @@ export type ExperienceConfigUpdate = {
* Determines the behavior of the reject all button
*/
reject_all_mechanism?: RejectAllMechanism | null;
resurface_behavior?: Array<ResurfaceBehavior> | null;
};
12 changes: 12 additions & 0 deletions clients/admin-ui/src/types/api/models/ResurfaceBehavior.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/* istanbul ignore file */
/* tslint:disable */
/* eslint-disable */

/**
* Resurface behavior options - controls when to re-show the banner/modal.
* Used to configure whether the experience resurfaces after rejection or dismissal.
*/
export enum ResurfaceBehavior {
REJECT = "reject",
DISMISS = "dismiss",
}
143 changes: 143 additions & 0 deletions clients/fides-js/__tests__/lib/consent-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -439,6 +439,149 @@ describe("shouldResurfaceBanner", () => {
options: {},
expected: false,
},
{
label:
"returns true when user rejected and resurface_behavior includes reject",
experience: {
...mockExperience,
experience_config: {
component: ComponentType.BANNER_AND_MODAL,
resurface_behavior: ["reject"],
},
},
cookie: {
...mockCookie,
fides_meta: { consentMethod: ConsentMethod.REJECT },
},
savedConsent: mockSavedConsent,
options: {},
expected: true,
},
{
label:
"returns true when user dismissed and resurface_behavior includes dismiss",
experience: {
...mockExperience,
experience_config: {
component: ComponentType.BANNER_AND_MODAL,
resurface_behavior: ["dismiss"],
},
},
cookie: {
...mockCookie,
fides_meta: { consentMethod: ConsentMethod.DISMISS },
},
savedConsent: mockSavedConsent,
options: {},
expected: true,
},
{
label:
"returns true when user rejected and resurface_behavior includes both reject and dismiss",
experience: {
...mockExperience,
experience_config: {
component: ComponentType.BANNER_AND_MODAL,
resurface_behavior: ["reject", "dismiss"],
},
},
cookie: {
...mockCookie,
fides_meta: { consentMethod: ConsentMethod.REJECT },
},
savedConsent: mockSavedConsent,
options: {},
expected: true,
},
{
label:
"returns false when user accepted and resurface_behavior only includes reject",
experience: {
...mockExperience,
experience_config: {
component: ComponentType.BANNER_AND_MODAL,
resurface_behavior: ["reject"],
},
},
cookie: {
...mockCookie,
fides_meta: { consentMethod: ConsentMethod.ACCEPT },
},
savedConsent: mockSavedConsent,
options: {},
expected: false,
},
{
label: "returns false when user rejected and resurface_behavior is null",
experience: {
...mockExperience,
experience_config: {
component: ComponentType.BANNER_AND_MODAL,
resurface_behavior: null,
},
},
cookie: {
...mockCookie,
fides_meta: { consentMethod: ConsentMethod.REJECT },
},
savedConsent: mockSavedConsent,
options: {},
expected: false,
},
{
label:
"returns false when user rejected and resurface_behavior is undefined",
experience: {
...mockExperience,
experience_config: {
component: ComponentType.BANNER_AND_MODAL,
resurface_behavior: undefined,
},
},
cookie: {
...mockCookie,
fides_meta: { consentMethod: ConsentMethod.REJECT },
},
savedConsent: mockSavedConsent,
options: {},
expected: false,
},
{
label:
"returns false when user dismissed and resurface_behavior only includes reject",
experience: {
...mockExperience,
experience_config: {
component: ComponentType.BANNER_AND_MODAL,
resurface_behavior: ["reject"],
},
},
cookie: {
...mockCookie,
fides_meta: { consentMethod: ConsentMethod.DISMISS },
},
savedConsent: mockSavedConsent,
options: {},
expected: false,
},
{
label:
"returns true when user dismissed and resurface_behavior includes both",
experience: {
...mockExperience,
experience_config: {
component: ComponentType.BANNER_AND_MODAL,
resurface_behavior: ["reject", "dismiss"],
},
},
cookie: {
...mockCookie,
fides_meta: { consentMethod: ConsentMethod.DISMISS },
},
savedConsent: mockSavedConsent,
options: {},
expected: true,
},
])("$label", ({ experience, cookie, savedConsent, options, expected }) => {
expect(
shouldResurfaceBanner(
Expand Down
19 changes: 19 additions & 0 deletions clients/fides-js/docs/interfaces/FidesExperienceConfig.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,3 +123,22 @@ List of all available translations for the current experience.

This corresponds with the "Reject all mechanism" configuration option for TCF overlay experiences.
Determines whether opting out of all purposes blocks everything (both consent and legitimate interest processing) or only blocks consent-based processing while allowing legitimate interest to continue.

***

### resurface\_behavior?

> `optional` **resurface\_behavior**: `string`[]

This corresponds with the "Resurface banner" configuration option.
Controls when to show the consent banner again after the user has interacted with it.
Can include "reject", "dismiss", both, or be empty/null for default behavior (only resurface on cookie expiration or vendor changes).

#### Example

```ts
["reject"] // Resurface only on reject
["dismiss"] // Resurface only on dismiss
["reject", "dismiss"] // Resurface on both
null // Default behavior (no resurfacing)
```
14 changes: 14 additions & 0 deletions clients/fides-js/src/docs/fides-experience-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,20 @@ export interface FidesExperienceConfig {
*/
reject_all_mechanism: string;

/**
* This corresponds with the "Resurface banner" configuration option.
* Controls when to show the consent banner again after the user has interacted with it.
* Can include "reject", "dismiss", both, or be empty/null for default behavior (only resurface on cookie expiration or vendor changes).
* @example
* ```ts
* ["reject"] // Resurface only on reject
* ["dismiss"] // Resurface only on dismiss
* ["reject", "dismiss"] // Resurface on both
* null // Default behavior (no resurfacing)
* ```
*/
resurface_behavior?: Array<string>;
Comment on lines +90 to +102
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Allow null here to match the documented/API shape.

The example block says null disables resurfacing, but the declared type only allows Array<string>. The generated API types already model this as nullable, so this doc contract is currently narrower than what callers can actually receive.

Suggested fix
-  resurface_behavior?: Array<string>;
+  resurface_behavior?: Array<"reject" | "dismiss"> | null;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/**
* This corresponds with the "Resurface banner" configuration option.
* Controls when to show the consent banner again after the user has interacted with it.
* Can include "reject", "dismiss", both, or be empty/null for default behavior (only resurface on cookie expiration or vendor changes).
* @example
* ```ts
* ["reject"] // Resurface only on reject
* ["dismiss"] // Resurface only on dismiss
* ["reject", "dismiss"] // Resurface on both
* null // Default behavior (no resurfacing)
* ```
*/
resurface_behavior?: Array<string>;
/**
* This corresponds with the "Resurface banner" configuration option.
* Controls when to show the consent banner again after the user has interacted with it.
* Can include "reject", "dismiss", both, or be empty/null for default behavior (only resurface on cookie expiration or vendor changes).
* `@example`
*
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clients/fides-js/src/docs/fides-experience-config.ts` around lines 90 - 102,
The resurface_behavior property is documented to accept null but its type is
currently Array<string>; update the type of resurface_behavior in
fides-experience-config (the resurface_behavior? property) to be nullable (e.g.,
Array<string> | null or string[] | null) so the TypeScript type matches the
documented/API shape, and adjust the JSDoc if needed to explicitly state it can
be null.


/**
* @internal
*/
Expand Down
9 changes: 9 additions & 0 deletions clients/fides-js/src/lib/consent-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,15 @@ export const shouldResurfaceBanner = (
if (cookie?.fides_meta.consentMethod === ConsentMethod.GPC) {
return true;
}
// Resurface if configured for this consent method
if (
cookie?.fides_meta.consentMethod &&
experience.experience_config?.resurface_behavior?.includes(
cookie.fides_meta.consentMethod,
)
) {
return true;
Comment on lines +314 to +321
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

resurface_behavior is unreachable for TCF overlays.

This branch never runs for ComponentType.TCF_OVERLAY because the function already returns in the TCF block at Lines 279-290. That means a TCF experience configured to resurface on reject/dismiss will still ignore this setting once the version hash matches.

Suggested fix
   if (
     experience.experience_config?.component === ComponentType.TCF_OVERLAY &&
     !!cookie
   ) {
     if (!!options && isConsentOverride(options)) {
       return false;
     }
+    if (
+      cookie.fides_meta.consentMethod &&
+      experience.experience_config?.resurface_behavior?.includes(
+        cookie.fides_meta.consentMethod,
+      )
+    ) {
+      return true;
+    }
     if (experience.meta?.version_hash) {
       return experience.meta.version_hash !== cookie.tcf_version_hash;
     }
     return true;
   }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
// Resurface if configured for this consent method
if (
cookie?.fides_meta.consentMethod &&
experience.experience_config?.resurface_behavior?.includes(
cookie.fides_meta.consentMethod,
)
) {
return true;
if (
experience.experience_config?.component === ComponentType.TCF_OVERLAY &&
!!cookie
) {
if (!!options && isConsentOverride(options)) {
return false;
}
if (
cookie.fides_meta.consentMethod &&
experience.experience_config?.resurface_behavior?.includes(
cookie.fides_meta.consentMethod,
)
) {
return true;
}
if (experience.meta?.version_hash) {
return experience.meta.version_hash !== cookie.tcf_version_hash;
}
return true;
}
// Resurface if configured for this consent method
if (
cookie?.fides_meta.consentMethod &&
experience.experience_config?.resurface_behavior?.includes(
cookie.fides_meta.consentMethod,
)
) {
return true;
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@clients/fides-js/src/lib/consent-utils.ts` around lines 314 - 321, The TCF
overlay early-return prevents honoring
experience.experience_config?.resurface_behavior for TCF overlays; update the
resurface logic in the consent-resurfacing function (the block that checks
ComponentType.TCF_OVERLAY and the block that checks
cookie?.fides_meta.consentMethod /
experience.experience_config?.resurface_behavior) so that resurface_behavior is
checked for TCF overlays before returning early, or incorporate the same
resurface_behavior check into the TCF overlay branch (use the same
cookie.fides_meta.consentMethod and
experience.experience_config?.resurface_behavior check and return true when
matched) ensuring ComponentType.TCF_OVERLAY experiences can resurface on
reject/dismiss.

}
// Lastly, if we do have a prior consent state, resurface if we find *any*
// notices that don't have prior consent in that state
const hasConsentInCookie = (
Expand Down
Loading
Loading