From 33ffee2d593796d64d57396769ca0f3d13f1e040 Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Tue, 29 Apr 2025 14:29:58 +1000 Subject: [PATCH] refactor opt in form and modal --- .../GeneralSettings/AIOptInLevelSelector.tsx | 100 ++++++++++ .../GeneralSettings/DataPrivacyForm.tsx | 178 ++---------------- .../ui/AIAssistantPanel/AIAssistant.tsx | 47 +---- .../ui/AIAssistantPanel/AIOptInModal.tsx | 72 +++++++ apps/studio/hooks/forms/useAIOptInForm.ts | 86 +++++++++ apps/studio/hooks/misc/useOrgOptedIntoAi.ts | 30 +-- 6 files changed, 295 insertions(+), 218 deletions(-) create mode 100644 apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx create mode 100644 apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx create mode 100644 apps/studio/hooks/forms/useAIOptInForm.ts diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx new file mode 100644 index 00000000000..6cf54cb40fb --- /dev/null +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector.tsx @@ -0,0 +1,100 @@ +import { ReactNode } from 'react' +import { Control } from 'react-hook-form' +import { cn, FormField_Shadcn_, RadioGroupCard, RadioGroupCardItem } from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import OptInToOpenAIToggle from './OptInToOpenAIToggle' +import { AIOptInFormValues } from 'hooks/forms/useAIOptInForm' + +interface AIOptInLevelSelectorProps { + control: Control + disabled?: boolean + label?: ReactNode + layout?: 'horizontal' | 'vertical' | 'flex-row-reverse' +} + +export const AIOptInLevelSelector = ({ + control, + disabled, + label, + layout = 'vertical', +}: AIOptInLevelSelectorProps) => { + const content = ( +
+ {/* Wrap RadioGroupCard with FormField_Shadcn_ providing name and render */} + ( + + + Disabled + + No data is sent to OpenAI, responses will be generic. + + + } + /> + + Schema Only + + Send only your database schema to OpenAI for better responses. + + + } + /> + + Schema & Sample Data + + Send schema and SQL query data for the best AI responses. + + + } + /> + + )} + /> +
+ ) + + // Conditional rendering with FormItemLayout remains the same + return ( + +

+ By opting into sending anonymous data, Supabase AI can improve the answers it shows you. + This is an organization-wide setting. Select the level of data you are comfortable + sharing. +

+ + + } + layout={layout} + > + {content} +
+ ) + + return content +} diff --git a/apps/studio/components/interfaces/Organization/GeneralSettings/DataPrivacyForm.tsx b/apps/studio/components/interfaces/Organization/GeneralSettings/DataPrivacyForm.tsx index 35093b3b930..f72f79ef4cf 100644 --- a/apps/studio/components/interfaces/Organization/GeneralSettings/DataPrivacyForm.tsx +++ b/apps/studio/components/interfaces/Organization/GeneralSettings/DataPrivacyForm.tsx @@ -1,185 +1,39 @@ import { useEffect } from 'react' -import { useForm } from 'react-hook-form' -import { zodResolver } from '@hookform/resolvers/zod' import * as z from 'zod' -import { toast } from 'sonner' -import { useQueryClient } from '@tanstack/react-query' -import { useParams } from 'common' -import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { PermissionAction } from '@supabase/shared-types/out/constants' -import type { ResponseError } from 'types' -import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' -import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' -import { OPT_IN_TAGS } from 'lib/constants' -import { - Form_Shadcn_, - Card, - CardContent, - CardFooter, - FormField_Shadcn_, - RadioGroupCard, - RadioGroupCardItem, -} from 'ui' +import { Form_Shadcn_, Card, CardContent, CardFooter } from 'ui' import { FormActions } from 'components/ui/Forms/FormActions' -import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' -import OptInToOpenAIToggle from './OptInToOpenAIToggle' - -const DataPrivacySchema = z.object({ - aiOptInLevel: z.enum(['disabled', 'schema', 'schema_and_data'], { - required_error: 'AI Opt-in level selection is required', - }), -}) - -const getAiOptInLevel = (tags: string[] | undefined): 'disabled' | 'schema' | 'schema_and_data' => { - const hasSql = tags?.includes(OPT_IN_TAGS.AI_SQL) ?? false - const hasData = tags?.includes(OPT_IN_TAGS.AI_DATA ?? 'AI_DATA') - - if (hasSql && hasData) { - return 'schema_and_data' - } else if (hasSql) { - return 'schema' - } else { - return 'disabled' - } -} +import { useAIOptInForm } from 'hooks/forms/useAIOptInForm' +import { AIOptInLevelSelector } from './AIOptInLevelSelector' const DataPrivacyForm = () => { - const selectedOrganization = useSelectedOrganization() - const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') - const { slug } = useParams() - const queryClient = useQueryClient() - - const { mutate: updateOrganization, isLoading: isUpdatingPrivacy } = - useOrganizationUpdateMutation() - - const dataPrivacyForm = useForm>({ - resolver: zodResolver(DataPrivacySchema), - defaultValues: { - aiOptInLevel: getAiOptInLevel(selectedOrganization?.opt_in_tags), - }, - }) + const { form, onSubmit, isUpdating, canUpdateOrganization, currentOptInLevel } = useAIOptInForm() useEffect(() => { - if (selectedOrganization && !isUpdatingPrivacy) { - dataPrivacyForm.reset({ - aiOptInLevel: getAiOptInLevel(selectedOrganization.opt_in_tags), - }) - } - }, [selectedOrganization, dataPrivacyForm.reset, isUpdatingPrivacy]) - - const onUpdateDataPrivacy = async (values: z.infer) => { - if (!canUpdateOrganization) { - return toast.error('You do not have the required permissions to update this organization') - } - if (!slug) return console.error('Slug is required') - - const existingOptInTags = selectedOrganization?.opt_in_tags ?? [] - let updatedOptInTags = existingOptInTags.filter( - (tag: string) => tag !== OPT_IN_TAGS.AI_SQL && tag !== (OPT_IN_TAGS.AI_DATA ?? 'AI_DATA') - ) - if (values.aiOptInLevel === 'schema' || values.aiOptInLevel === 'schema_and_data') { - updatedOptInTags.push(OPT_IN_TAGS.AI_SQL) - } - if (values.aiOptInLevel === 'schema_and_data') { - updatedOptInTags.push(OPT_IN_TAGS.AI_DATA ?? 'AI_DATA') - } - updatedOptInTags = [...new Set(updatedOptInTags)] - - updateOrganization( - { slug, opt_in_tags: updatedOptInTags }, - { - onSuccess: () => { - invalidateOrganizationsQuery(queryClient) - toast.success('Successfully updated data privacy settings') - }, - onError: (error: ResponseError) => { - toast.error(`Failed to update data privacy settings: ${error.message}`) - }, - } - ) - } + form.reset({ aiOptInLevel: currentOptInLevel }) + }, [currentOptInLevel, form.reset]) const permissionsHelperText = !canUpdateOrganization ? "You need additional permissions to manage this organization's settings" : undefined return ( - -
+ + - ( - -

- By opting into sending anonymous data, Supabase AI can improve the answers - it shows you. This is an organization-wide setting. Select the level of data - you are comfortable sharing. -

- - - } - > - - - Disabled - - No data is sent to OpenAI, responses will be generic. - - - } - /> - - Schema Only - - Send only your database schema to OpenAI for better responses. - - - } - /> - - Schema & Sample Data - - Send schema and SQL query data for the best AI responses. - - - } - /> - -
- )} +
dataPrivacyForm.reset()} + isSubmitting={isUpdating} + hasChanges={form.formState.isDirty} + handleReset={() => form.reset()} helper={permissionsHelperText} disabled={!canUpdateOrganization} /> diff --git a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx index a6ee3dc3811..16bd8ff5c67 100644 --- a/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/AIAssistant.tsx @@ -20,9 +20,7 @@ import { useOrgSubscriptionQuery } from 'data/subscriptions/org-subscription-que import { useTablesQuery } from 'data/tables/tables-query' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' -import { - useOrgAiOptInLevel, -} from 'hooks/misc/useOrgOptedIntoAi' +import { useOrgAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { useFlag } from 'hooks/ui/useFlag' @@ -40,7 +38,6 @@ import { TooltipTrigger, } from 'ui' import { Admonition, AssistantChatForm, GenericSkeletonLoader } from 'ui-patterns' -import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' import { ButtonTooltip } from '../ButtonTooltip' import DotGrid from '../DotGrid' import { AIAssistantChatSelector } from './AIAssistantChatSelector' @@ -48,6 +45,7 @@ import AIOnboarding from './AIOnboarding' import CollapsibleCodeBlock from './CollapsibleCodeBlock' import { Message } from './Message' import { useAutoScroll } from './hooks' +import { AIOptInModal } from './AIOptInModal' const MemoizedMessage = memo( ({ @@ -181,7 +179,6 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { }) const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') - const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation() const updateMessage = useCallback( ({ @@ -242,30 +239,6 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { } } - const confirmOptInToShareSchemaData = async () => { - if (!canUpdateOrganization) { - return toast.error('You do not have the required permissions to update this organization') - } - - if (!selectedOrganization?.slug) return console.error('Organization slug is required') - - const existingOptInTags = selectedOrganization?.opt_in_tags ?? [] - - const updatedOptInTags = existingOptInTags.includes(OPT_IN_TAGS.AI_SQL) - ? existingOptInTags - : [...existingOptInTags, OPT_IN_TAGS.AI_SQL] - - updateOrganization( - { slug: selectedOrganization?.slug, opt_in_tags: updatedOptInTags }, - { - onSuccess: () => { - toast.success('Successfully opted-in') - setIsConfirmOptInModalOpen(false) - }, - } - ) - } - const handleClearMessages = () => { snap.clearMessages() setMessages([]) @@ -631,22 +604,10 @@ export const AIAssistant = ({ className }: AIAssistantProps) => { - setIsConfirmOptInModalOpen(false)} - onConfirm={confirmOptInToShareSchemaData} - loading={isUpdating} - > -

- By opting into sending anonymous data, Supabase AI can improve the answers it shows you. - This is an organization-wide setting, and affects all projects in your organization. -

- - -
+ /> ) } diff --git a/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx b/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx new file mode 100644 index 00000000000..e28869b9237 --- /dev/null +++ b/apps/studio/components/ui/AIAssistantPanel/AIOptInModal.tsx @@ -0,0 +1,72 @@ +import { useEffect } from 'react' +import { + Button, + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogSection, + DialogSectionSeparator, + DialogTitle, + Form_Shadcn_, +} from 'ui' +import { useAIOptInForm } from 'hooks/forms/useAIOptInForm' +import { AIOptInLevelSelector } from 'components/interfaces/Organization/GeneralSettings/AIOptInLevelSelector' + +interface AIOptInModalProps { + visible: boolean + onCancel: () => void +} + +export const AIOptInModal = ({ visible, onCancel }: AIOptInModalProps) => { + const { form, onSubmit, isUpdating, canUpdateOrganization, currentOptInLevel } = + useAIOptInForm(onCancel) + + useEffect(() => { + if (visible) { + form.reset({ aiOptInLevel: currentOptInLevel }) + } + }, [visible, currentOptInLevel, form.reset]) + + const onOpenChange = (open: boolean) => { + if (!open) { + onCancel() + } + } + + return ( + + + + + + Update Supabase Assistant Opt-in Level + + + + + + + + + + + + + + + ) +} diff --git a/apps/studio/hooks/forms/useAIOptInForm.ts b/apps/studio/hooks/forms/useAIOptInForm.ts new file mode 100644 index 00000000000..516dc2c3735 --- /dev/null +++ b/apps/studio/hooks/forms/useAIOptInForm.ts @@ -0,0 +1,86 @@ +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import * as z from 'zod' +import { toast } from 'sonner' +import { useQueryClient } from '@tanstack/react-query' + +import { PermissionAction } from '@supabase/shared-types/out/constants' +import type { ResponseError } from 'types' +import { useOrganizationUpdateMutation } from 'data/organizations/organization-update-mutation' +import { invalidateOrganizationsQuery } from 'data/organizations/organizations-query' +import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { getAiOptInLevel } from 'hooks/misc/useOrgOptedIntoAi' +import { OPT_IN_TAGS } from 'lib/constants' + +// Shared schema definition +export const AIOptInSchema = z.object({ + aiOptInLevel: z.enum(['disabled', 'schema', 'schema_and_data'], { + required_error: 'AI Opt-in level selection is required', + }), +}) + +export type AIOptInFormValues = z.infer + +/** + * Hook to manage the AI Opt-In form state and submission logic. + * Optionally takes an onSuccess callback (e.g., to close a modal). + */ +export const useAIOptInForm = (onSuccessCallback?: () => void) => { + const queryClient = useQueryClient() + const selectedOrganization = useSelectedOrganization() + const canUpdateOrganization = useCheckPermissions(PermissionAction.UPDATE, 'organizations') + + const { mutate: updateOrganization, isLoading: isUpdating } = useOrganizationUpdateMutation() + + const form = useForm({ + resolver: zodResolver(AIOptInSchema), + defaultValues: { + aiOptInLevel: getAiOptInLevel(selectedOrganization?.opt_in_tags), + }, + }) + + const onSubmit = async (values: AIOptInFormValues) => { + if (!canUpdateOrganization) { + return toast.error('You do not have the required permissions to update this organization') + } + if (!selectedOrganization?.slug) { + console.error('Organization slug is required') + return toast.error('Failed to update settings: Organization not found.') + } + + const existingOptInTags = selectedOrganization?.opt_in_tags ?? [] + let updatedOptInTags = existingOptInTags.filter( + (tag: string) => tag !== OPT_IN_TAGS.AI_SQL && tag !== (OPT_IN_TAGS.AI_DATA ?? 'AI_DATA') + ) + if (values.aiOptInLevel === 'schema' || values.aiOptInLevel === 'schema_and_data') { + updatedOptInTags.push(OPT_IN_TAGS.AI_SQL) + } + if (values.aiOptInLevel === 'schema_and_data') { + updatedOptInTags.push(OPT_IN_TAGS.AI_DATA ?? 'AI_DATA') + } + updatedOptInTags = [...new Set(updatedOptInTags)] + + updateOrganization( + { slug: selectedOrganization.slug, opt_in_tags: updatedOptInTags }, + { + onSuccess: () => { + invalidateOrganizationsQuery(queryClient) + toast.success('Successfully updated AI opt-in settings') + onSuccessCallback?.() // Call optional callback on success + }, + onError: (error: ResponseError) => { + toast.error(`Failed to update settings: ${error.message}`) + }, + } + ) + } + + return { + form, + onSubmit, + isUpdating, + canUpdateOrganization, + currentOptInLevel: getAiOptInLevel(selectedOrganization?.opt_in_tags), + } +} diff --git a/apps/studio/hooks/misc/useOrgOptedIntoAi.ts b/apps/studio/hooks/misc/useOrgOptedIntoAi.ts index ab5f8c615c9..3047a2ca11b 100644 --- a/apps/studio/hooks/misc/useOrgOptedIntoAi.ts +++ b/apps/studio/hooks/misc/useOrgOptedIntoAi.ts @@ -4,6 +4,20 @@ import { OPT_IN_TAGS } from 'lib/constants' export type AiOptInLevel = 'disabled' | 'schema' | 'schema_and_data' +// Exported helper function +export const getAiOptInLevel = (tags: string[] | undefined): AiOptInLevel => { + const hasSql = tags?.includes(OPT_IN_TAGS.AI_SQL) ?? false + const hasData = tags?.includes(OPT_IN_TAGS.AI_DATA ?? 'AI_DATA') // Ensure AI_DATA exists or handle potential undefined + + if (hasSql && hasData) { + return 'schema_and_data' + } else if (hasSql) { + return 'schema' + } else { + return 'disabled' + } +} + /** * Determines if the organization has opted into *any* level of AI features (schema or schema_and_data). * This is primarily for backward compatibility. @@ -36,20 +50,10 @@ export function useOrgOptedIntoAi(): boolean { */ export function useOrgAiOptInLevel(): AiOptInLevel { const selectedOrganization = useSelectedOrganization() - const optInTags = selectedOrganization?.opt_in_tags ?? [] + const optInTags = selectedOrganization?.opt_in_tags - const hasAiSqlTag = optInTags.includes(OPT_IN_TAGS.AI_SQL) - const hasAiDataTag = optInTags.includes(OPT_IN_TAGS.AI_DATA) - - let level: AiOptInLevel - - if (hasAiSqlTag && hasAiDataTag) { - level = 'schema_and_data' - } else if (hasAiSqlTag) { - level = 'schema' - } else { - level = 'disabled' - } + // Use the helper function + const level = getAiOptInLevel(optInTags) const disallowHipaa = useDisallowHipaa() return disallowHipaa(level !== 'disabled') ? level : 'disabled'