@@ -59,7 +59,7 @@ export const SystemCoverageCard = () => {
diff --git a/clients/admin-ui/src/home/TrendCard.tsx b/clients/admin-ui/src/home/TrendCard.tsx
index 4b95755ffff..13511500ea3 100644
--- a/clients/admin-ui/src/home/TrendCard.tsx
+++ b/clients/admin-ui/src/home/TrendCard.tsx
@@ -9,11 +9,11 @@ import cardStyles from "./dashboard-card.module.scss";
type StatType = "number" | "percent";
const TREND_METRIC_CONFIG = {
- gps_score: { label: "GPS Score", statType: "number" },
- dsr_volume: { label: "DSR Volume", statType: "number" },
- system_coverage: { label: "System Coverage", statType: "percent" },
+ gps_score: { label: "Governance posture", statType: "number" },
+ dsr_volume: { label: "DSR volume", statType: "number" },
+ system_coverage: { label: "System coverage", statType: "percent" },
classification_health: {
- label: "Classification Health",
+ label: "Classification health",
statType: "percent",
},
} satisfies Record
;
diff --git a/clients/admin-ui/src/pages/data-consumers/[id].tsx b/clients/admin-ui/src/pages/data-consumers/[id].tsx
new file mode 100644
index 00000000000..c8d197a7af2
--- /dev/null
+++ b/clients/admin-ui/src/pages/data-consumers/[id].tsx
@@ -0,0 +1,113 @@
+import { Flex, Spin, useMessage } from "fidesui";
+import type { NextPage } from "next";
+import { useRouter } from "next/router";
+
+import ErrorPage from "~/features/common/errors/ErrorPage";
+import { getErrorMessage } from "~/features/common/helpers";
+import Layout from "~/features/common/Layout";
+import { DATA_CONSUMERS_ROUTE } from "~/features/common/nav/routes";
+import PageHeader from "~/features/common/PageHeader";
+import {
+ useAssignConsumerPurposesMutation,
+ useGetDataConsumerByIdQuery,
+ useUpdateDataConsumerMutation,
+} from "~/features/data-consumers/data-consumer.slice";
+import DataConsumerForm, {
+ DataConsumerFormValues,
+} from "~/features/data-consumers/DataConsumerForm";
+import { RTKErrorResult } from "~/types/errors/api";
+
+const EditDataConsumerPage: NextPage = () => {
+ const message = useMessage();
+ const router = useRouter();
+ const { id: consumerId } = router.query;
+
+ const {
+ data: consumer,
+ error,
+ isLoading,
+ } = useGetDataConsumerByIdQuery(consumerId as string, {
+ skip: !consumerId,
+ });
+
+ const [updateDataConsumer] = useUpdateDataConsumerMutation();
+ const [assignConsumerPurposes] = useAssignConsumerPurposesMutation();
+
+ const handleSubmit = async (values: DataConsumerFormValues) => {
+ if (!consumerId) {
+ return;
+ }
+
+ const { purposeFidesKeys, ...consumerPayload } = values;
+
+ try {
+ await updateDataConsumer({
+ id: consumerId as string,
+ ...consumerPayload,
+ }).unwrap();
+
+ // Check if purposes changed and assign if so
+ const existingPurposes = consumer?.purpose_fides_keys ?? [];
+ const purposesChanged =
+ JSON.stringify([...purposeFidesKeys].sort()) !==
+ JSON.stringify([...existingPurposes].sort());
+
+ if (purposesChanged) {
+ try {
+ await assignConsumerPurposes({
+ id: consumerId as string,
+ purposeFidesKeys,
+ }).unwrap();
+ } catch (assignErr) {
+ message.error(
+ `Data consumer updated but failed to assign purposes: ${getErrorMessage(assignErr as RTKErrorResult["error"])}`,
+ );
+ return;
+ }
+ }
+
+ message.success(`Data consumer "${values.name}" updated successfully`);
+ } catch (err) {
+ message.error(getErrorMessage(err as RTKErrorResult["error"]));
+ }
+ };
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ if (isLoading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ return (
+
+
+
+
+ );
+};
+
+export default EditDataConsumerPage;
diff --git a/clients/admin-ui/src/pages/data-consumers/index.tsx b/clients/admin-ui/src/pages/data-consumers/index.tsx
new file mode 100644
index 00000000000..a517fcb5913
--- /dev/null
+++ b/clients/admin-ui/src/pages/data-consumers/index.tsx
@@ -0,0 +1,36 @@
+import { Typography } from "fidesui";
+import type { NextPage } from "next";
+
+import ErrorPage from "~/features/common/errors/ErrorPage";
+import Layout from "~/features/common/Layout";
+import PageHeader from "~/features/common/PageHeader";
+import { useGetAllDataConsumersQuery } from "~/features/data-consumers/data-consumer.slice";
+import DataConsumersTable from "~/features/data-consumers/DataConsumersTable";
+
+const DataConsumersPage: NextPage = () => {
+ const { error } = useGetAllDataConsumersQuery({});
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ Review and manage your data consumers below. Data consumers represent
+ the services, applications, groups, or users that access personal
+ data.
+
+
+
+
+ );
+};
+
+export default DataConsumersPage;
diff --git a/clients/admin-ui/src/pages/data-consumers/new.tsx b/clients/admin-ui/src/pages/data-consumers/new.tsx
new file mode 100644
index 00000000000..a9f7572a730
--- /dev/null
+++ b/clients/admin-ui/src/pages/data-consumers/new.tsx
@@ -0,0 +1,71 @@
+import { useMessage } from "fidesui";
+import type { NextPage } from "next";
+import { useRouter } from "next/router";
+
+import { getErrorMessage } from "~/features/common/helpers";
+import Layout from "~/features/common/Layout";
+import { DATA_CONSUMERS_ROUTE } from "~/features/common/nav/routes";
+import PageHeader from "~/features/common/PageHeader";
+import {
+ useAssignConsumerPurposesMutation,
+ useCreateDataConsumerMutation,
+} from "~/features/data-consumers/data-consumer.slice";
+import DataConsumerForm, {
+ DataConsumerFormValues,
+} from "~/features/data-consumers/DataConsumerForm";
+import { RTKErrorResult } from "~/types/errors/api";
+
+const NewDataConsumerPage: NextPage = () => {
+ const message = useMessage();
+ const router = useRouter();
+ const [createDataConsumer] = useCreateDataConsumerMutation();
+ const [assignConsumerPurposes] = useAssignConsumerPurposesMutation();
+
+ const handleSubmit = async (values: DataConsumerFormValues) => {
+ const { purposeFidesKeys, ...consumerPayload } = values;
+
+ try {
+ const created = await createDataConsumer(consumerPayload).unwrap();
+
+ if (purposeFidesKeys.length > 0) {
+ try {
+ await assignConsumerPurposes({
+ id: created.id,
+ purposeFidesKeys,
+ }).unwrap();
+ } catch (assignErr) {
+ message.error(
+ `Data consumer created but failed to assign purposes: ${getErrorMessage(assignErr as RTKErrorResult["error"])}`,
+ );
+ router.push(`${DATA_CONSUMERS_ROUTE}/${created.id}`);
+ return;
+ }
+ }
+
+ message.success(`Data consumer "${values.name}" created successfully`);
+ router.push(`${DATA_CONSUMERS_ROUTE}/${created.id}`);
+ } catch (err) {
+ message.error(getErrorMessage(err as RTKErrorResult["error"]));
+ }
+ };
+
+ return (
+
+
+
+
+ );
+};
+
+export default NewDataConsumerPage;
diff --git a/clients/admin-ui/src/pages/data-purposes/[fidesKey].tsx b/clients/admin-ui/src/pages/data-purposes/[fidesKey].tsx
new file mode 100644
index 00000000000..f28dde3184f
--- /dev/null
+++ b/clients/admin-ui/src/pages/data-purposes/[fidesKey].tsx
@@ -0,0 +1,78 @@
+import { Spin, useMessage } from "fidesui";
+import type { NextPage } from "next";
+import { useRouter } from "next/router";
+
+import ErrorPage from "~/features/common/errors/ErrorPage";
+import { getErrorMessage } from "~/features/common/helpers";
+import Layout from "~/features/common/Layout";
+import { DATA_PURPOSES_ROUTE } from "~/features/common/nav/routes";
+import PageHeader from "~/features/common/PageHeader";
+import {
+ useGetDataPurposeByKeyQuery,
+ useUpdateDataPurposeMutation,
+} from "~/features/data-purposes/data-purpose.slice";
+import DataPurposeForm, {
+ DataPurposeFormValues,
+} from "~/features/data-purposes/DataPurposeForm";
+import { RTKErrorResult } from "~/types/errors/api";
+
+const EditDataPurposePage: NextPage = () => {
+ const message = useMessage();
+ const router = useRouter();
+ const { fidesKey } = router.query;
+
+ const {
+ data: purpose,
+ error,
+ isLoading,
+ } = useGetDataPurposeByKeyQuery(fidesKey as string, {
+ skip: !fidesKey,
+ });
+
+ const [updateDataPurpose] = useUpdateDataPurposeMutation();
+
+ const handleSubmit = async (values: DataPurposeFormValues) => {
+ try {
+ await updateDataPurpose({
+ fidesKey: values.fides_key,
+ ...values,
+ }).unwrap();
+ message.success(`Data purpose "${values.name}" updated successfully`);
+ } catch (err) {
+ message.error(getErrorMessage(err as RTKErrorResult["error"]));
+ }
+ };
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
+
+ );
+};
+
+export default EditDataPurposePage;
diff --git a/clients/admin-ui/src/pages/data-purposes/index.tsx b/clients/admin-ui/src/pages/data-purposes/index.tsx
new file mode 100644
index 00000000000..25b89bfa0ce
--- /dev/null
+++ b/clients/admin-ui/src/pages/data-purposes/index.tsx
@@ -0,0 +1,35 @@
+import { Typography } from "fidesui";
+import type { NextPage } from "next";
+
+import ErrorPage from "~/features/common/errors/ErrorPage";
+import Layout from "~/features/common/Layout";
+import PageHeader from "~/features/common/PageHeader";
+import { useGetAllDataPurposesQuery } from "~/features/data-purposes/data-purpose.slice";
+import DataPurposesTable from "~/features/data-purposes/DataPurposesTable";
+
+const DataPurposesPage: NextPage = () => {
+ const { error } = useGetAllDataPurposesQuery({});
+
+ if (error) {
+ return (
+
+ );
+ }
+
+ return (
+
+
+
+ Review and manage your data purposes below. Data purposes define the
+ reasons data is collected and processed within your organization.
+
+
+
+
+ );
+};
+
+export default DataPurposesPage;
diff --git a/clients/admin-ui/src/pages/data-purposes/new.tsx b/clients/admin-ui/src/pages/data-purposes/new.tsx
new file mode 100644
index 00000000000..dee270ec078
--- /dev/null
+++ b/clients/admin-ui/src/pages/data-purposes/new.tsx
@@ -0,0 +1,51 @@
+import { useMessage } from "fidesui";
+import type { NextPage } from "next";
+import { useRouter } from "next/router";
+
+import { getErrorMessage } from "~/features/common/helpers";
+import Layout from "~/features/common/Layout";
+import { DATA_PURPOSES_ROUTE } from "~/features/common/nav/routes";
+import PageHeader from "~/features/common/PageHeader";
+import { useCreateDataPurposeMutation } from "~/features/data-purposes/data-purpose.slice";
+import DataPurposeForm, {
+ DataPurposeFormValues,
+} from "~/features/data-purposes/DataPurposeForm";
+import { RTKErrorResult } from "~/types/errors/api";
+
+const AddDataPurposePage: NextPage = () => {
+ const message = useMessage();
+ const router = useRouter();
+ const [createDataPurpose] = useCreateDataPurposeMutation();
+
+ const handleSubmit = async (values: DataPurposeFormValues) => {
+ try {
+ await createDataPurpose(values).unwrap();
+ message.success(`Data purpose "${values.name}" created successfully`);
+ router.push(DATA_PURPOSES_ROUTE);
+ } catch (error) {
+ message.error(getErrorMessage(error as RTKErrorResult["error"]));
+ }
+ };
+
+ return (
+
+
+
+
+
+
+ );
+};
+
+export default AddDataPurposePage;
diff --git a/clients/admin-ui/src/pages/poc/seed-data.tsx b/clients/admin-ui/src/pages/poc/seed-data.tsx
new file mode 100644
index 00000000000..a2ee4cbc2bc
--- /dev/null
+++ b/clients/admin-ui/src/pages/poc/seed-data.tsx
@@ -0,0 +1,22 @@
+import { Layout, Typography } from "fidesui";
+import type { NextPage } from "next";
+
+import PageHeader from "~/features/common/PageHeader";
+import SeedDataPanel from "~/features/seed-data/SeedDataPanel";
+
+const { Content } = Layout;
+const { Paragraph } = Typography;
+
+const SeedDataPage: NextPage = () => {
+ return (
+
+
+
+ Seed demo data into this environment for testing and development.
+
+
+
+ );
+};
+
+export default SeedDataPage;
diff --git a/clients/admin-ui/src/types/api/models/IntegrationFeature.ts b/clients/admin-ui/src/types/api/models/IntegrationFeature.ts
index d5be835d83d..bf8887f91c1 100644
--- a/clients/admin-ui/src/types/api/models/IntegrationFeature.ts
+++ b/clients/admin-ui/src/types/api/models/IntegrationFeature.ts
@@ -13,4 +13,5 @@ export enum IntegrationFeature {
WITHOUT_CONNECTION = "WITHOUT_CONNECTION",
DSR_AUTOMATION = "DSR_AUTOMATION",
CONDITIONS = "CONDITIONS",
+ QUERY_LOGGING = "QUERY_LOGGING",
}
diff --git a/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts b/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts
index 52c992effc6..e2b6c3b94a2 100644
--- a/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts
+++ b/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts
@@ -62,10 +62,18 @@ export enum ScopeRegistryEnum {
CUSTOM_REPORT_CREATE = "custom_report:create",
CUSTOM_REPORT_DELETE = "custom_report:delete",
CUSTOM_REPORT_READ = "custom_report:read",
+ DATA_CONSUMER_CREATE = "data_consumer:create",
+ DATA_CONSUMER_DELETE = "data_consumer:delete",
+ DATA_CONSUMER_READ = "data_consumer:read",
+ DATA_CONSUMER_UPDATE = "data_consumer:update",
DATA_CATEGORY_CREATE = "data_category:create",
DATA_CATEGORY_DELETE = "data_category:delete",
DATA_CATEGORY_READ = "data_category:read",
DATA_CATEGORY_UPDATE = "data_category:update",
+ DATA_PURPOSE_CREATE = "data_purpose:create",
+ DATA_PURPOSE_DELETE = "data_purpose:delete",
+ DATA_PURPOSE_READ = "data_purpose:read",
+ DATA_PURPOSE_UPDATE = "data_purpose:update",
DATA_SUBJECT_CREATE = "data_subject:create",
DATA_SUBJECT_DELETE = "data_subject:delete",
DATA_SUBJECT_READ = "data_subject:read",
@@ -165,6 +173,10 @@ export enum ScopeRegistryEnum {
PROPERTY_DELETE = "property:delete",
PROPERTY_READ = "property:read",
PROPERTY_UPDATE = "property:update",
+ QUERY_LOG_SOURCE_CREATE = "query_log_source:create",
+ QUERY_LOG_SOURCE_DELETE = "query_log_source:delete",
+ QUERY_LOG_SOURCE_READ = "query_log_source:read",
+ QUERY_LOG_SOURCE_UPDATE = "query_log_source:update",
RESPONDENT_CREATE = "respondent:create",
RULE_CREATE_OR_UPDATE = "rule:create_or_update",
RULE_DELETE = "rule:delete",
diff --git a/clients/fidesui/src/components/charts/StackedBarChart.tsx b/clients/fidesui/src/components/charts/StackedBarChart.tsx
index d0ee988ea42..1a94ca6b1a5 100644
--- a/clients/fidesui/src/components/charts/StackedBarChart.tsx
+++ b/clients/fidesui/src/components/charts/StackedBarChart.tsx
@@ -203,17 +203,38 @@ export const StackedBarChart = ({
.filter((entry): entry is ChartEntry => entry !== null);
}, [data, segments]);
- if (chartData.length === 0) {
+ // Sort alphabetically so ordering is deterministic (e.g. Access before Erasure)
+ const sortedData = useMemo(
+ () => [...chartData].sort((a, b) => a.category.localeCompare(b.category)),
+ [chartData],
+ );
+
+ // Compute YAxis width dynamically from the longest label
+ const yAxisWidth = useMemo(() => {
+ if (sortedData.length === 0) {
+ return 80;
+ }
+ const longestLabel = sortedData.reduce(
+ (max, entry) =>
+ entry.category.length > max.length ? entry.category : max,
+ "",
+ );
+ const charWidth = token.fontSizeSM * 0.65;
+ const iconSpace = onCategoryClick ? ICON_SIZE + ICON_GAP + 8 : 8;
+ return Math.ceil(longestLabel.length * charWidth + iconSpace);
+ }, [sortedData, token.fontSizeSM, onCategoryClick]);
+
+ if (sortedData.length === 0) {
return null;
}
const chartHeight =
- chartData.length * barHeight + (chartData.length - 1) * rowGap + rowGap;
+ sortedData.length * barHeight + (sortedData.length - 1) * rowGap + rowGap;
return (
}
tickLine={false}
diff --git a/noxfiles/ci_nox.py b/noxfiles/ci_nox.py
index 513ac67bb80..dea8f8f7d32 100644
--- a/noxfiles/ci_nox.py
+++ b/noxfiles/ci_nox.py
@@ -560,6 +560,7 @@ def pytest_redis_cluster_docker(session: nox.Session) -> None:
"misc-integration-external",
],
"tests/task/": ["misc-unit", "misc-integration", "misc-integration-external"],
+ "tests/unit/": ["misc-unit"],
"tests/util/": ["misc-unit", "misc-integration", "misc-integration-external"],
"tests/qa/": ["misc-unit", "misc-integration", "misc-integration-external"],
"tests/integration/": ["ops-integration"], # Workflow integration tests
diff --git a/noxfiles/setup_tests_nox.py b/noxfiles/setup_tests_nox.py
index a9c0b46e212..2002d9665cc 100644
--- a/noxfiles/setup_tests_nox.py
+++ b/noxfiles/setup_tests_nox.py
@@ -462,6 +462,7 @@ def pytest_misc_unit(session: Session, pytest_config: PytestConfig) -> None:
"tests/service/",
"tests/system_integration_link/",
"tests/task/",
+ "tests/unit/",
"tests/util/",
"-m",
"not integration and not integration_external and not integration_saas and not integration_snowflake and not integration_bigquery and not integration_postgres",
diff --git a/plans/pbac-ui-data-purposes-consumers.md b/plans/pbac-ui-data-purposes-consumers.md
new file mode 100644
index 00000000000..404dbc47c1f
--- /dev/null
+++ b/plans/pbac-ui-data-purposes-consumers.md
@@ -0,0 +1,659 @@
+# PBAC UI: Data Purposes & Data Consumers Management
+
+**Source:** feat/dataset-data-purposes branch (fides repo)
+
+## Problem Statement
+
+The PBAC backend has full CRUD APIs for data purposes and data consumers, but there's no UI to manage them. Users must use the API directly or the demo script. We need two new management pages under "Core configuration" in the admin nav — one for data purposes, one for data consumers — following Ant Design v5 patterns and the conventions in the Talos frontend guidance.
+
+## Requirements Summary
+
+- **Data Purposes page**: List, create, edit, delete data purposes. Filterable by data use.
+- **Data Consumers page**: List, create, edit, delete data consumers. Assign/unassign purposes to consumers. Filterable by type and assigned purpose.
+- Both pages gated behind `alphaPurposeBasedAccessControl` feature flag and `requiresPlus`.
+- Follow the feature-folder pattern: table with search → add/edit form pages → delete confirmation modal.
+
+## API Contract
+
+### Data Purpose API (`/api/v1/data-purpose`)
+
+| Method | Path | Request | Response |
+|--------|------|---------|----------|
+| POST | `/data-purpose` | `DataPurposeCreate` | `DataPurposeResponse` |
+| GET | `/data-purpose` | `?data_use=&page=&size=` | `Page[DataPurposeResponse]` |
+| GET | `/data-purpose/{fides_key}` | — | `DataPurposeResponse` |
+| PUT | `/data-purpose/{fides_key}` | `DataPurposeUpdate` | `DataPurposeResponse` |
+| DELETE | `/data-purpose/{fides_key}` | `?force=true` | 204 |
+
+**Key fields:** `fides_key` (user-provided unique key), `name`, `data_use` (required), `data_subject`, `data_categories[]`, `description`
+
+### Data Consumer API (`/api/v1/data-consumer`)
+
+| Method | Path | Request | Response |
+|--------|------|---------|----------|
+| POST | `/data-consumer` | `DataConsumerCreate` | `DataConsumerResponse` |
+| GET | `/data-consumer` | `?type=&purpose_fides_key=&tags=&page=&size=` | `Page[DataConsumerResponse]` |
+| GET | `/data-consumer/{id}` | — | `DataConsumerResponse` |
+| PUT | `/data-consumer/{id}` | `DataConsumerUpdate` | `DataConsumerResponse` |
+| DELETE | `/data-consumer/{id}` | — | 204 |
+| PUT | `/data-consumer/{id}/purpose` | `{purpose_fides_keys: [...]}` | `DataConsumerResponse` |
+
+**Key fields:** `id` (UUID, system-generated), `name`, `type` (required), `contact_email`, `tags[]`, `purposes[]` (embedded refs), `purpose_fides_keys[]`
+
+## Frontend Conventions (Talos Guidance)
+
+These conventions **must** be followed — they supersede patterns in older pages like Properties:
+
+- **Ant Design v5 first** — always prefer Ant components over custom code
+- **Mutations**: always `.unwrap()` inside `try/catch`, never `isErrorResult()`
+- **User feedback**: use Ant `message.success()` / `message.error()`, never `useToast()` (deprecated Chakra)
+- **Error messages**: use `getErrorMessage()` from `~/features/common/helpers`
+- **Enums**: define TypeScript `enum` for finite backend value sets; keep label/color maps in `constants.ts`
+- **Styling**: Ant theme tokens → FidesUI palette → Tailwind (layout only) → SCSS modules. Never arbitrary hex or sizing.
+- **Navigation**: `` wrapping buttons, never `router.push()` in onClick
+- **Pages are thin shells**: fetch data, handle loading/errors, compose the root feature component
+- **One component per file**, split early
+- **Type safety**: no `any`, no `@ts-expect-error`; use `interface` over `type`; override generated types with `Omit`
+
+## Implementation Approach
+
+Each entity gets a feature folder following the Talos file organization pattern:
+
+1. **Types & constants** — enums, label maps, interfaces
+2. **RTK Query API slice** — CRUD endpoints with `providesTags` / `invalidatesTags`
+3. **List page** — Ant `` with search, pagination, filters
+4. **Add page** — Formik form with validation
+5. **Edit page** — Same form, pre-populated, with delete button
+6. **Nav registration** — Under "Core configuration" with feature flag
+
+### File Structure
+
+Per Talos guidance (`features/my-feature/` template):
+
+```
+clients/admin-ui/src/
+├── features/
+│ ├── data-purposes/
+│ │ ├── types.d.ts # interfaces and type overrides
+│ │ ├── constants.ts # enums, label maps, display config
+│ │ ├── data-purpose.slice.ts # RTK Query endpoints
+│ │ ├── DataPurposesTable.tsx # root feature component (Ant Table)
+│ │ ├── useDataPurposesTable.tsx # table hook (state, columns, data)
+│ │ ├── DataPurposeForm.tsx # Formik add/edit form
+│ │ ├── DeleteDataPurposeModal.tsx # confirm delete modal
+│ │ ├── DataPurposeActionsCell.tsx # edit/delete cell in table
+│ │ └── index.ts # barrel export of public API
+│ │
+│ └── data-consumers/
+│ ├── types.d.ts
+│ ├── constants.ts
+│ ├── data-consumer.slice.ts
+│ ├── DataConsumersTable.tsx
+│ ├── useDataConsumersTable.tsx
+│ ├── DataConsumerForm.tsx # includes purpose assignment
+│ ├── DeleteDataConsumerModal.tsx
+│ ├── DataConsumerActionsCell.tsx
+│ └── index.ts
+│
+├── pages/ # thin routing shells only
+│ ├── data-purposes/
+│ │ ├── index.tsx # list page
+│ │ ├── new.tsx # add page
+│ │ └── [fidesKey].tsx # edit page
+│ │
+│ └── data-consumers/
+│ ├── index.tsx
+│ ├── new.tsx
+│ └── [id].tsx
+```
+
+Notes on Talos conventions:
+- **`types.d.ts`** — interfaces and type aliases (including `Omit`-based overrides of generated types)
+- **`constants.ts`** — TypeScript enums, label/color maps, static option arrays
+- **`index.ts`** — required barrel file in every feature directory
+- **Pages** are thin shells — fetch data, handle loading/error, compose root feature component; no business logic
+
+### Detailed Component Design
+
+#### 1. Types & Constants
+
+**`features/data-consumers/types.d.ts`**:
+```ts
+import { DataConsumerType } from "./constants";
+
+// Override too-generic generated types
+export interface DataConsumerResponse extends Omit {
+ type: DataConsumerType;
+}
+```
+
+**`features/data-consumers/constants.ts`**:
+```ts
+// Enums and label maps per Talos convention
+
+export enum DataConsumerType {
+ SERVICE = "service",
+ APPLICATION = "application",
+ GROUP = "group",
+ USER = "user",
+}
+
+export const CONSUMER_TYPE_LABELS: Record = {
+ [DataConsumerType.SERVICE]: "Service",
+ [DataConsumerType.APPLICATION]: "Application",
+ [DataConsumerType.GROUP]: "Group",
+ [DataConsumerType.USER]: "User",
+};
+
+export const CONSUMER_TYPE_OPTIONS = Object.entries(CONSUMER_TYPE_LABELS).map(
+ ([value, label]) => ({ value, label })
+);
+```
+
+#### 2. RTK Query API Slices
+
+**`data-purpose.slice.ts`**:
+```ts
+const dataPurposeApi = baseApi.injectEndpoints({
+ endpoints: (builder) => ({
+ getAllDataPurposes: builder.query({
+ query: (params) => ({ url: "plus/data-purpose", params }),
+ providesTags: ["DataPurpose"],
+ }),
+ getDataPurposeByKey: builder.query({
+ query: (fidesKey) => ({ url: `plus/data-purpose/${fidesKey}` }),
+ providesTags: ["DataPurpose"],
+ }),
+ createDataPurpose: builder.mutation({
+ query: (body) => ({ url: "plus/data-purpose", method: "POST", body }),
+ invalidatesTags: ["DataPurpose"],
+ }),
+ updateDataPurpose: builder.mutation<
+ DataPurposeResponse,
+ { fidesKey: string } & DataPurposeUpdate
+ >({
+ query: ({ fidesKey, ...body }) => ({
+ url: `plus/data-purpose/${fidesKey}`,
+ method: "PUT",
+ body,
+ }),
+ invalidatesTags: ["DataPurpose"],
+ }),
+ deleteDataPurpose: builder.mutation({
+ query: ({ fidesKey, force }) => ({
+ url: `plus/data-purpose/${fidesKey}`,
+ method: "DELETE",
+ params: force ? { force: true } : undefined,
+ }),
+ invalidatesTags: ["DataPurpose", "DataConsumer"],
+ }),
+ }),
+});
+
+export const {
+ useGetAllDataPurposesQuery,
+ useGetDataPurposeByKeyQuery,
+ useCreateDataPurposeMutation,
+ useUpdateDataPurposeMutation,
+ useDeleteDataPurposeMutation,
+} = dataPurposeApi;
+```
+
+**`data-consumer.slice.ts`** — same pattern plus:
+```ts
+assignConsumerPurposes: builder.mutation<
+ DataConsumerResponse,
+ { id: string; purposeFidesKeys: string[] }
+>({
+ query: ({ id, purposeFidesKeys }) => ({
+ url: `plus/data-consumer/${id}/purpose`,
+ method: "PUT",
+ body: { purpose_fides_keys: purposeFidesKeys },
+ }),
+ invalidatesTags: ["DataConsumer"],
+}),
+```
+
+#### 3. Page Components (Thin Shells)
+
+**`pages/data-purposes/index.tsx`** (list):
+```tsx
+const DataPurposesPage: NextPage = () => {
+ return (
+
+
+
+
+ );
+};
+```
+
+**`pages/data-purposes/new.tsx`** (add):
+```tsx
+const AddDataPurposePage: NextPage = () => {
+ const [createDataPurpose] = useCreateDataPurposeMutation();
+ const router = useRouter();
+
+ const handleSubmit = async (values: DataPurposeCreate) => {
+ try {
+ await createDataPurpose(values).unwrap();
+ message.success(`Data purpose "${values.name}" created`);
+ router.push(DATA_PURPOSES_ROUTE);
+ } catch (error) {
+ message.error(getErrorMessage(error));
+ }
+ };
+
+ return (
+
+
+
+
+ );
+};
+```
+
+**`pages/data-purposes/[fidesKey].tsx`** (edit):
+```tsx
+const EditDataPurposePage: NextPage = () => {
+ const router = useRouter();
+ const { fidesKey } = router.query;
+ const { data: purpose, isLoading, error } = useGetDataPurposeByKeyQuery(
+ fidesKey as string,
+ { skip: !fidesKey }
+ );
+ const [updateDataPurpose] = useUpdateDataPurposeMutation();
+
+ if (error) {
+ return ;
+ }
+
+ const handleSubmit = async (values: DataPurposeUpdate) => {
+ try {
+ await updateDataPurpose({ fidesKey: fidesKey as string, ...values }).unwrap();
+ message.success(`Data purpose "${values.name}" updated`);
+ router.push(DATA_PURPOSES_ROUTE);
+ } catch (error) {
+ message.error(getErrorMessage(error));
+ }
+ };
+
+ return (
+
+
+ {isLoading ? : }
+
+ );
+};
+```
+
+#### 4. Table Hook
+
+**`useDataPurposesTable.tsx`**:
+```tsx
+const useDataPurposesTable = () => {
+ const tableState = useTableState({
+ pagination: { defaultPageSize: 25, pageSizeOptions: [25, 50, 100] },
+ search: { defaultSearchQuery: "" },
+ });
+
+ const { pageIndex, pageSize, searchQuery, updateSearch } = tableState;
+ const { data, error, isLoading, isFetching } = useGetAllDataPurposesQuery({
+ page: pageIndex,
+ size: pageSize,
+ search: searchQuery,
+ });
+
+ const columns: ColumnsType = useMemo(
+ () => [
+ {
+ title: "Name",
+ dataIndex: "name",
+ render: (_, record) => (
+
+ {record.name}
+
+ ),
+ },
+ { title: "Key", dataIndex: "fides_key" },
+ {
+ title: "Data use",
+ dataIndex: "data_use",
+ render: (value: string) => {value},
+ },
+ {
+ title: "Categories",
+ dataIndex: "data_categories",
+ render: (categories: string[]) =>
+ categories.length > 0 ? (
+ {categories.map((c) => {c})}
+ ) : (
+ "N/A"
+ ),
+ },
+ {
+ title: "Actions",
+ render: (_, record) => ,
+ },
+ ],
+ []
+ );
+
+ const tableProps = useAntTable({ tableState, data });
+
+ return { tableProps, columns, error, isLoading, isFetching, searchQuery, updateSearch };
+};
+```
+
+#### 5. Delete Modal
+
+**`DeleteDataPurposeModal.tsx`**:
+```tsx
+interface DeleteDataPurposeModalProps {
+ purpose: DataPurposeResponse;
+}
+
+const DeleteDataPurposeModal = ({ purpose }: DeleteDataPurposeModalProps) => {
+ const [modal, contextHolder] = Modal.useModal();
+ const [deleteDataPurpose] = useDeleteDataPurposeMutation();
+ const router = useRouter();
+
+ const handleDelete = () => {
+ modal.confirm({
+ title: "Delete data purpose?",
+ content: `This will permanently delete "${purpose.name}". This action cannot be undone.`,
+ okText: "Delete",
+ okButtonProps: { danger: true },
+ centered: true,
+ onOk: async () => {
+ try {
+ await deleteDataPurpose({ fidesKey: purpose.fides_key, force: true }).unwrap();
+ message.success(`Data purpose "${purpose.name}" deleted`);
+ router.push(DATA_PURPOSES_ROUTE);
+ } catch (error) {
+ message.error(getErrorMessage(error));
+ }
+ },
+ });
+ };
+
+ return (
+ <>
+ {contextHolder}
+
+ >
+ );
+};
+```
+
+#### 6. Data Purpose Form
+
+Fields:
+- **Name** (required, Ant ``)
+- **Key** (required, auto-generated from name if blank, disabled on edit, Ant ``)
+- **Data use** (required, Ant `