Conversation
- Add chat provider configuration UI components - Add Slack integration card with conversation support - Add chat provider API slice and routes - Add ChatProviderConfig model and database migration - Gate feature behind alphaDataProtectionAssessments flag - Exclude notification-integrations page (kept for separate PR) Co-authored-by: Cursor <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
Greptile OverviewGreptile SummaryThis PR adds a comprehensive Slack chat provider integration feature gated behind the Backend Changes:
Frontend Changes:
Key Issues:
The feature is well-structured with proper encryption, permissions, and feature flagging, but needs adjustments before production deployment. Confidence Score: 3/5
Important Files Changed
|
Co-authored-by: Cursor <[email protected]>
| pollingInterval: hasActiveConversation ? 3000 : 30000, | ||
| }); |
There was a problem hiding this comment.
Polling intervals should be set in hours for production, not seconds. Current values (3s/30s) are only appropriate for development.
| pollingInterval: hasActiveConversation ? 3000 : 30000, | |
| }); | |
| pollingInterval: hasActiveConversation ? 3600000 : 14400000, // 1 hour / 4 hours |
Context Used: Rule from dashboard - Polling intervals for async operations should be set in hours for production, not seconds or minutes... (source)
| import { | ||
| Button, | ||
| Card, | ||
| CUSTOM_TAG_COLOR, | ||
| Flex, | ||
| Input, | ||
| Select, | ||
| Space, | ||
| Tag, | ||
| Typography, | ||
| useChakraToast as useToast, | ||
| } from "fidesui"; | ||
| import { useEffect, useState } from "react"; | ||
|
|
||
| import { getErrorMessage, isErrorResult } from "~/features/common/helpers"; | ||
| import { errorToastParams, successToastParams } from "~/features/common/toast"; | ||
| import { | ||
| useGetConfigurationSettingsQuery, | ||
| usePatchConfigurationSettingsMutation, | ||
| } from "~/features/config-settings/config-settings.slice"; | ||
|
|
||
| import { | ||
| ChatHistoryEntry, | ||
| Questionnaire, | ||
| useCreateQuestionnaireMutation, | ||
| useDeleteChatConnectionMutation, | ||
| useGetChatChannelsQuery, | ||
| useGetChatSettingsQuery, | ||
| useGetQuestionnairesQuery, | ||
| useSendChatMessageMutation, | ||
| useTestChatConnectionMutation, | ||
| useUpdateChatSettingsMutation, | ||
| } from "./chatProvider.slice"; | ||
| import styles from "./SlackIntegrationCard.module.scss"; | ||
|
|
||
| const { Text, Title, Paragraph } = Typography; | ||
|
|
||
| const SlackLogo = () => ( | ||
| <svg | ||
| width="32" | ||
| height="32" | ||
| viewBox="0 0 24 24" | ||
| fill="none" | ||
| xmlns="http://www.w3.org/2000/svg" | ||
| > | ||
| <path | ||
| d="M5.042 15.165a2.528 2.528 0 0 1-2.52 2.523A2.528 2.528 0 0 1 0 15.165a2.527 2.527 0 0 1 2.522-2.52h2.52v2.52zm1.271 0a2.527 2.527 0 0 1 2.521-2.52 2.527 2.527 0 0 1 2.521 2.52v6.313A2.528 2.528 0 0 1 8.834 24a2.528 2.528 0 0 1-2.521-2.522v-6.313z" | ||
| fill="#E01E5A" | ||
| /> | ||
| <path | ||
| d="M8.834 5.042a2.528 2.528 0 0 1-2.521-2.52A2.528 2.528 0 0 1 8.834 0a2.528 2.528 0 0 1 2.521 2.522v2.52H8.834zm0 1.271a2.528 2.528 0 0 1 2.521 2.521 2.528 2.528 0 0 1-2.521 2.521H2.522A2.528 2.528 0 0 1 0 8.834a2.528 2.528 0 0 1 2.522-2.521h6.312z" | ||
| fill="#36C5F0" | ||
| /> | ||
| <path | ||
| d="M18.956 8.834a2.528 2.528 0 0 1 2.522-2.521A2.528 2.528 0 0 1 24 8.834a2.528 2.528 0 0 1-2.522 2.521h-2.522V8.834zm-1.27 0a2.528 2.528 0 0 1-2.522 2.521 2.528 2.528 0 0 1-2.52-2.521V2.522A2.528 2.528 0 0 1 15.165 0a2.528 2.528 0 0 1 2.521 2.522v6.312z" | ||
| fill="#2EB67D" | ||
| /> | ||
| <path | ||
| d="M15.165 18.956a2.528 2.528 0 0 1 2.521 2.522A2.528 2.528 0 0 1 15.165 24a2.527 2.527 0 0 1-2.52-2.522v-2.522h2.52zm0-1.27a2.527 2.527 0 0 1-2.52-2.522 2.527 2.527 0 0 1 2.52-2.52h6.313A2.528 2.528 0 0 1 24 15.165a2.528 2.528 0 0 1-2.522 2.521h-6.313z" | ||
| fill="#ECB22E" | ||
| /> | ||
| </svg> | ||
| ); | ||
|
|
||
| const SlackIntegrationCard = () => { | ||
| const toast = useToast(); | ||
| const { | ||
| data: settings, | ||
| isLoading, | ||
| isError, | ||
| refetch, | ||
| } = useGetChatSettingsQuery(); | ||
| const [updateSettings, { isLoading: isSaving }] = | ||
| useUpdateChatSettingsMutation(); | ||
| const [testConnection, { isLoading: isTesting }] = | ||
| useTestChatConnectionMutation(); | ||
| const [deleteConnection, { isLoading: isDeleting }] = | ||
| useDeleteChatConnectionMutation(); | ||
| const [sendMessage, { isLoading: isSending }] = useSendChatMessageMutation(); | ||
| const [createQuestionnaire, { isLoading: isCreatingQuestionnaire }] = | ||
| useCreateQuestionnaireMutation(); | ||
|
|
||
| // Track if there's an active conversation for polling speed | ||
| const [hasActiveConversation, setHasActiveConversation] = useState(false); | ||
|
|
||
| // Questionnaire polling (provider-agnostic) | ||
| const { data: questionnairesData, refetch: refetchQuestionnaires } = | ||
| useGetQuestionnairesQuery(undefined, { | ||
| skip: !settings?.authorized, | ||
| pollingInterval: hasActiveConversation ? 3000 : 30000, | ||
| }); | ||
|
|
||
| // Get the latest questionnaire | ||
| const latestQuestionnaire = questionnairesData?.questionnaires?.[0]; | ||
|
|
||
| // Update active conversation state when data changes | ||
| useEffect(() => { | ||
| const isActive = latestQuestionnaire?.status === "in_progress"; | ||
| setHasActiveConversation(isActive ?? false); | ||
| }, [latestQuestionnaire?.status]); | ||
|
|
||
| const { data: channelsData } = useGetChatChannelsQuery(undefined, { | ||
| skip: !settings?.authorized, | ||
| }); | ||
|
|
||
| // Get and update global config for privacy assessments channel | ||
| const { data: appConfig } = useGetConfigurationSettingsQuery({ | ||
| api_set: true, | ||
| }); | ||
| const [patchConfigSettings] = usePatchConfigurationSettingsMutation(); | ||
|
|
||
| const [selectedChannel, setSelectedChannel] = useState<string | undefined>( | ||
| undefined | ||
| ); | ||
| const [testMessage, setTestMessage] = useState<string>( | ||
| "Hello from Fides! This is a test message." | ||
| ); | ||
|
|
||
| // Credentials form state | ||
| const [clientId, setClientId] = useState<string>(""); | ||
| const [clientSecret, setClientSecret] = useState<string>(""); | ||
| const [signingSecret, setSigningSecret] = useState<string>(""); | ||
|
|
||
| // Update selected channel when config settings load | ||
| useEffect(() => { | ||
| if (appConfig?.notifications?.privacy_assessments_channel) { | ||
| setSelectedChannel(appConfig.notifications.privacy_assessments_channel); | ||
| } | ||
| }, [appConfig?.notifications?.privacy_assessments_channel]); | ||
|
|
||
| // Handle OAuth callback results | ||
| useEffect(() => { | ||
| const urlParams = new URLSearchParams(window.location.search); | ||
| const chatSuccess = urlParams.get("chat_success"); | ||
| const chatError = urlParams.get("chat_error"); | ||
|
|
||
| if (chatSuccess === "true") { | ||
| toast(successToastParams("Slack connected successfully!")); | ||
| refetch(); | ||
| window.history.replaceState({}, "", window.location.pathname); | ||
| } else if (chatError) { | ||
| const errorMessages: Record<string, string> = { | ||
| invalid_state: | ||
| "Authorization failed: Invalid state token. Please try again.", | ||
| not_configured: "Authorization failed: Chat provider not configured.", | ||
| token_failed: "Authorization failed: Could not obtain access token.", | ||
| no_token: "Authorization failed: No token received from Slack.", | ||
| }; | ||
| toast( | ||
| errorToastParams( | ||
| errorMessages[chatError] ?? "Authorization failed. Please try again." | ||
| ) | ||
| ); | ||
| window.history.replaceState({}, "", window.location.pathname); | ||
| } | ||
| }, [refetch, toast]); | ||
|
|
||
| // Update form state when settings load (for editing existing credentials) | ||
| useEffect(() => { | ||
| if (settings?.client_id) { | ||
| setClientId(settings.client_id); | ||
| } | ||
| }, [settings?.client_id]); | ||
|
|
||
| const handleSaveCredentials = async () => { | ||
| if (!clientId.trim() || !clientSecret.trim()) { | ||
| toast(errorToastParams("Client ID and Client Secret are required.")); | ||
| return; | ||
| } | ||
|
|
||
| const result = await updateSettings({ | ||
| enabled: true, | ||
| provider_type: "slack", | ||
| client_id: clientId.trim(), | ||
| client_secret: clientSecret.trim(), | ||
| signing_secret: signingSecret.trim() || undefined, | ||
| }); | ||
|
|
||
| if (isErrorResult(result)) { | ||
| toast( | ||
| errorToastParams(getErrorMessage(result.error, "Failed to save credentials.")) | ||
| ); | ||
| } else { | ||
| toast(successToastParams("Credentials saved. You can now connect to Slack.")); | ||
| setClientSecret(""); // Clear secrets after save | ||
| setSigningSecret(""); | ||
| refetch(); | ||
| } | ||
| }; | ||
|
|
||
| const handleConnect = () => { | ||
| window.location.href = "/api/v1/plus/chat/authorize"; | ||
| }; | ||
|
|
||
| const hasCredentials = Boolean(settings?.client_id); | ||
|
|
||
| const handleTestConnection = async () => { | ||
| const result = await testConnection(); | ||
|
|
||
| if ("data" in result && result.data) { | ||
| if (result.data.success) { | ||
| toast(successToastParams(result.data.message)); | ||
| } else { | ||
| toast(errorToastParams(result.data.message)); | ||
| } | ||
| } else if (isErrorResult(result)) { | ||
| toast(errorToastParams(getErrorMessage(result.error, "Test failed."))); | ||
| } | ||
| }; | ||
|
|
||
| const handleDisconnect = async () => { | ||
| const result = await deleteConnection(); | ||
|
|
||
| if (isErrorResult(result)) { | ||
| toast( | ||
| errorToastParams(getErrorMessage(result.error, "Disconnect failed.")) | ||
| ); | ||
| } else { | ||
| toast(successToastParams("Slack disconnected successfully.")); | ||
| refetch(); | ||
| } | ||
| }; | ||
|
|
||
| const handleChannelChange = async (value: string) => { | ||
| setSelectedChannel(value); | ||
|
|
||
| const result = await patchConfigSettings({ | ||
| notifications: { | ||
| privacy_assessments_channel: value, | ||
| }, | ||
| }); | ||
|
|
||
| if (isErrorResult(result)) { | ||
| toast(errorToastParams(getErrorMessage(result.error, "Save failed."))); | ||
| } else { | ||
| toast(successToastParams("Channel updated.")); | ||
| } | ||
| }; | ||
|
|
||
| const handleSendMessage = async () => { | ||
| if (!selectedChannel || !testMessage.trim()) { | ||
| toast(errorToastParams("Please select a channel and enter a message.")); | ||
| return; | ||
| } | ||
|
|
||
| const result = await sendMessage({ | ||
| channel_id: selectedChannel, | ||
| message: testMessage, | ||
| }); | ||
|
|
||
| if ("data" in result && result.data) { | ||
| if (result.data.success) { | ||
| toast(successToastParams(result.data.message)); | ||
| } else { | ||
| toast(errorToastParams(result.data.message)); | ||
| } | ||
| } else if (isErrorResult(result)) { | ||
| toast( | ||
| errorToastParams(getErrorMessage(result.error, "Failed to send message.")) | ||
| ); | ||
| } | ||
| }; | ||
|
|
||
| // Questionnaire handler | ||
| const handleCreateQuestionnaire = async () => { | ||
| const result = await createQuestionnaire({ | ||
| title: "Team Preferences Survey", | ||
| questions: [ | ||
| "What's your favorite color?", | ||
| "What's your go-to comfort food?", | ||
| "Coffee or tea?", | ||
| "What's the best thing about working here?", | ||
| "What hobby would you pick up if you had unlimited time?", | ||
| "Does your organization sell personal information of California residents to third parties, or has it sold such information in the preceding 12 months?", | ||
| ], | ||
| }); | ||
|
|
||
| if ("data" in result && result.data) { | ||
| if (result.data.success) { | ||
| toast(successToastParams(result.data.message)); | ||
| refetchQuestionnaires(); | ||
| } else { | ||
| toast(errorToastParams(result.data.message)); | ||
| } | ||
| } else if (isErrorResult(result)) { | ||
| toast( | ||
| errorToastParams( | ||
| getErrorMessage(result.error, "Failed to create questionnaire.") | ||
| ) | ||
| ); | ||
| } | ||
| }; | ||
|
|
||
| const formatDateTime = (dateString: string) => { | ||
| return new Date(dateString).toLocaleString("en-US", { | ||
| month: "short", | ||
| day: "numeric", | ||
| year: "numeric", | ||
| hour: "numeric", | ||
| minute: "2-digit", | ||
| }); | ||
| }; | ||
|
|
||
| const formatTime = (dateString: string) => { | ||
| return new Date(dateString).toLocaleTimeString("en-US", { | ||
| hour: "numeric", | ||
| minute: "2-digit", | ||
| }); | ||
| }; | ||
|
|
||
| const formatDate = (dateString: string) => { | ||
| return new Date(dateString).toLocaleDateString("en-US", { | ||
| month: "short", | ||
| day: "numeric", | ||
| year: "numeric", | ||
| }); | ||
| }; | ||
|
|
||
| if (isLoading) { | ||
| return ( | ||
| <Card className={styles.card}> | ||
| <Flex align="center" gap={12}> | ||
| <SlackLogo /> | ||
| <Title level={5} className="m-0"> | ||
| Slack | ||
| </Title> | ||
| </Flex> | ||
| <Paragraph className={styles.description}>Loading...</Paragraph> | ||
| </Card> | ||
| ); | ||
| } | ||
|
|
||
| // Not connected state | ||
| if (!settings?.authorized || isError) { | ||
| return ( | ||
| <Card className={styles.card} data-testid="slack-integration-card"> | ||
| <div className={styles.header}> | ||
| <Flex align="center" gap={12}> | ||
| <SlackLogo /> | ||
| <Title level={5} className="m-0"> | ||
| Slack | ||
| </Title> | ||
| </Flex> | ||
| </div> | ||
|
|
||
| <Paragraph className={styles.description}> | ||
| Receive AI-powered privacy insights and alerts directly in your Slack | ||
| workspace. | ||
| </Paragraph> | ||
|
|
||
| <Flex align="center" gap={8} className="mb-4"> | ||
| <Text strong>Status:</Text> | ||
| <Tag color={CUSTOM_TAG_COLOR.DEFAULT}>Not connected</Tag> | ||
| </Flex> | ||
|
|
||
| <Space direction="vertical" size={12} className="w-full mb-4"> | ||
| <div> | ||
| <Text strong className="mb-1 block"> | ||
| Slack app credentials | ||
| </Text> | ||
| <Text type="secondary" className="mb-3 block"> | ||
| Create a Slack app at{" "} | ||
| <a | ||
| href="https://api.slack.com/apps" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| > | ||
| api.slack.com/apps | ||
| </a>{" "} | ||
| and enter your app credentials below. | ||
| </Text> | ||
| </div> | ||
|
|
||
| <div> | ||
| <Text className="mb-1 block">Client ID</Text> | ||
| <Input | ||
| value={clientId} | ||
| onChange={(e) => setClientId(e.target.value)} | ||
| placeholder="Enter Slack app Client ID" | ||
| data-testid="slack-client-id-input" | ||
| /> | ||
| </div> | ||
|
|
||
| <div> | ||
| <Text className="mb-1 block">Client Secret</Text> | ||
| <Input.Password | ||
| value={clientSecret} | ||
| onChange={(e) => setClientSecret(e.target.value)} | ||
| placeholder={ | ||
| hasCredentials | ||
| ? "Enter new secret to update" | ||
| : "Enter Slack app Client Secret" | ||
| } | ||
| data-testid="slack-client-secret-input" | ||
| /> | ||
| </div> | ||
|
|
||
| <div> | ||
| <Text className="mb-1 block"> | ||
| Signing Secret{" "} | ||
| <Text type="secondary">(optional, for webhooks)</Text> | ||
| </Text> | ||
| <Input.Password | ||
| value={signingSecret} | ||
| onChange={(e) => setSigningSecret(e.target.value)} | ||
| placeholder={ | ||
| settings?.has_signing_secret | ||
| ? "Enter new secret to update" | ||
| : "Enter Slack app Signing Secret" | ||
| } | ||
| data-testid="slack-signing-secret-input" | ||
| /> | ||
| </div> | ||
|
|
||
| <Flex gap={8}> | ||
| <Button | ||
| onClick={handleSaveCredentials} | ||
| loading={isSaving} | ||
| disabled={!clientId.trim() || !clientSecret.trim()} | ||
| data-testid="save-slack-credentials-btn" | ||
| > | ||
| Save credentials | ||
| </Button> | ||
|
|
||
| <Button | ||
| type="primary" | ||
| onClick={handleConnect} | ||
| disabled={!hasCredentials} | ||
| data-testid="connect-slack-btn" | ||
| > | ||
| Connect to Slack | ||
| </Button> | ||
| </Flex> | ||
|
|
||
| {!hasCredentials && ( | ||
| <Text type="secondary"> | ||
| Save your credentials first, then click "Connect to | ||
| Slack" to authorize. | ||
| </Text> | ||
| )} | ||
| </Space> | ||
| </Card> | ||
| ); | ||
| } | ||
|
|
||
| // Connected state | ||
| return ( | ||
| <Card className={styles.card} data-testid="slack-integration-card"> | ||
| <div className={styles.header}> | ||
| <Flex align="center" gap={12}> | ||
| <SlackLogo /> | ||
| <Title level={5} className="m-0"> | ||
| Slack | ||
| </Title> | ||
| </Flex> | ||
| <Tag color={CUSTOM_TAG_COLOR.SUCCESS}>Connected</Tag> | ||
| </div> | ||
|
|
||
| <Space direction="vertical" size={4} className="mb-4"> | ||
| {settings.workspace_name && ( | ||
| <Text> | ||
| <Text strong>Workspace:</Text> {settings.workspace_name} | ||
| </Text> | ||
| )} | ||
| {settings.connected_by_email && ( | ||
| <Text> | ||
| <Text strong>Connected by:</Text> {settings.connected_by_email} | ||
| </Text> | ||
| )} | ||
| {settings.created_at && ( | ||
| <Text> | ||
| <Text strong>Connected on:</Text> {formatDate(settings.created_at)} | ||
| </Text> | ||
| )} | ||
| </Space> | ||
|
|
||
| <div className={styles.channelSection}> | ||
| <Text strong className="mb-2 block"> | ||
| Notification settings | ||
| </Text> | ||
| <Flex align="center" gap={8}> | ||
| <Text>Channel for AI insights:</Text> | ||
| <Select | ||
| value={selectedChannel} | ||
| onChange={handleChannelChange} | ||
| placeholder="Select a channel" | ||
| className={styles.channelSelect} | ||
| options={channelsData?.channels?.map((channel) => ({ | ||
| value: channel.id, | ||
| label: `#${channel.name}`, | ||
| }))} | ||
| loading={!channelsData} | ||
| data-testid="slack-channel-select" | ||
| /> | ||
| </Flex> | ||
| </div> | ||
|
|
||
| <div className={styles.testMessageSection}> | ||
| <Text strong className="mb-2 block"> | ||
| Send test message | ||
| </Text> | ||
| <Flex gap={8}> | ||
| <Input | ||
| value={testMessage} | ||
| onChange={(e) => setTestMessage(e.target.value)} | ||
| placeholder="Enter a test message..." | ||
| className={styles.messageInput} | ||
| data-testid="slack-test-message-input" | ||
| /> | ||
| <Button | ||
| onClick={handleSendMessage} | ||
| loading={isSending} | ||
| disabled={!selectedChannel} | ||
| data-testid="send-slack-message-btn" | ||
| > | ||
| Send | ||
| </Button> | ||
| </Flex> | ||
| {!selectedChannel && ( | ||
| <Text type="secondary" className="mt-1 block"> | ||
| Select a channel first to send a test message. | ||
| </Text> | ||
| )} | ||
| </div> | ||
|
|
||
| <Flex gap={8} className="mt-4"> | ||
| <Button | ||
| onClick={handleTestConnection} | ||
| loading={isTesting} | ||
| data-testid="test-slack-btn" | ||
| > | ||
| Test connection | ||
| </Button> | ||
| <Button | ||
| danger | ||
| onClick={handleDisconnect} | ||
| loading={isDeleting} | ||
| data-testid="disconnect-slack-btn" | ||
| > | ||
| Disconnect | ||
| </Button> | ||
| </Flex> | ||
|
|
||
| {/* Questionnaire Section */} | ||
| <div className={styles.qaSection}> | ||
| <Flex justify="space-between" align="center" className="mb-3"> | ||
| <Text strong>Questionnaire</Text> | ||
| <Flex gap={8}> | ||
| <Button | ||
| onClick={() => refetchQuestionnaires()} | ||
| size="small" | ||
| data-testid="refresh-questionnaires-btn" | ||
| > | ||
| Refresh | ||
| </Button> | ||
| <Button | ||
| type="primary" | ||
| onClick={handleCreateQuestionnaire} | ||
| loading={isCreatingQuestionnaire} | ||
| disabled={!selectedChannel} | ||
| size="small" | ||
| data-testid="create-questionnaire-btn" | ||
| > | ||
| New questionnaire | ||
| </Button> | ||
| </Flex> | ||
| </Flex> | ||
|
|
||
| {!selectedChannel && ( | ||
| <Text type="secondary" className="mb-3 block"> | ||
| Select a channel above to start a questionnaire. | ||
| </Text> | ||
| )} | ||
|
|
||
| {latestQuestionnaire ? ( | ||
| <QuestionnaireCard | ||
| questionnaire={latestQuestionnaire} | ||
| formatDateTime={formatDateTime} | ||
| formatTime={formatTime} | ||
| /> | ||
| ) : ( | ||
| <Text type="secondary"> | ||
| No questionnaires yet. Click "New questionnaire" to create one. | ||
| </Text> | ||
| )} | ||
| </div> | ||
| </Card> | ||
| ); | ||
| }; | ||
|
|
||
| // Questionnaire Card with tabbed Q&A Summary and Full Conversation views | ||
| const QuestionnaireCard = ({ | ||
| questionnaire, | ||
| formatDateTime, | ||
| formatTime, | ||
| }: { | ||
| questionnaire: Questionnaire; | ||
| formatDateTime: (dateString: string) => string; | ||
| formatTime: (dateString: string) => string; | ||
| }) => { | ||
| const [activeTab, setActiveTab] = useState<"qa" | "history">("qa"); | ||
| const answeredCount = questionnaire.questions.filter( | ||
| (q) => q.answer !== null | ||
| ).length; | ||
| const totalQuestions = questionnaire.questions.length; | ||
| const isCompleted = questionnaire.status === "completed"; | ||
|
|
||
| return ( | ||
| <Card size="small" className={styles.questionCard}> | ||
| <Flex justify="space-between" align="center" className="mb-2"> | ||
| <Text strong>{questionnaire.title}</Text> | ||
| <Tag | ||
| color={ | ||
| isCompleted ? CUSTOM_TAG_COLOR.SUCCESS : CUSTOM_TAG_COLOR.PROCESSING | ||
| } | ||
| > | ||
| {isCompleted | ||
| ? "Completed" | ||
| : `${answeredCount}/${totalQuestions} answered`} | ||
| </Tag> | ||
| </Flex> | ||
| <Text type="secondary" className="block mb-3"> | ||
| Started {formatDateTime(questionnaire.created_at)} | ||
| </Text> | ||
|
|
||
| {/* Simple tab buttons instead of Ant Tabs */} | ||
| <Flex gap={8} className="mb-3"> | ||
| <Button | ||
| size="small" | ||
| type={activeTab === "qa" ? "primary" : "default"} | ||
| onClick={() => setActiveTab("qa")} | ||
| > | ||
| Q&A Summary | ||
| </Button> | ||
| <Button | ||
| size="small" | ||
| type={activeTab === "history" ? "primary" : "default"} | ||
| onClick={() => setActiveTab("history")} | ||
| > | ||
| Full Conversation | ||
| </Button> | ||
| </Flex> | ||
|
|
||
| {activeTab === "qa" && ( | ||
| <div className={styles.answersContainer}> | ||
| {questionnaire.questions.map((q, idx) => ( | ||
| <div key={idx} className={styles.qaItem}> | ||
| <Text strong className="block"> | ||
| Q{idx + 1}: {q.displayed_as || q.question} | ||
| </Text> | ||
| {q.answer ? ( | ||
| <Text className="block ml-4"> | ||
| <Text type="secondary">A:</Text> {q.answer} | ||
| {(q.answered_by_email ?? q.answered_by_display_name) && ( | ||
| <Text type="secondary"> | ||
| {" "} | ||
| (by {q.answered_by_email ?? q.answered_by_display_name}) | ||
| </Text> | ||
| )} | ||
| </Text> | ||
| ) : idx === questionnaire.current_question_index && !isCompleted ? ( | ||
| <Text type="secondary" italic className="block ml-4"> | ||
| Waiting for answer... | ||
| </Text> | ||
| ) : ( | ||
| <Text type="secondary" className="block ml-4"> | ||
| — | ||
| </Text> | ||
| )} | ||
| </div> | ||
| ))} | ||
| </div> | ||
| )} | ||
|
|
||
| {activeTab === "history" && ( | ||
| <div className={styles.chatHistoryContainer}> | ||
| {questionnaire.chat_history.map((entry, idx) => ( | ||
| <div | ||
| key={idx} | ||
| className={entry.is_bot ? styles.botMessage : styles.userMessage} | ||
| > | ||
| <Flex justify="space-between" align="center" className="mb-1"> | ||
| <Text strong> | ||
| {entry.is_bot | ||
| ? "🤖 Astralis" | ||
| : `👤 ${entry.user_display_name ?? entry.user_email ?? "User"}`} | ||
| </Text> | ||
| <Text type="secondary">{formatTime(entry.timestamp)}</Text> | ||
| </Flex> | ||
| <Text className="block">{entry.text}</Text> | ||
| </div> | ||
| ))} | ||
| </div> | ||
| )} | ||
| </Card> | ||
| ); | ||
| }; | ||
|
|
||
| export default SlackIntegrationCard; |
There was a problem hiding this comment.
Component file is 699 lines, at the limit. Consider extracting:
SlackLogocomponent to separate fileQuestionnaireCardcomponent to separate file (already defined at bottom)- Format functions (
formatDateTime,formatTime,formatDate) to a utils/helpers file - Form state and handlers to a custom hook
Context Used: Rule from dashboard - Keep React component files under 700 lines and follow the practice of 1 component per file. Extract ... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")), | ||
| sa.Column("single_row", sa.Boolean(), nullable=False, server_default=sa.text("true")), |
There was a problem hiding this comment.
Use server_default="f" format instead of sa.text("false") for consistency with project conventions.
| sa.Column("enabled", sa.Boolean(), nullable=False, server_default=sa.text("false")), | |
| sa.Column("single_row", sa.Boolean(), nullable=False, server_default=sa.text("true")), | |
| sa.Column("enabled", sa.Boolean(), nullable=False, server_default="f"), | |
| sa.Column("single_row", sa.Boolean(), nullable=False, server_default="t"), |
Context Used: Rule from dashboard - Use server_default="f" for boolean columns in Alembic migrations instead of default=False or oth... (source)
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| sendChatMessage: build.mutation<SendMessageResponse, SendMessageRequest>({ | ||
| query: (body) => ({ | ||
| url: "plus/chat/send", | ||
| method: "POST", | ||
| body, | ||
| }), | ||
| }), |
There was a problem hiding this comment.
Consider adding invalidatesTags: ["Questionnaires"] to ensure questionnaire data refreshes after sending a message, as the message might trigger questionnaire updates.
Context Used: Rule from dashboard - When adding new RTK Query mutation endpoints, include invalidateTags to ensure related data refreshe... (source)
lucanovera
left a comment
There was a problem hiding this comment.
The feature was working correctly. I've pushed a few refactors to use Ant components, follow our FE best practices and to use the openapi generated type. Everything looks good now. Approved!
Description Of Changes
This PR adds the foundation for Slack chat provider integration, allowing administrators to configure Slack workspace connections for sending notifications. The feature is gated behind the
alphaDataProtectionAssessmentsflag.The implementation includes:
Note: The notification-integrations page UI has been excluded and will be included in a separate PR.
Code Changes
Backend (fidesplus)
ChatProviderConfigServicefor managing chat provider configurations with business logic/api/v1/plus/chat/for configuration CRUD, OAuth flow, and messagingCHAT_PROVIDER_READandCHAT_PROVIDER_UPDATEscopes to plus scope registrySlackOAuthhelper class for Slack OAuth 2.0 and API interactionsChatProviderConfigNotFoundErrorexception typemain.pyFrontend (fides)
alphaDataProtectionAssessmentsflagDatabase
ChatProviderConfigmodel with fields for provider type, credentials, OAuth tokens, and workspace infoSteps to Confirm
alphaDataProtectionAssessmentsfeature flagPre-Merge Checklist
CHANGELOG.mdupdatedmaindowngrade()migration is correct and works