Skip to content
Closed
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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ Changes can also be flagged with a GitHub label for tracking purposes. The URL o

## [Unreleased](https://github.com/ethyca/fides/compare/2.84.0..main)

### Added
- Privacy center form builder: free reordering of identity and custom fields via a new `field_order` array on each privacy request action, persisted end-to-end through the form builder, the privacy center renderer, and admin/property APIs.

### Deprecated
- `custom_privacy_request_field_order` on privacy center actions — superseded by `field_order`, which orders identity and custom fields jointly. Existing configs continue to read correctly.

## [2.84.0](https://github.com/ethyca/fides/compare/2.83.3..2.84.0)

### Added
Expand Down
59 changes: 58 additions & 1 deletion clients/admin-ui/cypress/e2e/properties/form-builder.cy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,64 @@ describe("Privacy center form builder", () => {
.should("be.visible");
});

// ── Test 2: dropped-features acknowledgement ────────────────────────────────
// ── Test 2: field_order persists across save ────────────────────────────────
it("saves field_order so a custom field can sit between identity fields", () => {
// Spec places `reason` between Email and Phone — i.e. the legacy
// hardcoded name → email → phone → customs ordering can't represent this.
const spec = {
root: "form",
elements: {
form: {
type: "Form",
props: {},
children: ["f_email", "f_reason", "f_phone"],
},
f_email: { type: "Email", props: { required: true }, children: [] },
f_reason: {
type: "Text",
props: { name: "reason", label: "Reason", required: false },
children: [],
},
f_phone: { type: "Phone", props: { required: false }, children: [] },
},
};

cy.intercept(
"POST",
`/api/v1/plus/property/${PROPERTY_ID}/form-builder/chat`,
{
statusCode: 200,
headers: { "content-type": "text/event-stream" },
body: buildSseBody(spec),
},
).as("chatTurn");

cy.intercept("PUT", `/api/v1/plus/property/${PROPERTY_ID}`, {
statusCode: 200,
body: buildPropertyFixture(),
}).as("savePut");

cy.visit(`/properties/${PROPERTY_ID}/forms/${POLICY_KEY}`);
cy.wait("@getProperty");

cy.findByPlaceholderText(/tell the builder/i).type(
"Add a reason field between email and phone",
);
cy.findByRole("button", { name: /^send$/i }).click();
cy.wait("@chatTurn");

cy.findByRole("button", { name: /^save$/i }).click();
cy.wait("@savePut").then((interception) => {
const action = (
interception.request.body.privacy_center_config.actions ?? []
).find((a: { policy_key?: string }) => a.policy_key === POLICY_KEY);
expect(action.field_order).to.deep.equal(["email", "reason", "phone"]);
// Deprecated key should not be re-emitted on save.
expect(action).not.to.have.property("custom_privacy_request_field_order");
});
});

// ── Test 3: dropped-features acknowledgement ────────────────────────────────
it("warns and gates save when conditional logic is present", () => {
const spec = buildSpec({
f_state: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ interface ActionShape {
cancelButtonText?: string | null;
custom_privacy_request_fields?: PcCustomFields;
identity_inputs?: Record<string, "required" | "optional"> | null;
field_order?: string[] | null;
// eslint-disable-next-line no-underscore-dangle
_form_builder_spec?: { spec: JsonRenderSpec; version: number };
}
Expand All @@ -55,6 +56,7 @@ interface FormBuilderPageProps {
actionPolicyKey: string;
pcShape: PcCustomFields;
identityInputs: Record<string, "required" | "optional">;
fieldOrder: string[];
richSpec: JsonRenderSpec;
}) => Promise<void>;
}
Expand Down Expand Up @@ -127,6 +129,7 @@ export const FormBuilderPage = ({
return synthesizeSpecFromPcShape(
action.custom_privacy_request_fields ?? {},
action.identity_inputs,
action.field_order,
);
}
// No saved fields yet — seed with the standard DSR defaults so the
Expand Down Expand Up @@ -328,6 +331,7 @@ export const FormBuilderPage = ({
actionPolicyKey,
pcShape: result.pcShape,
identityInputs: result.identityInputs,
fieldOrder: result.fieldOrder,
richSpec: builder.spec,
});
// Save just wrote rich + legacy in lockstep, so any prior drift is
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,72 @@ describe("mapSpecToPcShape", () => {
expect(result.pcShape.notes.placeholder).toBeUndefined();
});

it("emits fieldOrder reflecting children order across identity and custom fields", () => {
const spec = buildSpec(
{
f_email: { type: "Email", props: { required: true }, children: [] },
f_reason: {
type: "Text",
props: { name: "reason", label: "Reason", required: false },
children: [],
},
f_name: { type: "Name", props: { required: false }, children: [] },
f_topics: {
type: "MultiSelect",
props: {
name: "topics",
label: "Topics",
required: false,
options: ["A", "B"],
},
children: [],
},
},
["f_email", "f_reason", "f_name", "f_topics"],
);

const result = mapSpecToPcShape(spec);

expect(result.errors).toEqual([]);
expect(result.fieldOrder).toEqual(["email", "reason", "name", "topics"]);
expect(result.identityInputs).toEqual({
email: "required",
name: "optional",
});
expect(Object.keys(result.pcShape)).toEqual(["reason", "topics"]);
});

it("returns an empty fieldOrder when the spec has no children", () => {
const spec = buildSpec({}, []);
const result = mapSpecToPcShape(spec);
expect(result.fieldOrder).toEqual([]);
});

it("excludes unknown components from fieldOrder while reporting them as dropped", () => {
const spec = buildSpec(
{
ok: {
type: "Text",
props: { name: "notes", label: "Notes", required: false },
children: [],
},
weird: {
type: "Mystery",
props: {},
children: [],
},
},
["ok", "weird"],
);

const result = mapSpecToPcShape(spec);

expect(result.fieldOrder).toEqual(["notes"]);
expect(result.droppedFeatures.map((d) => d.kind)).toContain(
"unknown_component",
);
});

it("translates json-render `visible` into legacy `visible_when` and does not drop it", () => {
const spec = buildSpec(
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,4 +52,110 @@ describe("synthesizeSpecFromPcShape", () => {
expect(back.pcShape.country.field_type).toBe("location");
expect(back.pcShape.contact_method.field_type).toBe("radio");
});

it("uses fieldOrder when provided, interleaving identity and custom fields", () => {
const pcShape = {
reason: { label: "Reason", field_type: "text" as const, required: false },
topics: {
label: "Topics",
field_type: "multiselect" as const,
options: ["A", "B"],
required: false,
},
};
const identityInputs = {
email: "required" as const,
name: "optional" as const,
};
const fieldOrder = ["email", "reason", "name", "topics"];

const spec = synthesizeSpecFromPcShape(pcShape, identityInputs, fieldOrder);

expect(spec.elements.form.children).toEqual([
"f_email",
"f_reason",
"f_name",
"f_topics",
]);
const back = mapSpecToPcShape(spec);
expect(back.fieldOrder).toEqual(["email", "reason", "name", "topics"]);
});

it("falls back to legacy ordering when fieldOrder is absent", () => {
const pcShape = {
reason: { label: "Reason", field_type: "text" as const, required: false },
};
const identityInputs = {
phone: "optional" as const,
email: "required" as const,
name: "optional" as const,
};

const spec = synthesizeSpecFromPcShape(pcShape, identityInputs);

// Canonical legacy order: name → email → phone → customs.
expect(spec.elements.form.children).toEqual([
"f_name",
"f_email",
"f_phone",
"f_reason",
]);
});

it("appends configured fields missing from fieldOrder using legacy fallback", () => {
const pcShape = {
reason: { label: "Reason", field_type: "text" as const, required: false },
topics: {
label: "Topics",
field_type: "multiselect" as const,
options: ["A"],
required: false,
},
};
const identityInputs = { email: "required" as const };
const fieldOrder = ["reason", "email"]; // `topics` configured but absent from order

const spec = synthesizeSpecFromPcShape(pcShape, identityInputs, fieldOrder);

expect(spec.elements.form.children).toEqual([
"f_reason",
"f_email",
"f_topics",
]);
});

it("skips unknown keys in fieldOrder rather than crashing on stale data", () => {
const pcShape = {
reason: { label: "Reason", field_type: "text" as const, required: false },
};
const identityInputs = { email: "required" as const };
const fieldOrder = ["email", "ghost_field", "reason"];

const spec = synthesizeSpecFromPcShape(pcShape, identityInputs, fieldOrder);

expect(spec.elements.form.children).toEqual(["f_email", "f_reason"]);
});

it("preserves arbitrary order via a synthesize → map round trip", () => {
const pcShape = {
reason: { label: "Reason", field_type: "text" as const, required: false },
topics: {
label: "Topics",
field_type: "multiselect" as const,
options: ["A"],
required: false,
},
};
const identityInputs = {
email: "required" as const,
name: "optional" as const,
phone: "optional" as const,
};
const fieldOrder = ["topics", "email", "phone", "reason", "name"];

const spec = synthesizeSpecFromPcShape(pcShape, identityInputs, fieldOrder);
const back = mapSpecToPcShape(spec);

expect(back.fieldOrder).toEqual(fieldOrder);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,11 @@ export type ValidationError =
export interface MapResult {
pcShape: PcCustomFields;
identityInputs: Record<string, "required" | "optional">;
// Unified render order across identity_inputs and pcShape, in the order the
// form builder's children list resolved. Persisted as `field_order` on the
// privacy center action so the public renderer can interleave identity and
// custom fields freely. Empty if the spec has no children.
fieldOrder: string[];
droppedFeatures: DroppedFeature[];
errors: ValidationError[];
}
Expand Down Expand Up @@ -204,11 +209,12 @@ export function mapSpecToPcShape(spec: JsonRenderSpec): MapResult {
const errors: ValidationError[] = [];
const pcShape: PcCustomFields = {};
const identityInputs: Record<string, "required" | "optional"> = {};
const fieldOrder: string[] = [];

const root = spec.elements?.[spec.root];
if (!root || root.type !== "Form") {
errors.push({ kind: "missing_form_root", rootId: spec.root });
return { pcShape, identityInputs, droppedFeatures, errors };
return { pcShape, identityInputs, fieldOrder, droppedFeatures, errors };
}

const seenNames: Record<string, string[]> = {};
Expand Down Expand Up @@ -260,6 +266,7 @@ export function mapSpecToPcShape(spec: JsonRenderSpec): MapResult {
}
const { required } = validation.data as { required: boolean };
identityInputs[identityKey] = required ? "required" : "optional";
fieldOrder.push(identityKey);
return;
}

Expand Down Expand Up @@ -380,6 +387,7 @@ export function mapSpecToPcShape(spec: JsonRenderSpec): MapResult {
}
}
pcShape[name] = pcField;
fieldOrder.push(name);
});

Object.entries(seenNames).forEach(([name, ids]) => {
Expand All @@ -388,5 +396,5 @@ export function mapSpecToPcShape(spec: JsonRenderSpec): MapResult {
}
});

return { pcShape, identityInputs, droppedFeatures, errors };
return { pcShape, identityInputs, fieldOrder, droppedFeatures, errors };
}
Loading
Loading