Skip to content
Merged
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
4 changes: 4 additions & 0 deletions changelog/7515-privacy-assessments-slack-questionnaire.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
type: Added
description: Added questionnaire workflow for privacy assessments, including request input modal, questionnaire status bar, and send reminder
pr: 7515
labels: []
39 changes: 39 additions & 0 deletions clients/admin-ui/src/features/common/hooks/useRelativeTime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import dayjs from "dayjs";
import relativeTime from "dayjs/plugin/relativeTime";
import { useEffect, useState } from "react";

dayjs.extend(relativeTime);

const MINUTE_S = 60;

const getRelativeTime = (date: Date) => dayjs(date).fromNow();

/**
* Returns a human-readable relative time string (e.g. "5 minutes ago") for
* the given date, refreshing at the given interval. Returns an empty string
* when date is null or undefined.
*/
export const useRelativeTime = (
date: Date | null | undefined,
intervalSeconds: number = MINUTE_S,
): string => {
const [timeAgo, setTimeAgo] = useState(() =>
date ? getRelativeTime(date) : "",
);

useEffect(() => {
if (!date) {
setTimeAgo("");
return undefined;
}
setTimeAgo(getRelativeTime(date));
const interval = setInterval(() => {
setTimeAgo(getRelativeTime(date));
}, intervalSeconds * 1000);
return () => {
clearInterval(interval);
};
}, [date, intervalSeconds]);

return timeAgo;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Space, Tag, Tooltip } from "fidesui";

import {
ANSWER_SOURCE_LABELS,
ANSWER_SOURCE_TAG_COLORS,
ANSWER_STATUS_LABELS,
ANSWER_STATUS_TAG_COLORS,
} from "./constants";
import { AnswerStatus, AssessmentQuestion } from "./types";

interface AnswerStatusTagsProps {
question: AssessmentQuestion;
}

export const AnswerStatusTags = ({ question }: AnswerStatusTagsProps) => {
if (question.answer_status === AnswerStatus.COMPLETE) {
return (
<Space size="small">
<Tag color={ANSWER_STATUS_TAG_COLORS[question.answer_status]}>
{ANSWER_STATUS_LABELS[question.answer_status]}
</Tag>
<Tag color={ANSWER_SOURCE_TAG_COLORS[question.answer_source]}>
{ANSWER_SOURCE_LABELS[question.answer_source]}
</Tag>
</Space>
);
}

const statusTag = (
<Tag color={ANSWER_STATUS_TAG_COLORS[question.answer_status]}>
{ANSWER_STATUS_LABELS[question.answer_status]}
</Tag>
);

if (question.answer_status === AnswerStatus.PARTIAL) {
const tooltipTitle =
question.missing_data && question.missing_data.length > 0
? `This answer can be automatically derived if you populate: ${question.missing_data.join(", ")}`
: "This answer can be derived from Fides data if the relevant field is populated";

return <Tooltip title={tooltipTitle}>{statusTag}</Tooltip>;
}

return statusTag;
};
Original file line number Diff line number Diff line change
Expand Up @@ -17,16 +17,24 @@ import { useRouter } from "next/router";
import { useMemo, useState } from "react";

import { getErrorMessage } from "~/features/common/helpers";
import { useRelativeTime } from "~/features/common/hooks/useRelativeTime";
import useTaxonomies from "~/features/common/hooks/useTaxonomies";
import { PRIVACY_ASSESSMENTS_ROUTE } from "~/features/common/nav/routes";
import { RTKErrorResult } from "~/types/errors/api";

import styles from "./AssessmentDetail.module.scss";
import { useDeletePrivacyAssessmentMutation } from "./privacy-assessments.slice";
import {
useCreateQuestionnaireReminderMutation,
useDeletePrivacyAssessmentMutation,
useGetAssessmentConfigQuery,
} from "./privacy-assessments.slice";
import { QuestionCard } from "./QuestionCard";
import { QuestionGroupPanel } from "./QuestionGroupPanel";
import { QuestionnaireStatusBar } from "./QuestionnaireStatusBar";
import { RequestInputModal } from "./RequestInputModal";
import { SlackIcon } from "./SlackIcon";
import { PrivacyAssessmentDetailResponse } from "./types";
import { getSlackQuestions } from "./utils";

interface AssessmentDetailProps {
assessment: PrivacyAssessmentDetailResponse;
Expand All @@ -39,18 +47,43 @@ export const AssessmentDetail = ({ assessment }: AssessmentDetailProps) => {
const { getDataCategoryDisplayName } = useTaxonomies();

const [expandedKeys, setExpandedKeys] = useState<string[]>([]);
const [isRequestInputOpen, setIsRequestInputOpen] = useState(false);

const { data: config } = useGetAssessmentConfigQuery();
const [deleteAssessment, { isLoading: isDeleting }] =
useDeletePrivacyAssessmentMutation();
const [createReminder, { isLoading: isSendingReminder }] =
useCreateQuestionnaireReminderMutation();

const slackChannelName = config?.slack_channel_name
? `#${config.slack_channel_name}`
: null;

const allQuestions = useMemo(
() => (assessment.question_groups ?? []).flatMap((g) => g.questions),
[assessment.question_groups],
);

const isComplete = useMemo(
() => allQuestions.every((q) => q.answer_text.trim().length > 0),
[allQuestions],
);

const { slackQuestions, answeredSlackQuestions } = useMemo(
() => getSlackQuestions(allQuestions),
[allQuestions],
);

const questionnaireSentAt = useMemo(
() =>
(assessment.question_groups ?? [])
.flatMap((g) => g.questions)
.every((q) => q.answer_text.trim().length > 0),
[assessment.question_groups],
assessment.questionnaire?.sent_at
? new Date(assessment.questionnaire.sent_at)
: null,
[assessment.questionnaire?.sent_at],
);

const timeSinceSent = useRelativeTime(questionnaireSentAt);

const handleDelete = () => {
modalApi.confirm({
title: "Delete assessment",
Expand Down Expand Up @@ -83,6 +116,20 @@ export const AssessmentDetail = ({ assessment }: AssessmentDetailProps) => {
});
};

const handleSendReminder = async () => {
try {
await createReminder({ id: assessment.id }).unwrap();
message.success(`Reminder sent to ${slackChannelName}.`);
} catch (error) {
message.error(
getErrorMessage(
error as RTKErrorResult["error"],
"Failed to send reminder. Please try again.",
),
);
}
};

const collapseItems = useMemo(
() =>
(assessment.question_groups ?? []).map((group) => ({
Expand Down Expand Up @@ -111,7 +158,7 @@ export const AssessmentDetail = ({ assessment }: AssessmentDetailProps) => {
return (
<Space direction="vertical" size="small" className="w-full">
<Flex justify="space-between" align="flex-start">
<div>
<div className="flex-1">
<Flex align="center" gap="small" className="mb-1">
<Typography.Title level={4} className="m-0">
{assessment.name}
Expand All @@ -124,29 +171,9 @@ export const AssessmentDetail = ({ assessment }: AssessmentDetailProps) => {
{isComplete ? "Completed" : "In progress"}
</Tag>
</Flex>
<Text type="secondary" size="sm" className="mb-2 block">
<Text type="secondary" size="sm" className="block">
System: {assessment.system_name}
</Text>
<Text type="secondary" className="block leading-loose">
Processing{" "}
{(assessment.data_categories ?? []).length > 0 ? (
<TagList
tags={(assessment.data_categories ?? []).map((key) => ({
value: key,
label: getDataCategoryDisplayName(key),
}))}
maxTags={1}
expandable
/>
) : (
<Tag>0 data categories</Tag>
)}{" "}
for{" "}
<TagList
tags={assessment.data_use_name ? [assessment.data_use_name] : []}
maxTags={1}
/>
</Text>
</div>

<Space>
Expand Down Expand Up @@ -174,13 +201,56 @@ export const AssessmentDetail = ({ assessment }: AssessmentDetailProps) => {
</Space>
</Flex>

{!isComplete && (
<Flex justify="flex-end">
{/* TODO: implement request input from team */}
<Button icon={<SlackIcon size={14} />} size="small">
Request input from team
</Button>
</Flex>
<Flex justify="space-between" align="center" className="mb-2 w-full">
<Text type="secondary" className="leading-loose">
Processing{" "}
{(assessment.data_categories ?? []).length > 0 ? (
<TagList
tags={(assessment.data_categories ?? []).map((key) => ({
value: key,
label: getDataCategoryDisplayName(key),
}))}
maxTags={1}
expandable
/>
) : (
<Tag>0 data categories</Tag>
)}{" "}
for{" "}
<TagList
tags={assessment.data_use_name ? [assessment.data_use_name] : []}
maxTags={1}
/>
</Text>
{!isComplete && (
<Tooltip
title={
!slackChannelName
? "Configure a Slack channel in assessment settings to enable this feature"
: undefined
}
>
<Button
icon={<SlackIcon size={14} />}
size="small"
onClick={() => setIsRequestInputOpen(true)}
disabled={!slackChannelName}
>
Request input from team
</Button>
</Tooltip>
)}
</Flex>

{!isComplete && questionnaireSentAt && (
<QuestionnaireStatusBar
channel={slackChannelName ?? ""}
timeSinceSent={timeSinceSent}
answeredCount={answeredSlackQuestions.length}
totalCount={slackQuestions.length}
isSendingReminder={isSendingReminder}
onSendReminder={handleSendReminder}
/>
)}

<Collapse
Expand All @@ -190,6 +260,16 @@ export const AssessmentDetail = ({ assessment }: AssessmentDetailProps) => {
items={collapseItems}
size="large"
/>

{slackChannelName && (
<RequestInputModal
open={isRequestInputOpen}
onClose={() => setIsRequestInputOpen(false)}
assessmentId={assessment.id}
questions={allQuestions}
slackChannelName={slackChannelName}
/>
)}
</Space>
);
};
Original file line number Diff line number Diff line change
@@ -1,20 +1,13 @@
import {
CUSTOM_TAG_COLOR,
Flex,
Tag,
Text,
Tooltip,
useMessage,
} from "fidesui";
import { Flex, Space, Text, useMessage } from "fidesui";

import { getErrorMessage } from "~/features/common/helpers";
import { RTKErrorResult } from "~/types/errors/api";

import { ANSWER_SOURCE_LABELS, ANSWER_SOURCE_TAG_COLORS } from "./constants";
import { AnswerStatusTags } from "./AnswerStatusTags";
import { EditableTextBlock } from "./EditableTextBlock";
import { useUpdateAssessmentAnswerMutation } from "./privacy-assessments.slice";
import styles from "./QuestionCard.module.scss";
import { AnswerStatus, AssessmentQuestion } from "./types";
import { AssessmentQuestion } from "./types";

interface QuestionCardProps {
assessmentId: string;
Expand All @@ -26,9 +19,6 @@ export const QuestionCard = ({ assessmentId, question }: QuestionCardProps) => {
const [updateAnswer, { isLoading: isSaving }] =
useUpdateAssessmentAnswerMutation();

const sourceLabel = ANSWER_SOURCE_LABELS[question.answer_source];
const sourceColor = ANSWER_SOURCE_TAG_COLORS[question.answer_source];

const handleSave = async (newAnswer: string) => {
try {
await updateAnswer({
Expand All @@ -52,19 +42,9 @@ export const QuestionCard = ({ assessmentId, question }: QuestionCardProps) => {
<Text strong>
{question.id}. {question.question_text}
</Text>
{question.answer_status === AnswerStatus.PARTIAL ? (
<Tooltip
title={
question.missing_data && question.missing_data.length > 0
? `This answer can be automatically derived if you populate: ${question.missing_data.join(", ")}`
: "This answer can be derived from Fides data if the relevant field is populated"
}
>
<Tag color={CUSTOM_TAG_COLOR.WARNING}>System derivable</Tag>
</Tooltip>
) : (
<Tag color={sourceColor}>{sourceLabel}</Tag>
)}
<Space size="small">
<AnswerStatusTags question={question} />
</Space>
</Flex>
<EditableTextBlock
value={question.answer_text}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
.container {
background-color: var(--fidesui-bg-corinth);
border-radius: var(--ant-border-radius-lg);
padding: 16px;
width: 100%;
}

.checkIcon {
color: var(--fidesui-success);
font-size: var(--ant-font-size-lg);
}
Loading
Loading