diff --git a/CHANGELOG.md b/CHANGELOG.md
index 885648389c5..315869e52d8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -22,11 +22,11 @@ Changes can also be flagged with a GitHub label for tracking purposes. The URL o
## [Unreleased](https://github.com/ethyca/fides/compare/2.62.0...main)
### Added
+- Added ability to add internal comments to privacy requests [#6165](https://github.com/ethyca/fides/pull/6165)
- Attachments can now be stored with GCS [#6161](https://github.com/ethyca/fides/pull/6161)
- Attachments can now retrieve their content as well as their download urls [#6169 ](https://github.com/ethyca/fides/pull/6169)
-
## [2.62.0](https://github.com/ethyca/fides/compare/2.61.1...2.62.0)
### Added
diff --git a/clients/admin-ui/cypress/e2e/privacy-requests.cy.ts b/clients/admin-ui/cypress/e2e/privacy-requests.cy.ts
index c0d90e5ae7e..a722bce8af1 100644
--- a/clients/admin-ui/cypress/e2e/privacy-requests.cy.ts
+++ b/clients/admin-ui/cypress/e2e/privacy-requests.cy.ts
@@ -512,4 +512,125 @@ describe("Privacy Requests", () => {
);
});
});
+
+ /**
+ * Tests for privacy request comments functionality
+ */
+ describe("Request Comments", () => {
+ beforeEach(() => {
+ cy.assumeRole(RoleRegistryEnum.OWNER);
+
+ cy.intercept("GET", "/api/v1/plus/privacy-request/*/comment*", {
+ statusCode: 200,
+ fixture: "privacy-requests/comments/comments-list.json",
+ }).as("getComments");
+
+ cy.intercept("POST", "/api/v1/plus/privacy-request/*/comment", {
+ statusCode: 200,
+ fixture: "privacy-requests/comments/comment-created.json",
+ }).as("createComment");
+
+ cy.intercept("GET", "/api/v1/privacy-request*", {
+ fixture: "privacy-requests/with-logs.json",
+ }).as("getPrivacyRequestWithLogs");
+
+ cy.visit("/privacy-requests/pri_96bb91d3-cdb9-46c3-9546-0c276eb05a5c");
+ cy.wait("@getPrivacyRequestWithLogs");
+ cy.wait("@getComments");
+ });
+
+ it("displays existing comments in the activity timeline", () => {
+ cy.getByTestId("activity-timeline-item")
+ .contains("This is a test comment")
+ .should("exist");
+ cy.contains("Test User:").should("exist");
+ cy.getByTestId("activity-timeline-type")
+ .contains("Internal comment")
+ .should("exist");
+ });
+
+ it("allows creating a new comment", () => {
+ cy.getByTestId("add-comment-button").click();
+ cy.getByTestId("comment-input").should("exist");
+ cy.getByTestId("comment-input").type("New comment from test");
+ cy.getByTestId("submit-comment-button").click();
+
+ // Check that the request was made with the correct form data
+ cy.wait("@createComment").then((interception) => {
+ const requestBody = interception.request.body;
+ expect(requestBody).to.include('name="comment_text"');
+ expect(requestBody).to.include("New comment from test");
+ expect(requestBody).to.include('name="comment_type"');
+ expect(requestBody).to.include("note");
+ });
+ });
+
+ it("allows canceling comment creation", () => {
+ cy.getByTestId("add-comment-button").click();
+ cy.getByTestId("comment-input").should("exist");
+ cy.getByTestId("comment-input").type("Comment that will be canceled");
+ cy.getByTestId("cancel-comment-button").click();
+ cy.getByTestId("comment-input").should("not.exist");
+ });
+
+ it("shows loading state while fetching comments", () => {
+ cy.intercept("GET", "/api/v1/plus/privacy-request/*/comment*", {
+ statusCode: 200,
+ fixture: "privacy-requests/comments/empty-comments.json",
+ delay: 1000,
+ }).as("getCommentsDelayed");
+
+ cy.visit("/privacy-requests/pri_96bb91d3-cdb9-46c3-9546-0c276eb05a5c");
+
+ // Check for skeleton loading state before comments load
+ cy.getByTestId("timeline-skeleton").should("exist");
+
+ cy.wait("@getCommentsDelayed");
+
+ // Verify skeletons are gone after loading
+ cy.getByTestId("timeline-skeleton").should("not.exist");
+ });
+
+ it("restricts comment functionality based on user permissions", () => {
+ // First check with a viewer role (has comment:read but not comment:create)
+ cy.assumeRole(RoleRegistryEnum.VIEWER);
+ cy.reload();
+ cy.wait("@getPrivacyRequestWithLogs");
+ cy.wait("@getComments");
+
+ // Button should be hidden for users without COMMENT_CREATE scope
+ cy.getByTestId("add-comment-button").should("not.exist");
+
+ // Then check with an owner role (has comment:create scope)
+ cy.assumeRole(RoleRegistryEnum.OWNER);
+ cy.reload();
+ cy.wait("@getPrivacyRequestWithLogs");
+ cy.wait("@getComments");
+
+ // Button should be visible for users with COMMENT_CREATE scope
+ cy.getByTestId("add-comment-button").should("exist");
+ });
+
+ it("handles 404 errors from comments API gracefully", () => {
+ // Intercept comments API and return a 404 error
+ cy.intercept("GET", "/api/v1/plus/privacy-request/*/comment*", {
+ statusCode: 404,
+ body: {
+ detail: "Not found",
+ },
+ }).as("commentsNotFound");
+
+ // Load the page
+ cy.visit("/privacy-requests/pri_96bb91d3-cdb9-46c3-9546-0c276eb05a5c");
+ cy.wait("@getPrivacyRequestWithLogs");
+ cy.wait("@commentsNotFound");
+
+ // Verify the timeline still shows request updates even if comments failed to load
+ cy.getByTestId("activity-timeline-list").should("exist");
+ cy.getByTestId("activity-timeline-item").should("exist");
+
+ // The Add comment button should still be available
+ cy.getByTestId("add-comment-button").should("exist");
+ });
+ });
});
diff --git a/clients/admin-ui/cypress/fixtures/privacy-requests/comments/comment-created.json b/clients/admin-ui/cypress/fixtures/privacy-requests/comments/comment-created.json
new file mode 100644
index 00000000000..9e4be199a23
--- /dev/null
+++ b/clients/admin-ui/cypress/fixtures/privacy-requests/comments/comment-created.json
@@ -0,0 +1,11 @@
+{
+ "id": "comment_456",
+ "privacy_request_id": "pri_123",
+ "comment_text": "New comment from test",
+ "comment_type": "NOTE",
+ "created_at": "2023-01-02T12:00:00Z",
+ "user_id": "usr_123",
+ "username": "testuser",
+ "user_first_name": "Test",
+ "user_last_name": "User"
+}
diff --git a/clients/admin-ui/cypress/fixtures/privacy-requests/comments/comments-list.json b/clients/admin-ui/cypress/fixtures/privacy-requests/comments/comments-list.json
new file mode 100644
index 00000000000..ec0356b8301
--- /dev/null
+++ b/clients/admin-ui/cypress/fixtures/privacy-requests/comments/comments-list.json
@@ -0,0 +1,18 @@
+{
+ "items": [
+ {
+ "id": "comment_123",
+ "privacy_request_id": "pri_123",
+ "comment_text": "This is a test comment",
+ "comment_type": "NOTE",
+ "created_at": "2023-01-01T12:00:00Z",
+ "user_id": "usr_123",
+ "username": "testuser",
+ "user_first_name": "Test",
+ "user_last_name": "User"
+ }
+ ],
+ "total": 1,
+ "page": 1,
+ "size": 10
+}
diff --git a/clients/admin-ui/cypress/fixtures/privacy-requests/comments/empty-comments.json b/clients/admin-ui/cypress/fixtures/privacy-requests/comments/empty-comments.json
new file mode 100644
index 00000000000..871c13aedaf
--- /dev/null
+++ b/clients/admin-ui/cypress/fixtures/privacy-requests/comments/empty-comments.json
@@ -0,0 +1,6 @@
+{
+ "items": [],
+ "total": 0,
+ "page": 1,
+ "size": 10
+}
diff --git a/clients/admin-ui/cypress/fixtures/scopes/roles-to-scopes.json b/clients/admin-ui/cypress/fixtures/scopes/roles-to-scopes.json
index 7bcb3d301b8..57abd508030 100644
--- a/clients/admin-ui/cypress/fixtures/scopes/roles-to-scopes.json
+++ b/clients/admin-ui/cypress/fixtures/scopes/roles-to-scopes.json
@@ -18,6 +18,8 @@
"client:delete",
"client:read",
"client:update",
+ "comment:create",
+ "comment:read",
"config:read",
"config:update",
"connection:authorize",
@@ -157,6 +159,7 @@
"classify_instance:read",
"cli-objects:read",
"client:read",
+ "comment:read",
"connection:read",
"connection_type:read",
"consent:read",
@@ -201,6 +204,7 @@
"classify_instance:read",
"cli-objects:read",
"client:read",
+ "comment:read",
"connection:read",
"connection_type:read",
"consent:read",
@@ -237,6 +241,8 @@
"webhook:read"
],
"approver": [
+ "comment:create",
+ "comment:read",
"privacy-request:read",
"privacy-request:resume",
"privacy-request:review",
@@ -259,6 +265,8 @@
"client:delete",
"client:read",
"client:update",
+ "comment:create",
+ "comment:read",
"config:read",
"config:update",
"connection:authorize",
diff --git a/clients/admin-ui/src/features/common/ClipboardButton.tsx b/clients/admin-ui/src/features/common/ClipboardButton.tsx
index 54920fa3a32..f4a52992a41 100644
--- a/clients/admin-ui/src/features/common/ClipboardButton.tsx
+++ b/clients/admin-ui/src/features/common/ClipboardButton.tsx
@@ -2,12 +2,11 @@ import {
AntButton as Button,
AntButtonProps as ButtonProps,
AntTooltip as Tooltip,
+ Icons,
useClipboard,
} from "fidesui";
import React, { useState } from "react";
-import { CopyIcon } from "./Icon";
-
enum TooltipText {
COPY = "Copy",
COPIED = "Copied!",
@@ -51,7 +50,7 @@ const ClipboardButton = ({ copyText, ...props }: ClipboardButtonProps) => {
}}
>
}
+ icon={}
aria-label="copy"
type="text"
data-testid="clipboard-btn"
diff --git a/clients/admin-ui/src/features/common/api.slice.ts b/clients/admin-ui/src/features/common/api.slice.ts
index bd513ec75b3..00d41823631 100644
--- a/clients/admin-ui/src/features/common/api.slice.ts
+++ b/clients/admin-ui/src/features/common/api.slice.ts
@@ -60,6 +60,7 @@ export const baseApi = createApi({
"Privacy Notices",
"Privacy Notice Translations",
"Privacy Request Attachments",
+ "Privacy Request Comments",
"Property",
"Property-Specific Messaging Templates",
"Purpose",
diff --git a/clients/admin-ui/src/features/common/utils.ts b/clients/admin-ui/src/features/common/utils.ts
index 4b88bc02ace..966fda9d525 100644
--- a/clients/admin-ui/src/features/common/utils.ts
+++ b/clients/admin-ui/src/features/common/utils.ts
@@ -32,7 +32,7 @@ export const debounce = (fn: (props?: any) => void, ms = 0) => {
};
export const formatDate = (value: string | number | Date): string =>
- format(new Date(value), "MMMM d, y, KK:mm:ss z");
+ format(new Date(value), "MMMM d, y, KK:mm:ss aaa z");
export const utf8ToB64 = (str: string): string =>
window.btoa(unescape(encodeURIComponent(str)));
diff --git a/clients/admin-ui/src/features/privacy-requests/PrivacyRequest.tsx b/clients/admin-ui/src/features/privacy-requests/PrivacyRequest.tsx
index 48b431f0743..933584c3e30 100644
--- a/clients/admin-ui/src/features/privacy-requests/PrivacyRequest.tsx
+++ b/clients/admin-ui/src/features/privacy-requests/PrivacyRequest.tsx
@@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
import { useGetAllPrivacyRequestsQuery } from "~/features/privacy-requests";
import { PrivacyRequestStatus } from "~/types/api";
-import ActivityTimeline from "./events-and-logs/ActivityTimeline";
+import ActivityTab from "./events-and-logs/ActivityTab";
import ManualProcessingList from "./manual-processing/ManualProcessingList";
import RequestDetails from "./RequestDetails";
import { PrivacyRequestEntity } from "./types";
@@ -46,7 +46,7 @@ const PrivacyRequest = ({ data: initialData }: PrivacyRequestProps) => {
{
key: "activity",
label: "Activity",
- children: ,
+ children: ,
},
{
key: "manual-steps",
diff --git a/clients/admin-ui/src/features/privacy-requests/RequestDetails.tsx b/clients/admin-ui/src/features/privacy-requests/RequestDetails.tsx
index 9d5c26501bf..fd99ce9760c 100644
--- a/clients/admin-ui/src/features/privacy-requests/RequestDetails.tsx
+++ b/clients/admin-ui/src/features/privacy-requests/RequestDetails.tsx
@@ -1,12 +1,13 @@
import {
+ AntButton as Button,
AntFlex as Flex,
AntForm as Form,
AntInput as Input,
+ AntSpace as Space,
AntTooltip as Tooltip,
AntTypography as Typography,
} from "fidesui";
-import ClipboardButton from "~/features/common/ClipboardButton";
import DaysLeftTag from "~/features/common/DaysLeftTag";
import { useFeatures } from "~/features/common/features";
import RequestStatusBadge from "~/features/common/RequestStatusBadge";
@@ -14,6 +15,7 @@ import RequestType from "~/features/common/RequestType";
import { PrivacyRequestEntity } from "~/features/privacy-requests/types";
import { PrivacyRequestStatus as ApiPrivacyRequestStatus } from "~/types/api/models/PrivacyRequestStatus";
+import ClipboardButton from "../common/ClipboardButton";
import RequestAttachments from "./attachments/RequestAttachments";
import RequestCustomFields from "./RequestCustomFields";
import RequestDetailsRow from "./RequestDetailsRow";
@@ -79,8 +81,14 @@ const RequestDetails = ({ subjectRequest }: RequestDetailsProps) => {
-
-
+
+
+ } />
+
diff --git a/clients/admin-ui/src/features/privacy-requests/comments/privacy-request-comments.slice.ts b/clients/admin-ui/src/features/privacy-requests/comments/privacy-request-comments.slice.ts
new file mode 100644
index 00000000000..dbf0c98c73b
--- /dev/null
+++ b/clients/admin-ui/src/features/privacy-requests/comments/privacy-request-comments.slice.ts
@@ -0,0 +1,77 @@
+import { createSlice } from "@reduxjs/toolkit";
+
+import { baseApi } from "~/features/common/api.slice";
+import { CommentResponse } from "~/types/api/models/CommentResponse";
+import { CommentType } from "~/types/api/models/CommentType";
+import { Page_CommentResponse_ } from "~/types/api/models/Page_CommentResponse_";
+
+export interface State {}
+
+const initialState: State = {};
+
+interface GetCommentsParams {
+ privacy_request_id: string;
+ page?: number;
+ size?: number;
+}
+
+interface CreateCommentParams {
+ privacy_request_id: string;
+ comment_text: string;
+ comment_type: CommentType;
+}
+
+const privacyRequestCommentsApi = baseApi.injectEndpoints({
+ endpoints: (build) => ({
+ getComments: build.query({
+ query: ({ privacy_request_id, page = 1, size }) => ({
+ url: `plus/privacy-request/${privacy_request_id}/comment`,
+ method: "GET",
+ params: {
+ page,
+ size,
+ },
+ }),
+ providesTags: ["Privacy Request Comments"],
+ }),
+ createComment: build.mutation({
+ query: ({ privacy_request_id, comment_text, comment_type }) => {
+ const formData = new FormData();
+ formData.append("comment_text", comment_text);
+ formData.append("comment_type", comment_type);
+
+ return {
+ url: `plus/privacy-request/${privacy_request_id}/comment`,
+ method: "POST",
+ body: formData,
+ formData: true,
+ };
+ },
+ invalidatesTags: ["Privacy Request Comments", "Request"],
+ }),
+ getCommentDetails: build.query<
+ CommentResponse,
+ { privacy_request_id: string; comment_id: string }
+ >({
+ query: ({ privacy_request_id, comment_id }) => ({
+ url: `plus/privacy-request/${privacy_request_id}/comment/${comment_id}`,
+ method: "GET",
+ }),
+ }),
+ }),
+});
+
+export const {
+ useGetCommentsQuery,
+ useCreateCommentMutation,
+ useGetCommentDetailsQuery,
+ useLazyGetCommentDetailsQuery,
+} = privacyRequestCommentsApi;
+
+export const privacyRequestCommentsSlice = createSlice({
+ name: "privacyRequestComments",
+ initialState,
+ reducers: {},
+});
+
+export const { reducer } = privacyRequestCommentsSlice;
diff --git a/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTab.tsx b/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTab.tsx
new file mode 100644
index 00000000000..e2590008436
--- /dev/null
+++ b/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTab.tsx
@@ -0,0 +1,45 @@
+import { AntButton as Button, AntFlex as Flex } from "fidesui";
+import { useState } from "react";
+
+import Restrict from "~/features/common/Restrict";
+import { ScopeRegistryEnum } from "~/types/api";
+
+import { PrivacyRequestEntity } from "../types";
+import ActivityTimeline from "./ActivityTimeline";
+import { CommentInput } from "./CommentInput";
+
+type ActivityTabProps = {
+ subjectRequest: PrivacyRequestEntity;
+};
+
+const ActivityTab = ({ subjectRequest }: ActivityTabProps) => {
+ const [showCommentInput, setShowCommentInput] = useState(false);
+
+ return (
+
+
+
+ {showCommentInput ? (
+
setShowCommentInput(false)}
+ />
+ ) : (
+
+
+
+
+
+ )}
+
+ );
+};
+
+export default ActivityTab;
diff --git a/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTimeline.tsx b/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTimeline.tsx
index d9dca7f90d0..1c87685ae84 100644
--- a/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTimeline.tsx
+++ b/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTimeline.tsx
@@ -1,12 +1,21 @@
-import { Box, useDisclosure } from "fidesui";
import {
+ AntList as List,
+ AntSkeleton as Skeleton,
+ Box,
+ useDisclosure,
+} from "fidesui";
+import React, { useCallback, useEffect, useMemo, useState } from "react";
+
+import {
+ ActivityTimelineItemTypeEnum,
ExecutionLog,
ExecutionLogStatus,
PrivacyRequestEntity,
-} from "privacy-requests/types";
-import React, { useEffect, useState } from "react";
+} from "~/features/privacy-requests/types";
-import ActivityTimelineList from "./ActivityTimelineList";
+import ActivityTimelineEntry from "./ActivityTimelineEntry";
+import styles from "./ActivityTimelineEntry.module.scss";
+import { usePrivacyRequestComments, usePrivacyRequestEventLogs } from "./hooks";
import LogDrawer from "./LogDrawer";
type ActivityTimelineProps = {
@@ -23,9 +32,15 @@ const ActivityTimeline = ({ subjectRequest }: ActivityTimelineProps) => {
ExecutionLogStatus.ERROR,
);
- const { results } = subjectRequest;
+ const { results, id: privacyRequestId } = subjectRequest;
+
+ const { commentItems, isLoading: isCommentsLoading } =
+ usePrivacyRequestComments(privacyRequestId);
+ const { eventItems, isLoading: isResultsLoading } =
+ usePrivacyRequestEventLogs(results);
+
+ const isLoading = isCommentsLoading || isResultsLoading;
- // Update currentLogs when results change and we have a selected key
useEffect(() => {
if (currentKey && results && results[currentKey]) {
setCurrentLogs(results[currentKey]);
@@ -52,18 +67,77 @@ const ActivityTimeline = ({ subjectRequest }: ActivityTimelineProps) => {
onClose();
};
- const showLogs = (key: string, logs: ExecutionLog[]) => {
- setCurrentKey(key);
- setCurrentLogs(logs);
- onOpen();
- };
+ const showLogs = useCallback(
+ (key: string, logs: ExecutionLog[]) => {
+ setCurrentKey(key);
+ setCurrentLogs(logs);
+ onOpen();
+ },
+ [onOpen],
+ );
+
+ const timelineItems = useMemo(() => {
+ const eventItemsWithClickHandler = eventItems.map((item) => {
+ if (item.type === "Request update" && item.title && results) {
+ const key = item.title;
+ if (results[key]) {
+ return {
+ ...item,
+ onClick: () => showLogs(key, results[key]),
+ };
+ }
+ }
+ return item;
+ });
+
+ // Create initial access request item
+ const initialRequestItem = {
+ author: "Fides",
+ title: "Access request received",
+ date: new Date(subjectRequest.created_at),
+ type: ActivityTimelineItemTypeEnum.REQUEST_UPDATE,
+ showViewLog: false,
+ isError: false,
+ isSkipped: false,
+ id: "initial-request",
+ };
+
+ const allItems = [
+ initialRequestItem,
+ ...eventItemsWithClickHandler,
+ ...commentItems,
+ ];
+
+ // Sort by date (oldest first)
+ return allItems.sort((a, b) => {
+ return new Date(a.date).getTime() - new Date(b.date).getTime();
+ });
+ }, [eventItems, commentItems, results, showLogs, subjectRequest.created_at]);
+
+ const renderSkeletonItems = () => (
+
+
+
+ );
return (
- showLogs(key, logs)}
- />
+
+
+ {isLoading
+ ? renderSkeletonItems()
+ : timelineItems.map((item) => (
+ -
+
+
+ ))}
+
+
{
- const [isOpen, setIsOpen] = useState(false);
-
- return (
-
-
- {content &&
{content}}
-
- {logs}
+const ActivityTimelineEntry = ({ item }: ActivityTimelineEntryProps) => {
+ const {
+ author,
+ title,
+ date,
+ type,
+ onClick,
+ isError,
+ isSkipped,
+ description,
+ } = item;
+
+ // Format the date for display
+ const formattedDate = formatDate(date);
+
+ const isClickable = !!onClick;
+
+ const content = (
+ <>
+
+
+ {author}:
+
+ {title && (
+
+ {title}
+ {isError && " failed"}
+
+ )}
+
+ {formattedDate}
+
+
+ {type}
+
+ {(isError || isSkipped) && (
+
+ View Log
+
+ )}
-
+ {description && (
+
+
+ {description}
+
+
+ )}
+ >
+ );
+
+ const commonProps = {
+ className: classNames(styles.itemButton, {
+ [styles["itemButton--error"]]: isError,
+ [styles["itemButton--clickable"]]: isClickable,
+ [styles["itemButton--comment"]]:
+ type === ActivityTimelineItemTypeEnum.INTERNAL_COMMENT,
+ }),
+ "data-testid": "activity-timeline-item",
+ };
+
+ // Render a button for clickable items, or a div for non-clickable items
+ // This maintains the same styling and data-testid attributes while changing the HTML element
+ return isClickable ? (
+
+ ) : (
+
{content}
);
};
+
export default ActivityTimelineEntry;
diff --git a/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTimelineList.module.scss b/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTimelineList.module.scss
deleted file mode 100644
index 5757ecbe1ed..00000000000
--- a/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTimelineList.module.scss
+++ /dev/null
@@ -1,68 +0,0 @@
-$border-width: 1px;
-$horizontal-padding: 20px;
-
-.itemButton {
- display: block;
- width: 100%;
- border: $border-width solid transparent;
-
- border-radius: 6px;
- transition: border-color 0.2s ease-in-out;
- margin-bottom: 20px;
- padding: 12px $horizontal-padding;
-
- &:hover,
- &--error {
- border-color: var(--fidesui-neutral-100);
- }
-
- &:focus-visible {
- border-color: var(--fidesui-neutral-700);
- }
-
- &--error,
- &--error:hover,
- &--error:focus {
- border-left: 8px solid var(--fidesui-error);
- }
-}
-
-.header {
- cursor: pointer;
- width: 100%;
-
- display: flex;
- flex-wrap: wrap;
- gap: 8px;
- align-items: center;
-}
-
-.title {
- font-weight: 600;
-
- &--error {
- color: var(--fidesui-error);
- }
-}
-
-.timestamp {
- color: var(--fidesui-neutral-700);
-}
-
-.viewLogs {
- color: var(--fidesui-link);
-}
-
-.logs {
- height: 0;
- overflow: hidden;
- transition: height 0.2s ease-in-out;
- box-sizing: border-box;
- padding: 0 $horizontal-padding;
-
- &--open {
- height: auto;
- margin-top: 20px;
- margin-bottom: 20px;
- }
-}
diff --git a/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTimelineList.tsx b/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTimelineList.tsx
deleted file mode 100644
index ceb7c2e431b..00000000000
--- a/clients/admin-ui/src/features/privacy-requests/events-and-logs/ActivityTimelineList.tsx
+++ /dev/null
@@ -1,100 +0,0 @@
-import classNames from "classnames";
-import { AntList as List, AntTag as Tag } from "fidesui";
-import { map } from "lodash";
-import { useCallback } from "react";
-
-import { formatDate } from "~/features/common/utils";
-import { ExecutionLogStatus } from "~/types/api";
-
-import { ExecutionLog, PrivacyRequestResults } from "../types";
-import styles from "./ActivityTimelineList.module.scss";
-
-interface ActivityTimelineItem {
- // eslint-disable-next-line react/no-unused-prop-types
- logs: ExecutionLog[];
- // eslint-disable-next-line react/no-unused-prop-types
- key: string;
-}
-
-interface ActivityTimelineProps {
- results?: PrivacyRequestResults;
- onItemClicked: ({ key, logs }: ActivityTimelineItem) => void;
-}
-
-const ActivityTimelineList = ({
- results,
- onItemClicked,
-}: ActivityTimelineProps) => {
- const items: ActivityTimelineItem[] = map(results, (logs, key) => ({
- logs,
- key,
- }));
-
- const renderItem = useCallback(
- ({ logs, key }: ActivityTimelineItem) => {
- const hasUnresolvedError = logs.some(
- (log) => log.status === ExecutionLogStatus.ERROR,
- );
- const hasSkippedEntry = logs.some(
- (log) => log.status === ExecutionLogStatus.SKIPPED,
- );
-
- return (
-
- );
- },
- [onItemClicked],
- );
-
- return (
-
- {items.map(renderItem)}
-
- );
-};
-
-export default ActivityTimelineList;
diff --git a/clients/admin-ui/src/features/privacy-requests/events-and-logs/CommentInput.tsx b/clients/admin-ui/src/features/privacy-requests/events-and-logs/CommentInput.tsx
new file mode 100644
index 00000000000..33f39eb53ee
--- /dev/null
+++ b/clients/admin-ui/src/features/privacy-requests/events-and-logs/CommentInput.tsx
@@ -0,0 +1,96 @@
+import {
+ AntButton as Button,
+ AntFlex as Flex,
+ AntInput as Input,
+ AntMessage as message,
+ AntTabs as Tabs,
+ AntTabsProps as TabsProps,
+} from "fidesui";
+import { useEffect, useRef, useState } from "react";
+
+import { CommentType } from "~/types/api/models/CommentType";
+
+import { useCreateCommentMutation } from "../comments/privacy-request-comments.slice";
+
+export interface CommentInputProps {
+ privacyRequestId: string;
+ onCancel: () => void;
+}
+
+export const CommentInput = ({
+ privacyRequestId,
+ onCancel,
+}: CommentInputProps) => {
+ const [commentText, setCommentText] = useState("");
+ const textAreaRef = useRef
(null);
+ const [createComment, { isLoading }] = useCreateCommentMutation();
+
+ // Focus the textarea when the component mounts
+ useEffect(() => {
+ if (textAreaRef.current) {
+ textAreaRef.current.focus();
+ }
+ }, []);
+
+ const handleSubmit = async () => {
+ if (commentText.trim()) {
+ try {
+ await createComment({
+ privacy_request_id: privacyRequestId,
+ comment_text: commentText,
+ comment_type: CommentType.NOTE,
+ }).unwrap();
+
+ // Reset and close
+ setCommentText("");
+ onCancel();
+ } catch (error) {
+ // eslint-disable-next-line no-console
+ console.error("Failed to add comment:", error);
+ message.error({
+ content: "Failed to add comment",
+ duration: 5,
+ });
+ }
+ }
+ };
+
+ const items: TabsProps["items"] = [
+ {
+ key: "internal",
+ label: "Internal comment",
+ children: (
+ setCommentText(e.target.value)}
+ rows={3}
+ className="mb-3 h-[150px] w-full !resize-none"
+ data-testid="comment-input"
+ />
+ ),
+ },
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+ );
+};
diff --git a/clients/admin-ui/src/features/privacy-requests/events-and-logs/hooks/index.ts b/clients/admin-ui/src/features/privacy-requests/events-and-logs/hooks/index.ts
new file mode 100644
index 00000000000..c878eedfdaa
--- /dev/null
+++ b/clients/admin-ui/src/features/privacy-requests/events-and-logs/hooks/index.ts
@@ -0,0 +1,2 @@
+export * from "./usePrivacyRequestComments";
+export * from "./usePrivacyRequestEventLogs";
diff --git a/clients/admin-ui/src/features/privacy-requests/events-and-logs/hooks/usePrivacyRequestComments.ts b/clients/admin-ui/src/features/privacy-requests/events-and-logs/hooks/usePrivacyRequestComments.ts
new file mode 100644
index 00000000000..5ae0442887e
--- /dev/null
+++ b/clients/admin-ui/src/features/privacy-requests/events-and-logs/hooks/usePrivacyRequestComments.ts
@@ -0,0 +1,57 @@
+import { AntMessage as message } from "fidesui";
+import { useEffect } from "react";
+
+import { useGetCommentsQuery } from "~/features/privacy-requests/comments/privacy-request-comments.slice";
+import {
+ ActivityTimelineItem,
+ ActivityTimelineItemTypeEnum,
+} from "~/features/privacy-requests/types";
+import { CommentResponse } from "~/types/api/models/CommentResponse";
+
+/**
+ * Hook for fetching and processing privacy request comments
+ */
+export const usePrivacyRequestComments = (privacyRequestId: string) => {
+ // Fetch comments data for this privacy request
+ const {
+ data: commentsData,
+ isLoading,
+ error,
+ } = useGetCommentsQuery({
+ privacy_request_id: privacyRequestId,
+ size: 100, // Use a reasonable limit
+ });
+
+ // Handle error state
+ useEffect(() => {
+ if (error) {
+ message.error("Failed to fetch the request comments");
+ }
+ }, [error]);
+
+ // Map comments to ActivityTimelineItem
+ const commentItems: ActivityTimelineItem[] = !commentsData?.items
+ ? []
+ : commentsData.items.map((comment: CommentResponse) => {
+ const author =
+ comment.user_first_name && comment.user_last_name
+ ? `${comment.user_first_name} ${comment.user_last_name}`
+ : comment.username || "Unknown";
+
+ return {
+ author,
+ date: new Date(comment.created_at),
+ type: ActivityTimelineItemTypeEnum.INTERNAL_COMMENT,
+ showViewLog: false,
+ description: comment.comment_text,
+ isError: false,
+ isSkipped: false,
+ id: `comment-${comment.id}`,
+ };
+ });
+
+ return {
+ commentItems,
+ isLoading,
+ };
+};
diff --git a/clients/admin-ui/src/features/privacy-requests/events-and-logs/hooks/usePrivacyRequestEventLogs.ts b/clients/admin-ui/src/features/privacy-requests/events-and-logs/hooks/usePrivacyRequestEventLogs.ts
new file mode 100644
index 00000000000..974a606a411
--- /dev/null
+++ b/clients/admin-ui/src/features/privacy-requests/events-and-logs/hooks/usePrivacyRequestEventLogs.ts
@@ -0,0 +1,43 @@
+import {
+ ActivityTimelineItem,
+ ActivityTimelineItemTypeEnum,
+ ExecutionLogStatus,
+ PrivacyRequestResults,
+} from "~/features/privacy-requests/types";
+
+/**
+ * Hook for processing privacy request event logs
+ */
+export const usePrivacyRequestEventLogs = (results?: PrivacyRequestResults) => {
+ // Determine if results are loading
+ const isLoading = !results;
+
+ // Map from source events to ActivityTimelineItems
+ const eventItems: ActivityTimelineItem[] = !results
+ ? []
+ : Object.entries(results).map(([key, logs]) => {
+ const hasUnresolvedError = logs.some(
+ (log) => log.status === ExecutionLogStatus.ERROR,
+ );
+ const hasSkippedEntry = logs.some(
+ (log) => log.status === ExecutionLogStatus.SKIPPED,
+ );
+
+ return {
+ author: "Fides",
+ title: key,
+ date: new Date(logs[0].updated_at),
+ type: ActivityTimelineItemTypeEnum.REQUEST_UPDATE,
+ showViewLog: hasUnresolvedError || hasSkippedEntry,
+ onClick: () => {}, // This will be overridden in the component
+ isError: hasUnresolvedError,
+ isSkipped: hasSkippedEntry,
+ id: `request-${key}`,
+ };
+ });
+
+ return {
+ eventItems,
+ isLoading,
+ };
+};
diff --git a/clients/admin-ui/src/features/privacy-requests/types.ts b/clients/admin-ui/src/features/privacy-requests/types.ts
index 953c63a5f64..11168143e74 100644
--- a/clients/admin-ui/src/features/privacy-requests/types.ts
+++ b/clients/admin-ui/src/features/privacy-requests/types.ts
@@ -207,3 +207,29 @@ export interface ConfigMessagingSecretsRequest {
twilio_sender_phone_number?: string;
};
}
+
+export enum ActivityTimelineItemTypeEnum {
+ REQUEST_UPDATE = "Request update",
+ INTERNAL_COMMENT = "Internal comment",
+}
+
+export const TimelineItemColorMap: Record<
+ ActivityTimelineItemTypeEnum,
+ string
+> = {
+ [ActivityTimelineItemTypeEnum.REQUEST_UPDATE]: "sandstone",
+ [ActivityTimelineItemTypeEnum.INTERNAL_COMMENT]: "marble",
+};
+
+export interface ActivityTimelineItem {
+ author: string;
+ title?: string;
+ date: Date;
+ type: ActivityTimelineItemTypeEnum;
+ showViewLog: boolean;
+ onClick?: () => void;
+ description?: string;
+ isError: boolean;
+ isSkipped: boolean;
+ id: string;
+}
diff --git a/clients/admin-ui/src/types/api/index.ts b/clients/admin-ui/src/types/api/index.ts
index dbe1240d7ff..415dabad83e 100644
--- a/clients/admin-ui/src/types/api/index.ts
+++ b/clients/admin-ui/src/types/api/index.ts
@@ -33,7 +33,7 @@ export type { BasicSystemResponse } from "./models/BasicSystemResponse";
export type { BigQueryConfig } from "./models/BigQueryConfig";
export type { BigQueryDocsSchema } from "./models/BigQueryDocsSchema";
export type { Body_acquire_access_token_api_v1_oauth_token_post } from "./models/Body_acquire_access_token_api_v1_oauth_token_post";
-export type { Body_create_privacy_request_attachment_api_v1_plus_privacy_request__privacy_request_id__attachment_post } from "./models/Body_create_privacy_request_attachment_api_v1_plus_privacy_request__privacy_request_id__attachment_post";
+export type { Body_create_comment_api_v1_plus_privacy_request__privacy_request_id__comment_post } from "./models/Body_create_comment_api_v1_plus_privacy_request__privacy_request_id__comment_post";
export type { Body_export_minimal_datamap_with_format_api_v1_plus_datamap_minimal__export_format__post } from "./models/Body_export_minimal_datamap_with_format_api_v1_plus_datamap_minimal__export_format__post";
export type { Body_upload_data_api_v1_storage__request_id__post } from "./models/Body_upload_data_api_v1_storage__request_id__post";
export type { BulkCustomFieldRequest } from "./models/BulkCustomFieldRequest";
diff --git a/clients/admin-ui/src/types/api/models/Body_create_privacy_request_attachment_api_v1_plus_privacy_request__privacy_request_id__attachment_post.ts b/clients/admin-ui/src/types/api/models/Body_create_attachment_api_v1_plus_privacy_request__privacy_request_id__attachment_post.ts
similarity index 62%
rename from clients/admin-ui/src/types/api/models/Body_create_privacy_request_attachment_api_v1_plus_privacy_request__privacy_request_id__attachment_post.ts
rename to clients/admin-ui/src/types/api/models/Body_create_attachment_api_v1_plus_privacy_request__privacy_request_id__attachment_post.ts
index 02c097ac211..bc949d18d09 100644
--- a/clients/admin-ui/src/types/api/models/Body_create_privacy_request_attachment_api_v1_plus_privacy_request__privacy_request_id__attachment_post.ts
+++ b/clients/admin-ui/src/types/api/models/Body_create_attachment_api_v1_plus_privacy_request__privacy_request_id__attachment_post.ts
@@ -4,7 +4,7 @@
import type { AttachmentType } from "./AttachmentType";
-export type Body_create_privacy_request_attachment_api_v1_plus_privacy_request__privacy_request_id__attachment_post =
+export type Body_create_attachment_api_v1_plus_privacy_request__privacy_request_id__attachment_post =
{
attachment_type: AttachmentType;
attachment_file: Blob;
diff --git a/clients/admin-ui/src/types/api/models/Body_create_privacy_request_comment_api_v1_plus_privacy_request__privacy_request_id__comment_post.ts b/clients/admin-ui/src/types/api/models/Body_create_comment_api_v1_plus_privacy_request__privacy_request_id__comment_post.ts
similarity index 73%
rename from clients/admin-ui/src/types/api/models/Body_create_privacy_request_comment_api_v1_plus_privacy_request__privacy_request_id__comment_post.ts
rename to clients/admin-ui/src/types/api/models/Body_create_comment_api_v1_plus_privacy_request__privacy_request_id__comment_post.ts
index c7abbd35b2f..2fef4454559 100644
--- a/clients/admin-ui/src/types/api/models/Body_create_privacy_request_comment_api_v1_plus_privacy_request__privacy_request_id__comment_post.ts
+++ b/clients/admin-ui/src/types/api/models/Body_create_comment_api_v1_plus_privacy_request__privacy_request_id__comment_post.ts
@@ -5,7 +5,7 @@
import type { AttachmentType } from "./AttachmentType";
import type { CommentType } from "./CommentType";
-export type Body_create_privacy_request_comment_api_v1_plus_privacy_request__privacy_request_id__comment_post =
+export type Body_create_comment_api_v1_plus_privacy_request__privacy_request_id__comment_post =
{
comment_text: string;
comment_type: CommentType;
diff --git a/clients/admin-ui/src/types/api/models/Page_CommentResponse_.ts b/clients/admin-ui/src/types/api/models/Page_CommentResponse_.ts
new file mode 100644
index 00000000000..2e5207fd995
--- /dev/null
+++ b/clients/admin-ui/src/types/api/models/Page_CommentResponse_.ts
@@ -0,0 +1,13 @@
+/* istanbul ignore file */
+/* tslint:disable */
+/* eslint-disable */
+
+import type { CommentResponse } from "./CommentResponse";
+
+export type Page_CommentResponse_ = {
+ items: Array;
+ total: number | null;
+ page: number | null;
+ size: number | null;
+ pages?: number | null;
+};
diff --git a/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts b/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts
index ef3a4959ebe..29e1d678511 100644
--- a/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts
+++ b/clients/admin-ui/src/types/api/models/ScopeRegistryEnum.ts
@@ -21,6 +21,8 @@ export enum ScopeRegistryEnum {
CLIENT_DELETE = "client:delete",
CLIENT_READ = "client:read",
CLIENT_UPDATE = "client:update",
+ COMMENT_CREATE = "comment:create",
+ COMMENT_READ = "comment:read",
CONFIG_READ = "config:read",
CONFIG_UPDATE = "config:update",
CONNECTION_AUTHORIZE = "connection:authorize",