diff --git a/apps/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx b/apps/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx index af42e06dbd5..7e547897e80 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx +++ b/apps/studio/components/interfaces/Database/Wrappers/EditWrapper.tsx @@ -189,7 +189,10 @@ const EditWrapper = () => { // eslint-disable-next-line react-hooks/rules-of-hooks const [loadingSecrets, setLoadingSecrets] = useState(false) - const initialTables = formatWrapperTables(wrapper?.tables ?? []) + const initialTables = formatWrapperTables({ + handler: wrapper.handler, + tables: wrapper?.tables ?? [], + }) const hasFormChanges = JSON.stringify(values) !== JSON.stringify(initialValues) const hasTableChanges = JSON.stringify(initialTables) !== JSON.stringify(wrapperTables) const hasChanges = hasFormChanges || hasTableChanges diff --git a/apps/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts b/apps/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts index aebebdeb0d4..88e0ab69351 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts +++ b/apps/studio/components/interfaces/Database/Wrappers/Wrappers.constants.ts @@ -23,6 +23,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.STRIPE, validatorName: 'stripe_fdw_validator', icon: `${BASE_PATH}/img/icons/stripe-icon.svg`, + description: 'Payment processing and subscription management', extensionName: 'StripeFdw', label: 'Stripe', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/stripe', @@ -1139,6 +1140,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.FIREBASE, validatorName: 'firebase_fdw_validator', icon: `${BASE_PATH}/img/icons/firebase-icon.svg`, + description: 'Backend-as-a-Service with real-time database', extensionName: 'FirebaseFdw', label: 'Firebase', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/firebase', @@ -1265,6 +1267,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.S3, validatorName: 's3_fdw_validator', icon: `${BASE_PATH}/img/icons/s3-icon.svg`, + description: 'Cloud object storage service', extensionName: 'S3Fdw', label: 'S3', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/s3', @@ -1348,6 +1351,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.CLICK_HOUSE, validatorName: 'click_house_fdw_validator', icon: `${BASE_PATH}/img/icons/clickhouse-icon.svg`, + description: 'Column-oriented analytics database', extensionName: 'ClickHouseFdw', label: 'ClickHouse', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/clickhouse', @@ -1392,6 +1396,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.BIG_QUERY, validatorName: 'big_query_fdw_validator', icon: `${BASE_PATH}/img/icons/bigquery-icon.svg`, + description: 'Serverless data warehouse and analytics', extensionName: 'BigQueryFdw', label: 'BigQuery', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/bigquery', @@ -1466,6 +1471,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.AIRTABLE, validatorName: 'airtable_fdw_validator', icon: `${BASE_PATH}/img/icons/airtable-icon.svg`, + description: 'No-code database and spreadsheet platform', extensionName: 'airtableFdw', label: 'Airtable', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/airtable', @@ -1508,6 +1514,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.LOGFLARE, validatorName: 'logflare_fdw_validator', icon: `${BASE_PATH}/img/icons/logflare-icon.svg`, + description: 'Log management and analytics service', extensionName: 'logflareFdw', label: 'Logflare', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/logflare', @@ -1543,6 +1550,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.AUTH0, validatorName: 'auth0_fdw_validator', icon: `${BASE_PATH}/img/icons/auth0-icon.svg`, + description: 'Identity and access management platform', extensionName: 'Auth0Fdw', label: 'Auth0', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/auth0', @@ -1673,6 +1681,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.COGNITO, validatorName: 'cognito_fdw_validator', icon: `${BASE_PATH}/img/icons/cognito-icon.svg`, + description: 'AWS user authentication and authorization', extensionName: 'CognitoFdw', label: 'Cognito', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/cognito', @@ -1756,6 +1765,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.MSSQL, validatorName: 'mssql_fdw_validator', icon: `${BASE_PATH}/img/icons/mssql-icon.svg`, + description: 'Microsoft SQL Server database', extensionName: 'mssqlFdw', label: 'Microsoft SQL Server', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/mssql', @@ -1792,6 +1802,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.REDIS, validatorName: 'redis_fdw_validator', icon: `${BASE_PATH}/img/icons/redis-icon.svg`, + description: 'In-memory data structure store', extensionName: 'redisFdw', label: 'Redis', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/redis', @@ -1874,6 +1885,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.PADDLE, validatorName: 'wasm_fdw_validator', icon: `${BASE_PATH}/img/icons/paddle-icon.svg`, + description: 'Subscription billing and payments platform', extensionName: 'paddleFdw', label: 'Paddle', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/paddle', @@ -1974,6 +1986,7 @@ export const WRAPPERS: WrapperMeta[] = [ handlerName: WRAPPER_HANDLERS.SNOWFLAKE, validatorName: 'wasm_fdw_validator', icon: `${BASE_PATH}/img/icons/snowflake-icon.svg`, + description: 'Cloud data warehouse platform', extensionName: 'snowflakeFdw', label: 'Snowflake', docsUrl: 'https://supabase.com/docs/guides/database/extensions/wrappers/snowflake', diff --git a/apps/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts b/apps/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts index 786db021133..70011183472 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts +++ b/apps/studio/components/interfaces/Database/Wrappers/Wrappers.types.ts @@ -3,6 +3,8 @@ export type WrapperMeta = { handlerName: string validatorName: string icon: string + /** Short description (one-sentence) used in lists of integrations */ + description: string extensionName: string label: string server: Server diff --git a/apps/studio/components/interfaces/Database/Wrappers/Wrappers.utils.ts b/apps/studio/components/interfaces/Database/Wrappers/Wrappers.utils.ts index fd7cf3cf2b9..3298e6d5b21 100644 --- a/apps/studio/components/interfaces/Database/Wrappers/Wrappers.utils.ts +++ b/apps/studio/components/interfaces/Database/Wrappers/Wrappers.utils.ts @@ -1,4 +1,4 @@ -import { FDW } from 'data/fdw/fdws-query' +import { FDW, FDWTable } from 'data/fdw/fdws-query' import { WRAPPERS, WRAPPER_HANDLERS } from './Wrappers.constants' import type { WrapperMeta } from './Wrappers.types' @@ -50,10 +50,24 @@ export const makeValidateRequired = (options: { name: string; required: boolean } } -export const formatWrapperTables = (wrapper: any, wrapperMeta?: WrapperMeta) => { +interface FormattedWrapperTable { + index: number + columns: { name: string }[] + is_new_schema: boolean + schema: string + schema_name: string + table_name: string + object?: string // From options object for Firebase/Stripe + [key: string]: any // For other dynamic options from table.options +} + +export const formatWrapperTables = ( + wrapper: { handler: string; tables?: FDWTable[] }, + wrapperMeta?: WrapperMeta +): FormattedWrapperTable[] => { const tables = wrapper?.tables ?? [] - return tables.map((table: any) => { + return tables.map((table) => { let index: number = 0 const options = Object.fromEntries(table.options.map((option: string) => option.split('='))) @@ -85,6 +99,7 @@ export const formatWrapperTables = (wrapper: any, wrapperMeta?: WrapperMeta) => return { ...options, index, + id: table.id, columns: table.columns, is_new_schema: false, schema: table.schema, @@ -98,7 +113,10 @@ export const convertKVStringArrayToJson = (values: string[]) => { return Object.fromEntries(values.map((value) => value.split('='))) } -export function wrapperMetaComparator(wrapperMeta: WrapperMeta, wrapper: FDW | undefined) { +export function wrapperMetaComparator( + wrapperMeta: Pick, + wrapper: FDW | undefined +) { if (wrapperMeta.handlerName === 'wasm_fdw_handler') { const serverOptions = convertKVStringArrayToJson(wrapper?.server_options ?? []) return ( diff --git a/apps/studio/components/interfaces/DiskManagement/ui/DiskSpaceBar.tsx b/apps/studio/components/interfaces/DiskManagement/ui/DiskSpaceBar.tsx index 26c007ef661..e75ac95346e 100644 --- a/apps/studio/components/interfaces/DiskManagement/ui/DiskSpaceBar.tsx +++ b/apps/studio/components/interfaces/DiskManagement/ui/DiskSpaceBar.tsx @@ -1,6 +1,6 @@ import { AnimatePresence, motion } from 'framer-motion' import { Info } from 'lucide-react' -import MotionNumber from 'motion-number' +import MotionNumber from '@number-flow/react' import { useTheme } from 'next-themes' import { UseFormReturn } from 'react-hook-form' @@ -64,14 +64,7 @@ export default function DiskSpaceBar({ form }: DiskSpaceBarProps) { {usedSize.toFixed(2)} GB used of - + {' '} GB diff --git a/apps/studio/pages/project/[ref]/integrations/graphiql.tsx b/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx similarity index 91% rename from apps/studio/pages/project/[ref]/integrations/graphiql.tsx rename to apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx index af3804e7554..bf7be14c44a 100644 --- a/apps/studio/pages/project/[ref]/integrations/graphiql.tsx +++ b/apps/studio/components/interfaces/Integrations/GraphQL/GraphiQLTab.tsx @@ -16,10 +16,8 @@ import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-ex import { API_URL, IS_PLATFORM } from 'lib/constants' import { getRoleImpersonationJWT } from 'lib/role-impersonation' import { useGetImpersonatedRole } from 'state/role-impersonation-state' -import type { NextPageWithLayout } from 'types' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' -const GraphiQLPage: NextPageWithLayout = () => { +export const GraphiQLTab = () => { const { resolvedTheme } = useTheme() const { ref: projectRef } = useParams() const { project } = useProjectContext() @@ -107,9 +105,3 @@ const GraphiQLPage: NextPageWithLayout = () => { return } - -GraphiQLPage.getLayout = (page) => ( - {page} -) - -export default GraphiQLPage diff --git a/apps/studio/components/interfaces/Integrations/Integration/BuildBySection.tsx b/apps/studio/components/interfaces/Integrations/Integration/BuildBySection.tsx new file mode 100644 index 00000000000..5cb36d2a21c --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Integration/BuildBySection.tsx @@ -0,0 +1,63 @@ +import { Book } from 'lucide-react' +import Link from 'next/link' +import { ComponentPropsWithoutRef, ElementRef, forwardRef } from 'react' + +import { cn } from 'ui' +import { IntegrationDefinition } from '../Landing/Integrations.constants' + +interface BuiltBySectionProps extends ComponentPropsWithoutRef<'div'> { + integration: IntegrationDefinition +} + +export const BuiltBySection = forwardRef, BuiltBySectionProps>( + ({ integration, className, ...props }, ref) => { + const { docsUrl } = integration + const { name, websiteUrl } = integration?.author ?? {} + + if (!name && !docsUrl && !websiteUrl) return null + + return ( +
+ {name && ( +
+
BUILT BY
+
{name}
+
+ )} + {docsUrl && ( +
+
DOCS
+ + + {docsUrl.includes('supabase.com/docs') + ? 'Supabase Docs' + : docsUrl.includes('github.com') + ? 'GitHub Docs' + : 'Documentation'} + +
+ )} + {websiteUrl && ( +
+
WEBSITE
+ + {websiteUrl.replace('https://', '')} + +
+ )} +
+ ) + } +) + +BuiltBySection.displayName = 'BuiltBySection' diff --git a/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx new file mode 100644 index 00000000000..c17d3158abb --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Integration/IntegrationOverviewTab.tsx @@ -0,0 +1,86 @@ +import { useRouter } from 'next/router' +import { PropsWithChildren, ReactNode } from 'react' + +import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' +import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { Badge, Separator } from 'ui' +import { Admonition } from 'ui-patterns' +import { INTEGRATIONS } from '../Landing/Integrations.constants' +import { BuiltBySection } from './BuildBySection' +import { MarkdownContent } from './MarkdownContent' +import { MissingExtensionAlert } from './MissingExtensionAlert' + +interface IntegrationOverviewTabProps { + actions?: ReactNode +} + +export const IntegrationOverviewTab = ({ + actions, + children, +}: PropsWithChildren) => { + const { id } = useParams() + const router = useRouter() + const project = useSelectedProject() + + const integration = INTEGRATIONS.find((i) => i.id === id) + + const dependsOnExtension = (integration?.requiredExtensions ?? []).length > 0 + + const { data: extensions } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const neededExtensions = (extensions ?? []).filter((ext) => + (integration?.requiredExtensions ?? []).includes(ext.name) + ) + + const hasMissingExtensions = neededExtensions.some((x) => !x.installed_version) + + if (!integration) { + return
Unsupported integration type
+ } + + return ( +
+ + {dependsOnExtension && ( +
+ + + Supabase + Database Extension + + `\`${x}\``).join(', ')} + extension${integration.requiredExtensions.length > 1 ? 's' : ''} directly in your Postgres database. + ${hasMissingExtensions ? `Install ${integration.requiredExtensions.length > 1 ? 'these' : 'this'} database extension${integration.requiredExtensions.length > 1 ? 's' : ''} to use ${integration.name} in your project.` : ''} + `} + /> + +
+ {neededExtensions.map((extension) => ( + + ))} +
+
+
+ )} + {!!actions && !hasMissingExtensions &&
{actions}
} + + + {children} +
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/Integration/MarkdownContent.tsx b/apps/studio/components/interfaces/Integrations/Integration/MarkdownContent.tsx new file mode 100644 index 00000000000..cd5e5ec7af6 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Integration/MarkdownContent.tsx @@ -0,0 +1,57 @@ +import { motion } from 'framer-motion' +import { useEffect, useState } from 'react' + +import { Markdown } from 'components/interfaces/Markdown' +import { cn } from 'ui' + +const CHAR_LIMIT = 500 // Adjust this number as needed + +export const MarkdownContent = ({ integrationId }: { integrationId: string }) => { + const [content, setContent] = useState('') + const [isExpanded, setIsExpanded] = useState(false) + + useEffect(() => { + import(`static-data/integrations/${integrationId}/overview.md`) + .then((module) => setContent(String(module.default))) + .catch((error) => console.error('Error loading markdown:', error)) + }, [integrationId]) + + const displayContent = isExpanded ? content : content.slice(0, CHAR_LIMIT) + const supportExpanding = content.length > CHAR_LIMIT || (content.match(/\n/g) || []).length > 1 + + if (displayContent.length === 0) return null + + return ( +
+
+ + + + {!isExpanded && ( +
+ )} + {supportExpanding && ( +
+ +
+ )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/Integration/MissingExtensionAlert.tsx b/apps/studio/components/interfaces/Integrations/Integration/MissingExtensionAlert.tsx new file mode 100644 index 00000000000..54526a943fd --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Integration/MissingExtensionAlert.tsx @@ -0,0 +1,27 @@ +import { useState } from 'react' + +import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal' +import { DatabaseExtension } from 'data/database-extensions/database-extensions-query' +import { Button } from 'ui' + +export const MissingExtensionAlert = ({ extension }: { extension: DatabaseExtension }) => { + const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false) + + const extensionInstalled = !!extension?.installed_version + if (!extensionInstalled) { + return ( + <> + + + setShowEnableExtensionModal(false)} + /> + + ) + } + return null +} diff --git a/apps/studio/components/interfaces/Integrations/Landing/AvailableIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/AvailableIntegrations.tsx new file mode 100644 index 00000000000..8a391518a38 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Landing/AvailableIntegrations.tsx @@ -0,0 +1,107 @@ +import { Search } from 'lucide-react' +import { useState } from 'react' + +import AlertError from 'components/ui/AlertError' +import NoSearchResults from 'components/ui/NoSearchResults' +import { Tabs_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_ } from 'ui' +import { Input } from 'ui-patterns/DataInputs/Input' +import { IntegrationCard, IntegrationLoadingCard } from './IntegrationCard' +import { useInstalledIntegrations } from './useInstalledIntegrations' +import { Admonition } from 'ui-patterns' + +type IntegrationCategory = 'all' | 'wrapper' | 'postgres_extensions' | 'custom' + +export const AvailableIntegrations = () => { + const [search, setSearch] = useState('') + const [selectedCategory, setSelectedCategory] = useState('all') + + const { availableIntegrations, installedIntegrations, error, isError, isLoading, isSuccess } = + useInstalledIntegrations() + + const installedIds = installedIntegrations.map((i) => i.id) + + // available integrations for install + const integrationsByCategory = + selectedCategory === 'all' + ? availableIntegrations.filter((i) => !installedIds.includes(i.id)) + : availableIntegrations.filter( + (i) => !installedIds.includes(i.id) && i.type === selectedCategory + ) + const filteredIntegrations = ( + search.length > 0 + ? integrationsByCategory.filter((i) => i.name.toLowerCase().includes(search.toLowerCase())) + : integrationsByCategory + ).sort((a, b) => a.name.localeCompare(b.name)) + + return ( + <> + setSelectedCategory(value as IntegrationCategory)} + > + + setSearch(e.target.value)} + containerClassName="group w-40" + icon={ + + } + iconContainerClassName="p-0" + className="pl-7 rounded-none !border-0 border-transparent bg-transparent !shadow-none !ring-0 !ring-offset-0" + placeholder="Search..." + /> + + All Integrations + + + Foreign Data Wrappers + + + Postgres Extensions + + + +
+

Available integrations

+
+ {isLoading && + new Array(3) + .fill(0) + .map((_, idx) => )} + {isError && ( + + )} + {isSuccess && filteredIntegrations.map((i) => )} + {isSuccess && search.length > 0 && filteredIntegrations.length === 0 && ( + setSearch('')} + /> + )} + {isSuccess && + selectedCategory !== 'all' && + search.length === 0 && + filteredIntegrations.length === 0 && ( + + )} +
+
+ + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Landing/InstalledIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/InstalledIntegrations.tsx new file mode 100644 index 00000000000..30c3835ef02 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Landing/InstalledIntegrations.tsx @@ -0,0 +1,39 @@ +import AlertError from 'components/ui/AlertError' +import { IntegrationCard, IntegrationLoadingCard } from './IntegrationCard' +import { useInstalledIntegrations } from './useInstalledIntegrations' + +export const InstalledIntegrations = () => { + const { installedIntegrations, error, isLoading, isSuccess, isError } = useInstalledIntegrations() + + return ( +
+

Installed integrations

+
+ {isLoading && + new Array(3) + .fill(0) + .map((_, idx) => )} + {isError && ( + + )} + {isSuccess && ( + <> + {installedIntegrations.length === 0 ? ( +
+ {/* [Joshen] Not high priority imo - very low chance this state will be seen cause Vault is always installed */} + {/* Some placeholder image when no integrations are installed */} +

No integrations installed yet

+
+ ) : ( + installedIntegrations.map((i) => ) + )} + + )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/Landing/IntegrationCard.tsx b/apps/studio/components/interfaces/Integrations/Landing/IntegrationCard.tsx new file mode 100644 index 00000000000..3134a304c41 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Landing/IntegrationCard.tsx @@ -0,0 +1,59 @@ +import Link from 'next/link' + +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { Badge, cn } from 'ui' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import { IntegrationDefinition } from './Integrations.constants' + +type IntegrationCardProps = IntegrationDefinition & {} + +const INTEGRATION_CARD_STYLE = cn( + 'w-full h-full pl-5 pr-6 py-3 bg-surface-100 hover:bg-surface-200 hover:border-strong', + 'border border-border gap-3 rounded-md inline-flex ease-out duration-200 transition-all' +) + +export const IntegrationLoadingCard = () => { + return ( +
+
+ +
+
+
+ + +
+
+
+ ) +} + +export const IntegrationCard = ({ id, beta, name, icon, description }: IntegrationCardProps) => { + const { project } = useProjectContext() + + return ( + +
+
+ {icon()} +
+
+
+
+

{name}

+ {beta && ( + + Beta + + )} +
+

{description}

+
+ + Official + +
+
+ + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx new file mode 100644 index 00000000000..5745c362df6 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Landing/Integrations.constants.tsx @@ -0,0 +1,384 @@ +import { Clock5, Layers, Vault, Webhook } from 'lucide-react' +import Image from 'next/image' +import { ComponentType, ReactNode } from 'react' + +import { WRAPPERS } from 'components/interfaces/Database/Wrappers/Wrappers.constants' +import { WrapperMeta } from 'components/interfaces/Database/Wrappers/Wrappers.types' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { BASE_PATH } from 'lib/constants' +import dynamic from 'next/dynamic' +import { cn } from 'ui' + +export type Navigation = { + route: string + label: string + hasChild?: boolean + childIcon?: React.ReactNode + children?: Navigation[] +} + +const Loading = () => ( +
+ +
+) + +export type IntegrationDefinition = { + id: string + name: string + beta?: boolean + icon: (props?: { className?: string; style?: Record }) => ReactNode + description: string + docsUrl: string + author: { + name: string + websiteUrl: string + } + requiredExtensions: string[] + navigation?: Navigation[] + navigate: ( + id: string, + pageId: string | undefined, + childId: string | undefined + ) => ComponentType<{}> | null +} & ({ type: 'wrapper'; meta: WrapperMeta } | { type: 'postgres_extension' } | { type: 'custom' }) + +const authorSupabase = { + name: 'Supabase', + websiteUrl: 'https://supabase.com', +} + +const supabaseIntegrations: IntegrationDefinition[] = [ + { + id: 'queues', + type: 'postgres_extension' as const, + requiredExtensions: ['pgmq'], + name: `Queues`, + icon: ({ className, ...props } = {}) => ( + + ), + description: 'Lightweight message queue in Postgres', + docsUrl: 'https://github.com/tembo-io/pgmq', + author: { + name: 'pgmq', + websiteUrl: 'https://github.com/tembo-io/pgmq', + }, + navigation: [ + { + route: 'overview', + label: 'Overview', + }, + { + route: 'queues', + label: 'Queues', + hasChild: true, + childIcon: ( + + ), + }, + ], + navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => { + if (childId) { + return dynamic( + () => + import('components/interfaces/Integrations/NewQueues/QueueTab').then( + (mod) => mod.QueueTab + ), + { + loading: Loading, + } + ) + } + switch (pageId) { + case 'overview': + return dynamic( + () => + import('components/interfaces/Integrations/Integration/IntegrationOverviewTab').then( + (mod) => mod.IntegrationOverviewTab + ), + { + loading: Loading, + } + ) + case 'queues': + return dynamic( + () => + import('components/interfaces/Integrations/NewQueues/QueuesTab').then( + (mod) => mod.QueuesTab + ), + { + loading: Loading, + } + ) + } + return null + }, + }, + { + id: 'cron-jobs', + type: 'postgres_extension' as const, + requiredExtensions: ['pg_cron'], + name: `Cron Jobs`, + icon: ({ className, ...props } = {}) => ( + + ), + description: 'Schedule and automate tasks to run maintenance routines at specified intervals.', + docsUrl: 'https://github.com/citusdata/pg_cron', + author: { + name: 'pg_cron', + websiteUrl: 'https://github.com/citusdata/pg_cron', + }, + navigation: [ + { + route: 'overview', + label: 'Overview', + }, + { + route: 'cron-jobs', + label: 'Cron Jobs', + }, + ], + navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => { + switch (pageId) { + case 'overview': + return dynamic( + () => + import('components/interfaces/Integrations/Integration/IntegrationOverviewTab').then( + (mod) => mod.IntegrationOverviewTab + ), + { + loading: Loading, + } + ) + case 'cron-jobs': + return dynamic( + () => + import('components/interfaces/Integrations/NewCronJobs/CronjobsTab').then( + (mod) => mod.CronjobsTab + ), + { + loading: Loading, + } + ) + } + return null + }, + }, + { + id: 'vault', + type: 'postgres_extension' as const, + requiredExtensions: ['supabase_vault'], + name: `Vault`, + beta: true, + icon: ({ className, ...props } = {}) => ( + + ), + description: 'Application level encryption for your project', + docsUrl: 'https://supabase.com/docs', + author: authorSupabase, + navigation: [ + { + route: 'overview', + label: 'Overview', + }, + { + route: 'keys', + label: 'Keys', + }, + { + route: 'secrets', + label: 'Secrets', + }, + ], + navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => { + switch (pageId) { + case 'overview': + return dynamic( + () => + import('components/interfaces/Integrations/Integration/IntegrationOverviewTab').then( + (mod) => mod.IntegrationOverviewTab + ), + { + loading: Loading, + } + ) + case 'keys': + return dynamic( + () => + import('components/interfaces/Settings/Vault').then( + (mod) => mod.EncryptionKeysManagement + ), + { + loading: Loading, + } + ) + case 'secrets': + return dynamic( + () => + import('components/interfaces/Settings/Vault').then((mod) => mod.SecretsManagement), + { + loading: Loading, + } + ) + } + return null + }, + }, + { + id: 'webhooks', + type: 'custom' as const, + name: `Webhooks`, + icon: ({ className, ...props } = {}) => ( + + ), + description: + 'Send real-time data from your database to another system when a table event occurs', + docsUrl: 'https://supabase.com/docs', + author: authorSupabase, + requiredExtensions: [], + navigation: [ + { + route: 'overview', + label: 'Overview', + }, + { + route: 'webhooks', + label: 'Webhooks', + }, + ], + navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => { + switch (pageId) { + case 'overview': + return dynamic( + () => + import('components/interfaces/Integrations/Webhooks/OverviewTab').then( + (mod) => mod.WebhooksOverviewTab + ), + { + loading: Loading, + } + ) + case 'webhooks': + return dynamic( + () => + import('components/interfaces/Integrations/Webhooks/ListTab').then( + (mod) => mod.WebhooksListTab + ), + { + loading: Loading, + } + ) + } + return null + }, + }, + { + id: 'graphiql', + type: 'postgres_extension' as const, + requiredExtensions: ['pg_graphql'], + name: `GraphiQL`, + icon: ({ className, ...props } = {}) => ( + GraphiQL + ), + description: 'Run GraphQL queries through our interactive in-browser IDE', + docsUrl: 'https://supabase.com/docs', + author: authorSupabase, + navigation: [ + { + route: 'overview', + label: 'Overview', + }, + { + route: 'graphiql', + label: 'GraphiQL', + }, + ], + navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => { + switch (pageId) { + case 'overview': + return dynamic( + () => + import('components/interfaces/Integrations/Integration/IntegrationOverviewTab').then( + (mod) => mod.IntegrationOverviewTab + ), + { + loading: Loading, + } + ) + case 'graphiql': + return dynamic( + () => + import('components/interfaces/Integrations/GraphQL/GraphiQLTab').then( + (mod) => mod.GraphiQLTab + ), + { + loading: Loading, + } + ) + } + return null + }, + }, +] as const + +const wrapperIntegrations: IntegrationDefinition[] = WRAPPERS.map((w) => { + return { + id: w.name, + type: 'wrapper' as const, + name: `${w.label} Wrapper`, + icon: ({ className, ...props } = {}) => ( + {w.name} + ), + requiredExtensions: ['wrappers', 'supabase_vault'], + description: w.description, + docsUrl: w.docsUrl, + meta: w, + author: authorSupabase, + navigation: [ + { + route: 'overview', + label: 'Overview', + }, + { + route: 'wrappers', + label: 'Wrappers', + }, + ], + navigate: (id: string, pageId: string = 'overview', childId: string | undefined) => { + switch (pageId) { + case 'overview': + return dynamic( + () => + import('components/interfaces/Integrations/Wrappers/OverviewTab').then( + (mod) => mod.WrapperOverviewTab + ), + { + loading: Loading, + } + ) + case 'wrappers': + return dynamic( + () => + import('components/interfaces/Integrations/Wrappers/WrappersTab').then( + (mod) => mod.WrappersTab + ), + { + loading: Loading, + } + ) + } + return null + }, + } +}) + +export const INTEGRATIONS: IntegrationDefinition[] = [ + ...wrapperIntegrations, + ...supabaseIntegrations, +] diff --git a/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx new file mode 100644 index 00000000000..d7bde8d37b2 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Landing/useInstalledIntegrations.tsx @@ -0,0 +1,103 @@ +import { useMemo } from 'react' + +import { wrapperMetaComparator } from 'components/interfaces/Database/Wrappers/Wrappers.utils' +import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' +import { useSchemasQuery } from 'data/database/schemas-query' +import { useFDWsQuery } from 'data/fdw/fdws-query' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { EMPTY_ARR } from 'lib/void' +import { INTEGRATIONS } from './Integrations.constants' + +export const useInstalledIntegrations = () => { + const project = useSelectedProject() + + const { + data, + error: fdwError, + isError: isErrorFDWs, + isLoading: isFDWLoading, + isSuccess: isSuccessFDWs, + } = useFDWsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const { + data: extensions, + error: extensionsError, + isError: isErrorExtensions, + isLoading: isExtensionsLoading, + isSuccess: isSuccessExtensions, + } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const { + data: schemas, + error: schemasError, + isError: isErrorSchemas, + isLoading: isSchemasLoading, + isSuccess: isSuccessSchemas, + } = useSchemasQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const isHooksEnabled = schemas?.some((schema) => schema.name === 'supabase_functions') + const wrappers = useMemo(() => data ?? EMPTY_ARR, [data]) + + const installedIntegrations = useMemo(() => { + return INTEGRATIONS.filter((i) => { + // special handling for supabase webhooks + if (i.id === 'webhooks') { + return isHooksEnabled + } + if (i.type === 'wrapper') { + return wrappers.find((w) => wrapperMetaComparator(i.meta, w)) + } + if (i.type === 'postgres_extension') { + return i.requiredExtensions.every((extName) => { + const foundExtension = (extensions ?? []).find((ext) => ext.name === extName) + return !!foundExtension?.installed_version + }) + } + return false + }).sort((a, b) => a.name.localeCompare(b.name)) + }, [wrappers, extensions, isHooksEnabled]) + + // available integrations are all integrations that can be installed. If an extension is on available to be installed + // on the project, that integration is hidden. + const availableIntegrations = useMemo(() => { + return INTEGRATIONS.filter((i) => { + // special handling for supabase webhooks + if (i.id === 'webhooks') { + return true + } + if (i.type === 'wrapper') { + return true + } + if (i.type === 'postgres_extension') { + return i.requiredExtensions.every((extName) => { + const foundExtension = (extensions ?? []).find((ext) => ext.name === extName) + return !!foundExtension + }) + } + return false + }).sort((a, b) => a.name.localeCompare(b.name)) + }, [extensions]) + + const error = fdwError || extensionsError || schemasError + const isLoading = isSchemasLoading || isFDWLoading || isExtensionsLoading + const isError = isErrorFDWs || isErrorExtensions || isErrorSchemas + const isSuccess = isSuccessFDWs && isSuccessExtensions && isSuccessSchemas + + return { + // show all integrations at once instead of showing partial results + installedIntegrations: isLoading ? EMPTY_ARR : installedIntegrations, + availableIntegrations: isLoading ? EMPTY_ARR : availableIntegrations, + error, + isError, + isLoading, + isSuccess, + } +} diff --git a/apps/studio/components/interfaces/Integrations/NewCronJobs/CronjobsTab.tsx b/apps/studio/components/interfaces/Integrations/NewCronJobs/CronjobsTab.tsx new file mode 100644 index 00000000000..dc98931b38d --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/NewCronJobs/CronjobsTab.tsx @@ -0,0 +1,135 @@ +import { useState } from 'react' + +import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { CronJob, useCronJobsQuery } from 'data/database-cron-jobs/database-cron-jobs-query' +import { Search } from 'lucide-react' +import { parseAsString, useQueryState } from 'nuqs' +import { Button, Input, Sheet, SheetContent } from 'ui' +import { CronJobCard } from '../CronJobs/CronJobCard' +import DeleteCronJob from '../CronJobs/DeleteCronJob' + +export const CronjobsTab = () => { + const { project } = useProjectContext() + + const [searchQuery, setSearchQuery] = useQueryState('search', parseAsString.withDefault('')) + + // used for confirmation prompt in the Create Cron Job Sheet + const [isClosingCreateCronJobSheet, setIsClosingCreateCronJobSheet] = useState(false) + const [createCronJobSheetShown, setCreateCronJobSheetShown] = useState< + Pick | undefined + >() + const [cronJobForDeletion, setCronJobForDeletion] = useState() + + const { data: cronJobs, isLoading } = useCronJobsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + if (isLoading) + return ( +
+ +
+ ) + + const filteredCronJobs = (cronJobs ?? []).filter((cj) => cj.jobname.includes(searchQuery)) + + return ( + <> +
+ {(cronJobs ?? []).length == 0 ? ( +
+

No cron jobs created yet

+ +
+ ) : ( +
+
+ } + value={searchQuery || ''} + className="w-64" + onChange={(e) => setSearchQuery(e.target.value)} + /> + + +
+ {filteredCronJobs.length === 0 ? ( +
+

No results found

+

+ Your search for "{searchQuery}" did not return any results +

+
+ ) : ( + filteredCronJobs.map((job) => ( + setCreateCronJobSheetShown(job)} + onDeleteCronJob={(job) => setCronJobForDeletion(job)} + /> + )) + )} +
+ )} +
+ + setCronJobForDeletion(undefined)} + cronJob={cronJobForDeletion!} + /> + + setIsClosingCreateCronJobSheet(true)} + > + + { + setIsClosingCreateCronJobSheet(false) + setCreateCronJobSheetShown(undefined) + }} + isClosing={isClosingCreateCronJobSheet} + setIsClosing={setIsClosingCreateCronJobSheet} + /> + + + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/NewQueues/CreateQueueSheet.tsx b/apps/studio/components/interfaces/Integrations/NewQueues/CreateQueueSheet.tsx new file mode 100644 index 00000000000..9835e9cd3a7 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/NewQueues/CreateQueueSheet.tsx @@ -0,0 +1,337 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { SubmitHandler, useForm } from 'react-hook-form' +import { toast } from 'sonner' +import z from 'zod' + +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' +import { useDatabaseQueueCreateMutation } from 'data/database-queues/database-queues-create-mutation' +import { + Badge, + Button, + Form_Shadcn_, + FormControl_Shadcn_, + FormField_Shadcn_, + Input, + Input_Shadcn_, + RadioGroupStacked, + RadioGroupStackedItem, + Separator, + SheetFooter, + SheetHeader, + SheetSection, + SheetTitle, +} from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' +import { QUEUE_TYPES } from './Queues.constants' + +export interface CreateQueueSheetProps { + isClosing: boolean + setIsClosing: (v: boolean) => void + onClose: () => void +} + +const normalQueueSchema = z.object({ + type: z.literal('basic'), +}) + +const partitionedQueueSchema = z.object({ + type: z.literal('partitioned'), + partitionInterval: z.number(), + retentionInterval: z.number(), +}) + +const unloggedQueueSchema = z.object({ + type: z.literal('unlogged'), +}) + +const FormSchema = z.object({ + name: z + .string() + .trim() + .min(1, 'Please provide a name for your queue') + .max(47, "The name can't be longer than 47 characters"), + values: z.discriminatedUnion('type', [ + normalQueueSchema, + partitionedQueueSchema, + unloggedQueueSchema, + ]), +}) + +export type CreateQueueForm = z.infer +export type QueueType = CreateQueueForm['values'] + +const FORM_ID = 'create-queue-sidepanel' + +export const CreateQueueSheet = ({ isClosing, setIsClosing, onClose }: CreateQueueSheetProps) => { + // const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false) + const { mutate: createQueue, isLoading } = useDatabaseQueueCreateMutation() + + // const canToggleExtensions = useCheckPermissions( + // PermissionAction.TENANT_SQL_ADMIN_WRITE, + // 'extensions' + // ) + + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + name: '', + values: { + type: 'basic', + }, + }, + }) + + const { project } = useProjectContext() + const isEdited = form.formState.isDirty + + // if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet + if (!isEdited && isClosing) { + onClose() + } + + const onClosePanel = () => { + if (isEdited) { + setIsClosing(true) + } else { + onClose() + } + } + + const onSubmit: SubmitHandler = async ({ name, values }) => { + let query = `SELECT pgmq.create('${name}');` + if (values.type === 'partitioned') { + query = `select from pgmq.create_partitioned('${name}', '${values.partitionInterval}', '${values.retentionInterval}');` + } + if (values.type === 'unlogged') { + query = `SELECT pgmq.create_unlogged('${name}');` + } + + createQueue( + { + projectRef: project!.ref, + connectionString: project?.connectionString, + query, + }, + { + onSuccess: () => { + toast.success(`Successfully created queue ${name}`) + onClose() + }, + } + ) + } + + const { data } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const pgPartmanExtension = (data ?? []).find((ext) => ext.name === 'pg_partman') + const pgPartmanExtensionInstalled = pgPartmanExtension?.installed_version != undefined + + const queueType = form.watch('values.type') + + return ( + <> +
+ + Create a new queue + + +
+ +
+ + ( + + + + + + )} + /> + + + + ( + + + + {QUEUE_TYPES.map((definition) => ( + +
+
{definition.icon}
+
+
+

{definition.label}

+
+

+ {definition.description} +

+
+
+ {definition.value === 'partitioned' ? ( +
+ COMING SOON +
+ ) : null} + {/* {!pgPartmanExtensionInstalled && + definition.value === 'partitioned' ? ( +
+ + + pg_partman needs to be installed to use this type + +
+ ) : null} */} +
+ ))} +
+
+
+ )} + /> + {/* {!pgPartmanExtensionInstalled && ( + + Enable pg_partman for partitioned + queues + + } + description={ +
+ + This will allow you to create partitioned queues which can handle a large + amount of messages + + setShowEnableExtensionModal(true)} + tooltip={{ + content: { + side: 'bottom', + text: 'You need additional permissions to enable database extensions', + }, + }} + > + Install pg_partman extension + +
+ } + /> + )} */} +
+ + {queueType === 'partitioned' && ( + <> + + ( + + ms

} + /> +
+ )} + /> + ( + + ms

} + /> +
+ )} + /> +
+ + )} + +
+
+ + + + +
+ setIsClosing(false)} + onConfirm={() => onClose()} + > +

+ There are unsaved changes. Are you sure you want to close the panel? Your changes will be + lost. +

+
+ {/* {pgPartmanExtension && ( + setShowEnableExtensionModal(false)} + /> + )} */} + + ) +} diff --git a/apps/studio/pages/project/[ref]/integrations/queues/[name].tsx b/apps/studio/components/interfaces/Integrations/NewQueues/QueueTab.tsx similarity index 64% rename from apps/studio/pages/project/[ref]/integrations/queues/[name].tsx rename to apps/studio/components/interfaces/Integrations/NewQueues/QueueTab.tsx index 1ed8a0dfd97..0fa0c08fd91 100644 --- a/apps/studio/pages/project/[ref]/integrations/queues/[name].tsx +++ b/apps/studio/components/interfaces/Integrations/NewQueues/QueueTab.tsx @@ -1,5 +1,4 @@ import { Paintbrush, Trash2 } from 'lucide-react' -import Link from 'next/link' import { useMemo, useState } from 'react' import { useParams } from 'common' @@ -9,32 +8,12 @@ import { QUEUE_MESSAGE_TYPE } from 'components/interfaces/Integrations/Queues/Si import { QueueMessagesDataGrid } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid' import { QueueFilters } from 'components/interfaces/Integrations/Queues/SingleQueue/QueueFilters' import { SendMessageModal } from 'components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useQueueMessagesInfiniteQuery } from 'data/database-queues/database-queue-messages-infinite-query' -import type { NextPageWithLayout } from 'types' -import { - Breadcrumb_Shadcn_, - BreadcrumbItem_Shadcn_, - BreadcrumbLink_Shadcn_, - BreadcrumbList_Shadcn_, - BreadcrumbPage_Shadcn_, - BreadcrumbSeparator_Shadcn_, - Button, - LoadingLine, - Separator, -} from 'ui' +import { Button, LoadingLine, Separator } from 'ui' -const QueueMessagesPage: NextPageWithLayout = () => { - // TODO: Change this to the correct permissions - // const canReadFunctions = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'functions') - // const isPermissionsLoaded = usePermissionsLoaded() - - // if (isPermissionsLoaded && !canReadFunctions) { - // return - // } - - const { name: queueName } = useParams() +export const QueueTab = () => { + const { childId: queueName } = useParams() const { project } = useProjectContext() const [sendMessageModalShown, setSendMessageModalShown] = useState(false) const [purgeQueueModalShown, setPurgeQueueModalShown] = useState(false) @@ -59,22 +38,7 @@ const QueueMessagesPage: NextPageWithLayout = () => { return (
-
-
- - - - - Queues - - - - - {queueName} - - - -
+
) } - -QueueMessagesPage.getLayout = (page) => ( - {page} -) - -export default QueueMessagesPage diff --git a/apps/studio/components/interfaces/Integrations/NewQueues/Queues.constants.tsx b/apps/studio/components/interfaces/Integrations/NewQueues/Queues.constants.tsx new file mode 100644 index 00000000000..a80a56be71e --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/NewQueues/Queues.constants.tsx @@ -0,0 +1,23 @@ +import { Rows2, Rows3, Rows4 } from 'lucide-react' + +export const QUEUE_TYPES = [ + { + value: 'basic', + icon: , + label: 'Basic queue', + description: 'Create a basic queue.', + }, + { + value: 'unlogged', + icon: , + label: 'Unlogged queue', + description: + 'Creates an unlogged queue which loses all data on database restart. Can be useful when write throughput is more important than durability.', + }, + { + value: 'partitioned', + icon: , + label: 'Partitioned queue', + description: 'Create a partitioned queue which is optimized for large amount of messages', + }, +] as const diff --git a/apps/studio/components/interfaces/Integrations/NewQueues/QueuesDisabledState.tsx b/apps/studio/components/interfaces/Integrations/NewQueues/QueuesDisabledState.tsx new file mode 100644 index 00000000000..5de88c73820 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/NewQueues/QueuesDisabledState.tsx @@ -0,0 +1,79 @@ +import * as Tooltip from '@radix-ui/react-tooltip' +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useState } from 'react' + +import EnableExtensionModal from 'components/interfaces/Database/Extensions/EnableExtensionModal' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' +import { DocsButton } from 'components/ui/DocsButton' +import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { Button } from 'ui' + +export const QueuesDisabledState = () => { + const [showEnableExtensionModal, setShowEnableExtensionModal] = useState(false) + const { project } = useProjectContext() + const canToggleExtensions = useCheckPermissions( + PermissionAction.TENANT_SQL_ADMIN_WRITE, + 'extensions' + ) + + const { data } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const pgmqExtension = (data ?? []).find((ext) => ext.name === 'pgmq') + + if (!pgmqExtension) return null + + return ( + <> +
+ +
+

+ Message queues in PostgreSQL allow you to handle asynchronous tasks, manage workloads, + and enable reliable communication between different parts of your application. +

+

Enable queues in your project.

+
+ +
+ + + + + {!canToggleExtensions && ( + + + +
+ + You need additional permissions to enable pgmq for this project + +
+
+
+ )} +
+ +
+
+
+ + setShowEnableExtensionModal(false)} + /> + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/NewQueues/QueuesRows.tsx b/apps/studio/components/interfaces/Integrations/NewQueues/QueuesRows.tsx new file mode 100644 index 00000000000..ca79a1a42d5 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/NewQueues/QueuesRows.tsx @@ -0,0 +1,100 @@ +import dayjs from 'dayjs' +import { includes, sortBy } from 'lodash' +import { ChevronRight, Loader2 } from 'lucide-react' +import { useRouter } from 'next/router' + +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import Table from 'components/to-be-cleaned/Table' +import { useQueuesMetricsQuery } from 'data/database-queues/database-queues-metrics-query' +import { PostgresQueue } from 'data/database-queues/database-queues-query' +import { DATETIME_FORMAT } from 'lib/constants' + +interface QueuesRowsProps { + queues: PostgresQueue[] + filterString: string +} + +const QueueRow = ({ queue }: { queue: PostgresQueue }) => { + const router = useRouter() + const { project: selectedProject } = useProjectContext() + + const { data: metrics, isLoading } = useQueuesMetricsQuery( + { + queueName: queue.queue_name, + projectRef: selectedProject?.ref, + connectionString: selectedProject?.connectionString, + }, + { + staleTime: 30 * 1000, // 30 seconds, talk with Oli whether this is ok to call every minute + } + ) + + const type = queue.is_partitioned ? 'Partitioned' : queue.is_unlogged ? 'Unlogged' : 'Basic' + + return ( + { + router.push( + `/project/${selectedProject?.ref}/integrations/queues/queues/${queue.queue_name}` + ) + }} + > + +

{queue.queue_name}

+
+ +

+ {type} +

+
+ +

{dayjs(queue.created_at).format(DATETIME_FORMAT)}

+
+ +
+ {isLoading ? ( + + ) : ( +

+ {metrics?.queue_length} {metrics?.method === 'estimated' ? '(Approximate)' : null} +

+ )} +
+
+ +
+ +
+
+
+ ) +} + +export const QueuesRows = ({ queues: fetchedQueues, filterString }: QueuesRowsProps) => { + const filteredQueues = fetchedQueues.filter((x) => + includes(x.queue_name.toLowerCase(), filterString.toLowerCase()) + ) + const queues = sortBy(filteredQueues, (func) => func.queue_name.toLocaleLowerCase()) + + if (queues.length === 0 && filterString.length > 0) { + return ( + + +

No results found

+

+ Your search for "{filterString}" did not return any results +

+
+
+ ) + } + + return ( + <> + {queues.map((q) => ( + + ))} + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/NewQueues/QueuesTab.tsx b/apps/studio/components/interfaces/Integrations/NewQueues/QueuesTab.tsx new file mode 100644 index 00000000000..fa1db6f9bb8 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/NewQueues/QueuesTab.tsx @@ -0,0 +1,110 @@ +import { Search } from 'lucide-react' +import { useQueryState } from 'nuqs' +import { useState } from 'react' + +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import Table from 'components/to-be-cleaned/Table' +import AlertError from 'components/ui/AlertError' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useQueuesQuery } from 'data/database-queues/database-queues-query' +import { Button, Input, Sheet, SheetContent } from 'ui' +import { CreateQueueSheet } from './CreateQueueSheet' +import { QueuesRows } from './QueuesRows' + +export const QueuesTab = () => { + const { project } = useProjectContext() + + // used for confirmation prompt in the Create Queue Sheet + const [isClosingCreateQueueSheet, setIsClosingCreateQueueSheet] = useState(false) + const [createQueueSheetShown, setCreateQueueSheetShown] = useState(false) + + const { + data: queues, + error, + isLoading, + isError, + } = useQueuesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const [searchQuery, setSearchQuery] = useQueryState('search') + + if (isLoading) + return ( +
+ +
+ ) + if (isError) + return ( +
+ +
+ ) + + return ( + <> +
+ {queues.length === 0 ? ( +
+

No queues created yet

+ +
+ ) : ( +
+
+ } + value={searchQuery || ''} + className="w-64" + onChange={(e) => setSearchQuery(e.target.value)} + /> + + +
+ + + Name + + Type + + + Created at + + +
Size
+
+ + + } + body={} + /> + + )} + + + setIsClosingCreateQueueSheet(true)}> + + { + setIsClosingCreateQueueSheet(false) + setCreateQueueSheetShown(false) + }} + isClosing={isClosingCreateQueueSheet} + setIsClosing={setIsClosingCreateQueueSheet} + /> + + + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/NewQueues/SingleQueue/DeleteQueue.tsx b/apps/studio/components/interfaces/Integrations/NewQueues/SingleQueue/DeleteQueue.tsx new file mode 100644 index 00000000000..20c9e6812f8 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/NewQueues/SingleQueue/DeleteQueue.tsx @@ -0,0 +1,62 @@ +import { useRouter } from 'next/router' +import { toast } from 'sonner' + +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useDatabaseQueueDeleteMutation } from 'data/database-queues/database-queues-delete-mutation' +import TextConfirmModal from 'ui-patterns/Dialogs/TextConfirmModal' + +interface DeleteQueueProps { + queueName: string + visible: boolean + onClose: () => void +} + +const DeleteQueue = ({ queueName, visible, onClose }: DeleteQueueProps) => { + const { project } = useProjectContext() + const router = useRouter() + + const { mutate: deleteDatabaseQueue, isLoading } = useDatabaseQueueDeleteMutation({ + onSuccess: () => { + toast.success(`Successfully removed queue ${queueName}`) + router.push(`/project/${project?.ref}/integrations/queues`) + onClose() + }, + }) + + async function handleDelete() { + if (!project) return console.error('Project is required') + + deleteDatabaseQueue({ + queueName: queueName, + projectRef: project.ref, + connectionString: project.connectionString, + }) + } + + if (!queueName) { + return null + } + + return ( + onClose()} + onConfirm={handleDelete} + title="Delete this queue" + loading={isLoading} + confirmLabel={`Delete queue ${queueName}`} + confirmPlaceholder="Type in name of queue" + confirmString={queueName ?? 'Unknown'} + text={ + <> + This will delete the queue{' '} + {queueName} + + } + alert={{ title: 'You cannot recover this queue and its messages once deleted.' }} + /> + ) +} + +export default DeleteQueue diff --git a/apps/studio/components/interfaces/Integrations/NewQueues/SingleQueue/MessageDetailsPanel.tsx b/apps/studio/components/interfaces/Integrations/NewQueues/SingleQueue/MessageDetailsPanel.tsx new file mode 100644 index 00000000000..a2c6ead53aa --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/NewQueues/SingleQueue/MessageDetailsPanel.tsx @@ -0,0 +1,224 @@ +import { useEscapeKeydown } from '@radix-ui/react-use-escape-keydown' +import { isNil, noop } from 'lodash' +import { Archive, Clock12, Trash2, X } from 'lucide-react' +import { useState } from 'react' + +import { useParams } from 'common' +import { MonacoEditor } from 'components/grid/components/common/MonacoEditor' +import { RowAction, RowData } from 'components/interfaces/Auth/Users/UserOverview' +import { useDatabaseQueueMessageArchiveMutation } from 'data/database-queues/database-queue-messages-archive-mutation' +import { useDatabaseQueueMessageDeleteMutation } from 'data/database-queues/database-queue-messages-delete-mutation' +import { PostgresQueueMessage } from 'data/database-queues/database-queue-messages-infinite-query' +import { useDatabaseQueueMessageReadMutation } from 'data/database-queues/database-queue-messages-read-mutation' +import dayjs from 'dayjs' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { prettifyJSON } from 'lib/helpers' +import { + Button, + ResizablePanel, + Separator, + Tabs_Shadcn_, + TabsContent_Shadcn_, + TabsList_Shadcn_, + TabsTrigger_Shadcn_, +} from 'ui' + +export const DATE_FORMAT = 'DD MMM, YYYY HH:mm' + +interface MessageDetailsPanelProps { + selectedMessage: PostgresQueueMessage + setSelectedMessage: (value: number | null) => void +} + +const tryFormatInitialValue = (value: string) => { + try { + const jsonValue = JSON.parse(value) + return JSON.stringify(jsonValue) + } catch (err) { + if (typeof value === 'string') { + return value.replaceAll(`\"`, `"`) + } else { + return JSON.stringify(value) + } + } +} + +export const MessageDetailsPanel = ({ + selectedMessage, + setSelectedMessage, +}: MessageDetailsPanelProps) => { + const { id: _id } = useParams() + const { childId: queueName } = useParams() + const project = useSelectedProject() + + useEscapeKeydown(() => setSelectedMessage(null)) + + const { + mutate: archiveMessage, + isLoading: isLoadingArchive, + isSuccess: isSuccessArchive, + } = useDatabaseQueueMessageArchiveMutation() + + const { + mutate: readMessage, + isLoading: isLoadingRead, + isSuccess: isSuccessRead, + } = useDatabaseQueueMessageReadMutation() + + const { + mutate: deleteMessage, + isLoading: isLoadingDelete, + isSuccess: isSuccessDelete, + } = useDatabaseQueueMessageDeleteMutation() + + const initialValue = JSON.stringify(selectedMessage?.message) + const jsonString = prettifyJSON(!isNil(initialValue) ? tryFormatInitialValue(initialValue) : '') + + const [view, setView] = useState<'details' | 'suggestion'>('details') + + return ( + setSelectedMessage(null)} + className="bg-studio border-t pointer-events-auto" + > + + + + ), + }} + /> + + + {selectedMessage && ( + <> + + + + )} + + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/NewQueues/SingleQueue/QueueFilters.tsx b/apps/studio/components/interfaces/Integrations/NewQueues/SingleQueue/QueueFilters.tsx new file mode 100644 index 00000000000..2fd77da9157 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/NewQueues/SingleQueue/QueueFilters.tsx @@ -0,0 +1,29 @@ +import { FilterPopover } from 'components/ui/FilterPopover' +import { QUEUE_MESSAGE_OPTIONS, QUEUE_MESSAGE_TYPE } from './Queue.utils' + +interface QueueFiltersProps { + selectedTypes: QUEUE_MESSAGE_TYPE[] + setSelectedTypes: (value: QUEUE_MESSAGE_TYPE[]) => void +} + +export const QueueFilters = ({ selectedTypes, setSelectedTypes }: QueueFiltersProps) => { + return ( +
+
+ setSelectedTypes(value as any)} + /> +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/NewQueues/SingleQueue/SendMessageModal.tsx b/apps/studio/components/interfaces/Integrations/NewQueues/SingleQueue/SendMessageModal.tsx new file mode 100644 index 00000000000..af38654151a --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/NewQueues/SingleQueue/SendMessageModal.tsx @@ -0,0 +1,138 @@ +import { zodResolver } from '@hookform/resolvers/zod' +import { SubmitHandler, useForm } from 'react-hook-form' +import z from 'zod' + +import { useParams } from 'common' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import CodeEditor from 'components/ui/CodeEditor/CodeEditor' +import { useDatabaseQueueMessageSendMutation } from 'data/database-queues/database-queue-messages-send-mutation' +import { useEffect } from 'react' +import { toast } from 'sonner' +import { Form_Shadcn_, FormControl_Shadcn_, FormField_Shadcn_, Input, Modal } from 'ui' +import { FormItemLayout } from 'ui-patterns/form/FormItemLayout/FormItemLayout' + +interface SendMessageModalProps { + visible: boolean + onClose: () => void +} + +const FormSchema = z.object({ + delay: z.coerce.number().int().gte(0).default(5), + payload: z.string().refine( + (val) => { + try { + JSON.parse(val) + } catch { + return false + } + }, + { + message: 'The payload should be a JSON object', + } + ), +}) + +export type SendMessageForm = z.infer + +const FORM_ID = 'QUEUES_SEND_MESSAGE_FORM' + +export const SendMessageModal = ({ visible, onClose }: SendMessageModalProps) => { + const { childId: queueName } = useParams() + const { project } = useProjectContext() + const form = useForm({ + resolver: zodResolver(FormSchema), + defaultValues: { + delay: 1, + payload: '{}', + }, + }) + + const { isLoading, mutate } = useDatabaseQueueMessageSendMutation({ + onSuccess: () => { + toast.success(`Successfully added a message to the queue.`) + onClose() + }, + }) + + const onSubmit: SubmitHandler = (values) => { + mutate({ + projectRef: project?.ref!, + connectionString: project?.connectionString, + queueName: queueName!, + payload: values.payload, + delay: values.delay, + }) + } + + useEffect(() => { + if (visible) { + form.reset({ delay: 1, payload: '{}' }) + } + }, [visible]) + + return ( + { + const values = form.getValues() + onSubmit(values) + }} + > + + +
+ ( + + + sec

} + /> +
+
+ )} + /> + ( + + + field.onChange(e)} + options={{ wordWrap: 'off', contextmenu: false }} + value={field.value} + /> + + + )} + /> + +
+
+
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/Queues/QueuesRows.tsx b/apps/studio/components/interfaces/Integrations/Queues/QueuesRows.tsx index 629b0347d9f..ae44319e115 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/QueuesRows.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/QueuesRows.tsx @@ -25,7 +25,7 @@ const QueueRow = ({ queue }: { queue: PostgresQueue }) => { connectionString: selectedProject?.connectionString, }, { - staleTime: 30 * 1000, // 60 seconds, talk with Oli whether this is ok to call every minute + staleTime: 30 * 1000, // 30 seconds, talk with Oli whether this is ok to call every minute } ) @@ -37,7 +37,6 @@ const QueueRow = ({ queue }: { queue: PostgresQueue }) => { onClick={() => { router.push(`/project/${selectedProject?.ref}/integrations/queues/${queue.queue_name}`) }} - className="hover:" >

{queue.queue_name}

diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/MessageDetailsPanel.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/MessageDetailsPanel.tsx index 9de86466dec..bc137f9d186 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/MessageDetailsPanel.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/MessageDetailsPanel.tsx @@ -47,8 +47,7 @@ export const MessageDetailsPanel = ({ selectedMessage, setSelectedMessage, }: MessageDetailsPanelProps) => { - const { id: _id } = useParams() - const { name: queueName } = useParams() + const { id: _id, childId: queueName } = useParams() const project = useSelectedProject() useEscapeKeydown(() => setSelectedMessage(null)) diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx index c77211a1c7f..f9012f5c60c 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/QueueDataGrid.tsx @@ -130,7 +130,7 @@ export const QueueMessagesDataGrid = ({ const gridRef = useRef(null) const router = useRouter() - const [selectedMessageId, setSelectedMessageId] = useQueryState('id', parseAsInteger) + const [selectedMessageId, setSelectedMessageId] = useQueryState('messageId', parseAsInteger) const handleScroll = (event: UIEvent) => { if (isLoading || !isAtBottom(event)) return @@ -171,8 +171,8 @@ export const QueueMessagesDataGrid = ({ if (typeof idx === 'number' && idx >= 0) { setSelectedMessageId(props.row.msg_id) gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx }) - const { id, ...rest } = router.query - router.push({ ...router, query: { ...rest, id: props.row.msg_id } }) + const { messageId, ...rest } = router.query + router.push({ ...router, query: { ...rest, messageId: props.row.msg_id } }) } }} /> diff --git a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx index 26fb2c27c1b..af38654151a 100644 --- a/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx +++ b/apps/studio/components/interfaces/Integrations/Queues/SingleQueue/SendMessageModal.tsx @@ -37,7 +37,7 @@ export type SendMessageForm = z.infer const FORM_ID = 'QUEUES_SEND_MESSAGE_FORM' export const SendMessageModal = ({ visible, onClose }: SendMessageModalProps) => { - const { name: queueName } = useParams() + const { childId: queueName } = useParams() const { project } = useProjectContext() const form = useForm({ resolver: zodResolver(FormSchema), diff --git a/apps/studio/components/interfaces/Integrations/Integrations-Vercel.constants.ts b/apps/studio/components/interfaces/Integrations/Vercel/Integrations-Vercel.constants.ts similarity index 100% rename from apps/studio/components/interfaces/Integrations/Integrations-Vercel.constants.ts rename to apps/studio/components/interfaces/Integrations/Vercel/Integrations-Vercel.constants.ts diff --git a/apps/studio/components/interfaces/Integrations/OrganizationPicker.tsx b/apps/studio/components/interfaces/Integrations/Vercel/OrganizationPicker.tsx similarity index 100% rename from apps/studio/components/interfaces/Integrations/OrganizationPicker.tsx rename to apps/studio/components/interfaces/Integrations/Vercel/OrganizationPicker.tsx diff --git a/apps/studio/components/interfaces/Integrations/IntegrationConnection.tsx b/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationConnection.tsx similarity index 98% rename from apps/studio/components/interfaces/Integrations/IntegrationConnection.tsx rename to apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationConnection.tsx index b54187ecd71..3d287c1bc4f 100644 --- a/apps/studio/components/interfaces/Integrations/IntegrationConnection.tsx +++ b/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationConnection.tsx @@ -7,7 +7,7 @@ import { toast } from 'sonner' import { IntegrationConnection, IntegrationConnectionProps, -} from 'components/interfaces/Integrations/IntegrationPanels' +} from 'components/interfaces/Integrations/VercelGithub/IntegrationPanels' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useIntegrationsVercelConnectionSyncEnvsMutation } from 'data/integrations/integrations-vercel-connection-sync-envs-mutation' import type { IntegrationProjectConnection } from 'data/integrations/integrations.types' diff --git a/apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx b/apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.tsx similarity index 100% rename from apps/studio/components/interfaces/Integrations/IntegrationPanels.tsx rename to apps/studio/components/interfaces/Integrations/VercelGithub/IntegrationPanels.tsx diff --git a/apps/studio/components/interfaces/Integrations/ProjectLinker.tsx b/apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinker.tsx similarity index 100% rename from apps/studio/components/interfaces/Integrations/ProjectLinker.tsx rename to apps/studio/components/interfaces/Integrations/VercelGithub/ProjectLinker.tsx diff --git a/apps/studio/components/interfaces/Integrations/Webhooks/ListTab.tsx b/apps/studio/components/interfaces/Integrations/Webhooks/ListTab.tsx new file mode 100644 index 00000000000..4afe8207280 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Webhooks/ListTab.tsx @@ -0,0 +1,53 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { useState } from 'react' + +import DeleteHookModal from 'components/interfaces/Database/Hooks/DeleteHookModal' +import EditHookPanel from 'components/interfaces/Database/Hooks/EditHookPanel' +import HooksList from 'components/interfaces/Database/Hooks/HooksList/HooksList' +import NoPermission from 'components/ui/NoPermission' +import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' + +export const WebhooksListTab = () => { + const [selectedHook, setSelectedHook] = useState() + const [showCreateHookForm, setShowCreateHookForm] = useState(false) + const [showDeleteHookForm, setShowDeleteHookForm] = useState(false) + + const canReadWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers') + const canCreateWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers') + const isPermissionsLoaded = usePermissionsLoaded() + + const createHook = () => { + setSelectedHook(undefined) + setShowCreateHookForm(true) + } + + const editHook = (hook: any) => { + setSelectedHook(hook) + setShowCreateHookForm(true) + } + + const deleteHook = (hook: any) => { + setSelectedHook(hook) + setShowDeleteHookForm(true) + } + + if (isPermissionsLoaded && !canReadWebhooks) { + return + } + + return ( +
+ + setShowCreateHookForm(false)} + /> + setShowDeleteHookForm(false)} + /> +
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx new file mode 100644 index 00000000000..e8b2ec79869 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Webhooks/OverviewTab.tsx @@ -0,0 +1,94 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { toast } from 'sonner' + +import { useParams } from 'common' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import NoPermission from 'components/ui/NoPermission' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useHooksEnableMutation } from 'data/database/hooks-enable-mutation' +import { useSchemasQuery } from 'data/database/schemas-query' +import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' +import { Admonition } from 'ui-patterns' +import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' + +export const WebhooksOverviewTab = () => { + const { project } = useProjectContext() + const { ref: projectRef } = useParams() + + const { + data: schemas, + isSuccess: isSchemasLoaded, + refetch, + } = useSchemasQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const isHooksEnabled = schemas?.some((schema) => schema.name === 'supabase_functions') + const canReadWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers') + const isPermissionsLoaded = usePermissionsLoaded() + + const { mutate: enableHooks, isLoading: isEnablingHooks } = useHooksEnableMutation({ + onSuccess: async () => { + await refetch() + toast.success('Successfully enabled webhooks') + }, + }) + + const enableHooksForProject = async () => { + if (!projectRef) return console.error('Project ref is required') + enableHooks({ ref: projectRef }) + } + + if (isPermissionsLoaded && !canReadWebhooks) { + return ( +
+ +
+ ) + } + + if (!isSchemasLoaded) { + return ( +
+ +
+ ) + } + + return ( + +

+ Database Webhooks can be used to trigger serverless functions or send requests to an + HTTP endpoint +

+ enableHooksForProject()} + disabled={!isPermissionsLoaded || isEnablingHooks} + tooltip={{ + content: { + side: 'bottom', + text: + isPermissionsLoaded && !canReadWebhooks + ? 'You need additional permissions to enable webhooks' + : undefined, + }, + }} + > + Enable webhooks + + + ) + } + /> + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx new file mode 100644 index 00000000000..d755eb4cf28 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Wrappers/CreateWrapperSheet.tsx @@ -0,0 +1,283 @@ +import { useQueryClient } from '@tanstack/react-query' +import { isEmpty } from 'lodash' +import { Edit, Trash } from 'lucide-react' +import { useState } from 'react' +import { toast } from 'sonner' + +import InputField from 'components/interfaces/Database/Wrappers/InputField' +import WrapperTableEditor from 'components/interfaces/Database/Wrappers/WrapperTableEditor' +import { WrapperMeta } from 'components/interfaces/Database/Wrappers/Wrappers.types' +import { makeValidateRequired } from 'components/interfaces/Database/Wrappers/Wrappers.utils' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' +import { invalidateSchemasQuery } from 'data/database/schemas-query' +import { useFDWCreateMutation } from 'data/fdw/fdw-create-mutation' +import { Button, Form, Input, SheetFooter, SheetHeader, SheetTitle } from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' + +export interface CreateWrapperSheetProps { + isClosing: boolean + wrapperMeta: WrapperMeta + setIsClosing: (v: boolean) => void + onClose: () => void +} + +const FORM_ID = 'create-wrapper-form' + +export const CreateWrapperSheet = ({ + wrapperMeta, + isClosing, + setIsClosing, + onClose, +}: CreateWrapperSheetProps) => { + const queryClient = useQueryClient() + const { project } = useProjectContext() + + const [newTables, setNewTables] = useState([]) + const [isEditingTable, setIsEditingTable] = useState(false) + const [selectedTableToEdit, setSelectedTableToEdit] = useState() + const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({}) + + const { mutate: createFDW, isLoading: isCreating } = useFDWCreateMutation({ + onSuccess: () => { + toast.success(`Successfully created ${wrapperMeta?.label} foreign data wrapper`) + setNewTables([]) + + const hasNewSchema = newTables.some((table) => table.is_new_schema) + if (hasNewSchema) invalidateSchemasQuery(queryClient, project?.ref) + + onClose() + }, + }) + + const initialValues = { + wrapper_name: '', + server_name: '', + ...Object.fromEntries( + wrapperMeta.server.options.map((option) => [option.name, option.defaultValue ?? '']) + ), + } + + const onUpdateTable = (values: any) => { + setNewTables((prev) => { + // if the new values have tableIndex, we are editing an existing table + if (values.tableIndex !== undefined) { + const tableIndex = values.tableIndex + const newTables = [...prev] + delete values.tableIndex + newTables[tableIndex] = values + return newTables + } + return [...prev, values] + }) + setIsEditingTable(false) + setSelectedTableToEdit(undefined) + } + + const onSubmit = async (values: any) => { + const validate = makeValidateRequired(wrapperMeta.server.options) + const errors: any = validate(values) + + const { wrapper_name } = values + if (wrapper_name.length === 0) errors.name = 'Please provide a name for your wrapper' + if (newTables.length === 0) errors.tables = 'Please add at least one table' + if (!isEmpty(errors)) return setFormErrors(errors) + + createFDW({ + projectRef: project?.ref, + connectionString: project?.connectionString, + wrapperMeta, + formState: { ...values, server_name: `${wrapper_name}_server` }, + tables: newTables, + }) + } + + return ( + <> +
+
+ {({ handleReset, values, initialValues }: any) => { + const hasChanges = JSON.stringify(values) !== JSON.stringify(initialValues) + + const onClosePanel = () => { + if (hasChanges) { + setIsClosing(true) + } else { + onClose() + } + } + + // if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet + if (!hasChanges && isClosing) { + onClose() + } + + return ( + <> + + Create a {wrapperMeta.label} wrapper + +
+ Wrapper Configuration}> + + 0 ? ( + <> + Your wrapper's server name will be{' '} + {values.wrapper_name}_server + + ) : ( + '' + ) + } + /> + + + {wrapperMeta.label} Configuration} + > + + {wrapperMeta.server.options + .filter((option) => !option.hidden) + .map((option) => ( + + ))} + + + +

Foreign Tables

+

+ You can query your data from these foreign tables after the wrapper is + created +

+ + } + > + + {newTables.length === 0 ? ( +
+ +
+ ) : ( +
+ {newTables.map((table, i) => ( +
+
+

+ {table.schema_name}.{table.table_name} +

+

+ Columns:{' '} + {table.columns.map((column: any) => column.name).join(', ')} +

+
+
+
+
+ ))} +
+ )} + {newTables.length > 0 && ( +
+ +
+ )} + {newTables.length === 0 && formErrors.tables && ( +

{formErrors.tables}

+ )} +
+
+
+ + + + + + + ) + }} + +
+ setIsClosing(false)} + onConfirm={() => onClose()} + > +

+ There are unsaved changes. Are you sure you want to close the panel? Your changes will be + lost. +

+
+ + { + setSelectedTableToEdit(undefined) + setIsEditingTable(false) + }} + onSave={onUpdateTable} + initialData={selectedTableToEdit} + /> + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx new file mode 100644 index 00000000000..b77ffb1e8a3 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Wrappers/EditWrapperSheet.tsx @@ -0,0 +1,364 @@ +import { useQueryClient } from '@tanstack/react-query' +import { isEmpty } from 'lodash' +import { Edit, Trash } from 'lucide-react' +import { useEffect, useState } from 'react' +import { toast } from 'sonner' + +import InputField from 'components/interfaces/Database/Wrappers/InputField' +import WrapperTableEditor from 'components/interfaces/Database/Wrappers/WrapperTableEditor' +import { WrapperMeta } from 'components/interfaces/Database/Wrappers/Wrappers.types' +import { + convertKVStringArrayToJson, + formatWrapperTables, + makeValidateRequired, +} from 'components/interfaces/Database/Wrappers/Wrappers.utils' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { FormSection, FormSectionContent, FormSectionLabel } from 'components/ui/Forms/FormSection' +import { invalidateSchemasQuery } from 'data/database/schemas-query' +import { useFDWUpdateMutation } from 'data/fdw/fdw-update-mutation' +import { FDW } from 'data/fdw/fdws-query' +import { getDecryptedValue } from 'data/vault/vault-secret-decrypted-value-query' +import { useVaultSecretsQuery } from 'data/vault/vault-secrets-query' +import { Button, Form, Input, SheetFooter, SheetHeader, SheetTitle } from 'ui' +import ConfirmationModal from 'ui-patterns/Dialogs/ConfirmationModal' + +export interface EditWrapperSheetProps { + wrapper: FDW + isClosing: boolean + wrapperMeta: WrapperMeta + setIsClosing: (v: boolean) => void + onClose: () => void +} + +const FORM_ID = 'edit-wrapper-form' + +export const EditWrapperSheet = ({ + wrapper, + wrapperMeta, + isClosing, + setIsClosing, + onClose, +}: EditWrapperSheetProps) => { + const queryClient = useQueryClient() + const { project } = useProjectContext() + + const { data: secrets, isLoading: isSecretsLoading } = useVaultSecretsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const { mutate: updateFDW, isLoading: isSaving } = useFDWUpdateMutation({ + onSuccess: () => { + toast.success(`Successfully updated ${wrapperMeta?.label} foreign data wrapper`) + setWrapperTables([]) + + const hasNewSchema = wrapperTables.some((table) => table.is_new_schema) + if (hasNewSchema) invalidateSchemasQuery(queryClient, project?.ref) + }, + }) + + const [wrapperTables, setWrapperTables] = useState( + formatWrapperTables(wrapper, wrapperMeta) + ) + const [isEditingTable, setIsEditingTable] = useState(false) + const [selectedTableToEdit, setSelectedTableToEdit] = useState() + const [formErrors, setFormErrors] = useState<{ [k: string]: string }>({}) + + const initialValues = { + wrapper_name: wrapper?.name, + server_name: wrapper?.server_name, + ...convertKVStringArrayToJson(wrapper?.server_options ?? []), + } + + const onUpdateTable = (values: any) => { + setWrapperTables((prev) => { + // if the new values have tableIndex, we are editing an existing table + if (values.tableIndex !== undefined) { + const tableIndex = values.tableIndex + const wrapperTables = [...prev] + delete values.tableIndex + wrapperTables[tableIndex] = values + return wrapperTables + } + return [...prev, values] + }) + setIsEditingTable(false) + setSelectedTableToEdit(undefined) + } + + const onSubmit = async (values: any) => { + const validate = makeValidateRequired(wrapperMeta.server.options) + const errors: any = validate(values) + + const { wrapper_name } = values + if (wrapper_name.length === 0) errors.name = 'Please provide a name for your wrapper' + if (wrapperTables.length === 0) errors.tables = 'Please add at least one table' + if (!isEmpty(errors)) return setFormErrors(errors) + + updateFDW({ + projectRef: project?.ref, + connectionString: project?.connectionString, + wrapper, + wrapperMeta, + formState: { ...values, server_name: `${wrapper_name}_server` }, + tables: wrapperTables, + }) + } + + return ( + <> +
+
+ {({ values, initialValues, resetForm }: any) => { + // [Alaister] although this "technically" is breaking the rules of React hooks + // it won't error because the hooks are always rendered in the same order + // eslint-disable-next-line react-hooks/rules-of-hooks + const [loadingSecrets, setLoadingSecrets] = useState(false) + + const initialTables = formatWrapperTables({ + handler: wrapper.handler, + tables: wrapper?.tables ?? [], + }) + const hasFormChanges = JSON.stringify(values) !== JSON.stringify(initialValues) + const hasTableChanges = JSON.stringify(initialTables) !== JSON.stringify(wrapperTables) + const hasChanges = hasFormChanges || hasTableChanges + + const encryptedOptions = wrapperMeta.server.options.filter((option) => option.encrypted) + + const onClosePanel = () => { + if (hasChanges) { + setIsClosing(true) + } else { + onClose() + } + } + + // if the form hasn't been touched and the user clicked esc or the backdrop, close the sheet + if (!hasChanges && isClosing) { + onClose() + } + + // [Alaister] although this "technically" is breaking the rules of React hooks + // it won't error because the hooks are always rendered in the same order + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + const fetchEncryptedValues = async () => { + setLoadingSecrets(true) + // If the secrets haven't loaded, escape and run the effect again when they're loaded + if (isSecretsLoading) { + return + } + + const res = await Promise.all( + encryptedOptions.map(async (option) => { + const secret = secrets?.find( + (secret) => secret.name === `${wrapper.name}_${option.name}` + ) + if (secret !== undefined) { + const value = await getDecryptedValue({ + projectRef: project?.ref, + connectionString: project?.connectionString, + id: secret.id, + }) + return { [option.name]: value[0]?.decrypted_secret ?? '' } + } else { + return { [option.name]: '' } + } + }) + ) + const secretValues = res.reduce((a: any, b: any) => { + const [key] = Object.keys(b) + return { ...a, [key]: b[key] } + }, {}) + + resetForm({ + values: { ...values, ...secretValues }, + initialValues: { ...initialValues, ...secretValues }, + }) + setLoadingSecrets(false) + } + + if (encryptedOptions.length > 0) fetchEncryptedValues() + }, [isSecretsLoading]) + + return ( + <> + + + Edit {wrapperMeta.label} wrapper: {wrapper.name} + + +
+ Wrapper Configuration}> + + + Your wrapper's server name will be updated to{' '} + {values.wrapper_name}_server + + ) : ( + <> + Your wrapper's server name is{' '} + {values.wrapper_name}_server + + ) + } + /> + + + {wrapperMeta.label} Configuration} + > + + {wrapperMeta.server.options + .filter((option) => !option.hidden) + .map((option) => ( + + ))} + + + +

Foreign Tables

+

+ You can query your data from these foreign tables after the wrapper is + created +

+ + } + > + + {wrapperTables.length === 0 ? ( +
+ +
+ ) : ( +
+ {wrapperTables.map((table, i) => { + const target = table?.table ?? table.object + + return ( +
+
+

+ {table.schema_name}.{table.table_name}{' '} +

+

+ Target: {target} +

+

+ Columns:{' '} + {table.columns.map((column: any) => column.name).join(', ')} +

+
+
+
+
+ ) + })} +
+ )} + {wrapperTables.length > 0 && ( +
+ +
+ )} + {wrapperTables.length === 0 && formErrors.tables && ( +

{formErrors.tables}

+ )} +
+
+
+ + + + + + ) + }} + +
+ + setIsClosing(false)} + onConfirm={() => onClose()} + > +

+ There are unsaved changes. Are you sure you want to close the panel? Your changes will be + lost. +

+
+ + { + setSelectedTableToEdit(undefined) + setIsEditingTable(false) + }} + onSave={onUpdateTable} + initialData={selectedTableToEdit} + /> + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/MarkdownContent.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/MarkdownContent.tsx new file mode 100644 index 00000000000..fdecd62b5f7 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Wrappers/MarkdownContent.tsx @@ -0,0 +1,54 @@ +import { useParams } from 'common' +import { Markdown } from 'components/interfaces/Markdown' +import { motion } from 'framer-motion' +import { useEffect, useState } from 'react' +import { cn } from 'ui' + +const CHAR_LIMIT = 500 // Adjust this number as needed + +export const MarkdownContent = () => { + const { id } = useParams() + const [content, setContent] = useState('') + const [isExpanded, setIsExpanded] = useState(false) + + useEffect(() => { + import(`static-data/integrations/${id}/overview.md`) + .then((module) => setContent(String(module.default))) + .catch((error) => console.error('Error loading markdown:', error)) + }, [id]) + + const displayContent = isExpanded ? content : content.slice(0, CHAR_LIMIT) + + return ( +
+
+ + + + {!isExpanded && ( +
+ )} + {content.length > CHAR_LIMIT && ( +
+ +
+ )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx new file mode 100644 index 00000000000..6a0141d0ff5 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Wrappers/OverviewTab.tsx @@ -0,0 +1,130 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import Link from 'next/link' +import { useState } from 'react' + +import { useParams } from 'common' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { + Alert_Shadcn_, + AlertDescription_Shadcn_, + AlertTitle_Shadcn_, + Button, + Separator, + Sheet, + SheetContent, + WarningIcon, +} from 'ui' +import { IntegrationOverviewTab } from '../Integration/IntegrationOverviewTab' +import { INTEGRATIONS } from '../Landing/Integrations.constants' +import { CreateWrapperSheet } from './CreateWrapperSheet' +import { WrapperTable } from './WrapperTable' + +export const WrapperOverviewTab = () => { + const { id } = useParams() + const { project } = useProjectContext() + const [createWrapperShown, setCreateWrapperShown] = useState(false) + const [isClosingCreateWrapper, setisClosingCreateWrapper] = useState(false) + const canCreateWrapper = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers') + + const { data } = useDatabaseExtensionsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const integration = INTEGRATIONS.find((i) => i.id === id) + + if (integration?.type !== 'wrapper') { + return

Unsupported integration type

+ } + + const wrapperMeta = integration.meta + const wrappersExtension = data?.find((ext) => ext.name === 'wrappers') + const isWrappersExtensionInstalled = !!wrappersExtension?.installed_version + const hasRequiredVersion = + (wrappersExtension?.installed_version ?? '') >= (wrapperMeta?.minimumExtensionVersion ?? '') + const databaseNeedsUpgrading = + wrappersExtension?.installed_version !== wrappersExtension?.default_version + + return ( + + + + + Your extension version is outdated for this wrapper. + + +

+ The {wrapperMeta.label} wrapper requires a minimum extension version of{' '} + {wrapperMeta.minimumExtensionVersion}. You have version{' '} + {wrappersExtension?.installed_version} installed. Please{' '} + {databaseNeedsUpgrading && 'upgrade your database then '}update the extension by + disabling and enabling the wrappers extension to + create this wrapper. +

+

+ Warning: Before reinstalling the wrapper extension, you must first remove all + existing wrappers. Afterward, you can recreate the wrappers. +

+
+ + + +
+
+ ) : ( +
+ setCreateWrapperShown(true)} + disabled={!canCreateWrapper} + tooltip={{ + content: { + text: !canCreateWrapper + ? 'You need additional permissions to create a foreign data wrapper' + : undefined, + }, + }} + > + Add new wrapper + +
+ ) + } + > +
+ Recent wrappers + +
+ + setisClosingCreateWrapper(true)}> + + { + setCreateWrapperShown(false) + setisClosingCreateWrapper(false) + }} + isClosing={isClosingCreateWrapper} + setIsClosing={setisClosingCreateWrapper} + /> + + +
+ ) +} diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/WrapperRow.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperRow.tsx new file mode 100644 index 00000000000..de0a81f891d --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperRow.tsx @@ -0,0 +1,159 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { partition } from 'lodash' +import { ChevronRight, Edit, ExternalLink, Table2, Trash } from 'lucide-react' +import Link from 'next/link' +import { useState } from 'react' + +import { useParams } from 'common' +import DeleteWrapperModal from 'components/interfaces/Database/Wrappers/DeleteWrapperModal' +import { formatWrapperTables } from 'components/interfaces/Database/Wrappers/Wrappers.utils' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import type { FDW } from 'data/fdw/fdws-query' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { Badge, Sheet, SheetContent, TableCell, TableRow } from 'ui' +import { INTEGRATIONS } from '../Landing/Integrations.constants' +import { EditWrapperSheet } from './EditWrapperSheet' + +interface WrapperRowProps { + wrapper: FDW +} + +const WrapperRow = ({ wrapper }: WrapperRowProps) => { + const { ref, id } = useParams() + const canManageWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers') + + const [editWrapperShown, setEditWrapperShown] = useState(false) + const [isClosingEditWrapper, setisClosingEditWrapper] = useState(false) + const [deleteWrapperShown, setDeleteWrapperShown] = useState(false) + + const integration = INTEGRATIONS.find((i) => i.id === id) + + if (!integration || integration.type !== 'wrapper') { + return

A wrapper with this ID does not exist

+ } + + const serverOptions = Object.fromEntries( + wrapper.server_options.map((option) => option.split('=')) + ) + const [encryptedMetadata, visibleMetadata] = partition( + integration?.meta?.server.options.filter((option) => !option.hidden), + 'secureEntry' + ) + + const _tables = formatWrapperTables(wrapper, integration?.meta) + + return ( + <> + + + {wrapper.name} + + {visibleMetadata.map((metadata) => ( +
+ {metadata.label}: + {serverOptions[metadata.name]} +
+ ))} +
+ + + {_tables?.map((table) => { + const target = table.table ?? table.object + + return ( +
+ +
+ {integration.icon({ className: 'p-0' })} +
+ {target}{' '} + +
+ + + + {table.schema}.{table.table_name} + +
+ ) + })} +
+ + {encryptedMetadata.map((metadata) => ( +
+ {/*

{metadata.label}:

*/} + + Vault + + +
+ ))} +
+ + } + className="px-1.5" + onClick={() => setEditWrapperShown(true)} + tooltip={{ + content: { + side: 'bottom', + text: !canManageWrappers + ? 'You need additional permissions to edit wrappers' + : 'Edit wrapper', + }, + }} + /> + } + className="px-1.5" + onClick={() => setDeleteWrapperShown(true)} + tooltip={{ + content: { + side: 'bottom', + text: !canManageWrappers + ? 'You need additional permissions to delete wrappers' + : 'Delete wrapper', + }, + }} + /> + +
+ setisClosingEditWrapper(true)}> + + { + setEditWrapperShown(false) + setisClosingEditWrapper(false) + }} + isClosing={isClosingEditWrapper} + setIsClosing={setisClosingEditWrapper} + /> + + + {deleteWrapperShown && ( + setDeleteWrapperShown(false)} + /> + )} + + ) +} + +export default WrapperRow diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTable.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTable.tsx new file mode 100644 index 00000000000..ffb88fc25f8 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrapperTable.tsx @@ -0,0 +1,66 @@ +import { useParams } from 'common' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { useFDWsQuery } from 'data/fdw/fdws-query' +import { + Card, + CardContent, + Table, + TableBody, + TableCaption, + TableHead, + TableHeader, + TableRow, +} from 'ui' +import { INTEGRATIONS } from '../Landing/Integrations.constants' +import WrapperRow from './WrapperRow' + +interface WrapperTableProps { + isLatest?: boolean +} + +export const WrapperTable = ({ isLatest = false }: WrapperTableProps) => { + const { id, ref } = useParams() + const { project } = useProjectContext() + const integration = INTEGRATIONS.find((i) => i.id === id) + + const { data } = useFDWsQuery({ + projectRef: ref, + connectionString: project?.connectionString, + }) + + if (!integration || integration.type !== 'wrapper') { + return ( +

+ The referenced ID doesn't correspond to a wrapper integration +

+ ) + } + + const wrappers = (data ?? []).filter((x) => x.handler === integration.meta.handlerName) || [] + + return ( + + +
+ + {wrappers.length} {integration?.name} + {wrappers.length > 1 ? 's' : ''} created + + + + Name + Tables + Encrypted key + + + + + {(isLatest ? wrappers.slice(0, 3) : wrappers).map((x) => { + return + })} + +
+ + + ) +} diff --git a/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx b/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx new file mode 100644 index 00000000000..a3fcb856b18 --- /dev/null +++ b/apps/studio/components/interfaces/Integrations/Wrappers/WrappersTab.tsx @@ -0,0 +1,105 @@ +import { PermissionAction } from '@supabase/shared-types/out/constants' +import { HTMLProps, ReactNode, useCallback, useState } from 'react' + +import { useParams } from 'common' +import DeleteWrapperModal from 'components/interfaces/Database/Wrappers/DeleteWrapperModal' +import { wrapperMetaComparator } from 'components/interfaces/Database/Wrappers/Wrappers.utils' +import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { FDW, useFDWsQuery } from 'data/fdw/fdws-query' +import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { Sheet, SheetContent } from 'ui' +import { INTEGRATIONS } from '../Landing/Integrations.constants' +import { CreateWrapperSheet } from './CreateWrapperSheet' +import { WrapperTable } from './WrapperTable' + +export const WrappersTab = () => { + const { id } = useParams() + const { project } = useProjectContext() + const [selectedWrapperForDelete, setSelectedWrapperForDelete] = useState(null) + const [createWrapperShown, setCreateWrapperShown] = useState(false) + const [isClosingCreateWrapper, setisClosingCreateWrapper] = useState(false) + const canCreateWrapper = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'wrappers') + + const { data } = useFDWsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + + const wrappers = data ?? [] + const integration = INTEGRATIONS.find((i) => i.id === id) + + // this contains a collection of all wrapper instances for the wrapper type + const createdWrappers = + integration?.type === 'wrapper' + ? wrappers.filter((w) => wrapperMetaComparator(integration.meta, w)) + : [] + + const Container = useCallback( + ({ ...props }: { children: ReactNode } & HTMLProps) => ( +
+ {props.children} + setisClosingCreateWrapper(true)}> + + {integration?.type === 'wrapper' && ( + { + setCreateWrapperShown(false) + setisClosingCreateWrapper(false) + }} + isClosing={isClosingCreateWrapper} + setIsClosing={setisClosingCreateWrapper} + /> + )} + + +
+ ), + [createWrapperShown, integration, isClosingCreateWrapper] + ) + + if (!integration || integration.type !== 'wrapper') { + return
Missing integration.
+ } + + if (createdWrappers.length === 0) { + return ( + +
+
+

+ No {integration.meta.label} wrappers have been installed +

+ setCreateWrapperShown(true)} + disabled={!canCreateWrapper} + tooltip={{ + content: { + text: !canCreateWrapper + ? 'You need additional permissions to create a foreign data wrapper' + : undefined, + }, + }} + > + Add new wrapper + +
+
+
+ ) + } + + return ( + + + {selectedWrapperForDelete && ( + setSelectedWrapperForDelete(null)} + /> + )} + + ) +} diff --git a/apps/studio/components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx b/apps/studio/components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx index 6a37b721024..70fa4aff2ee 100644 --- a/apps/studio/components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx +++ b/apps/studio/components/interfaces/Organization/IntegrationSettings/IntegrationSettings.tsx @@ -2,7 +2,7 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { useCallback } from 'react' import { toast } from 'sonner' -import { EmptyIntegrationConnection } from 'components/interfaces/Integrations/IntegrationPanels' +import { EmptyIntegrationConnection } from 'components/interfaces/Integrations/VercelGithub/IntegrationPanels' import { Markdown } from 'components/interfaces/Markdown' import VercelSection from 'components/interfaces/Settings/Integrations/VercelIntegration/VercelSection' import { @@ -25,7 +25,7 @@ import { GITHUB_INTEGRATION_REVOKE_AUTHORIZATION_URL, } from 'lib/github' import { useSidePanelsStateSnapshot } from 'state/side-panels' -import { IntegrationConnectionItem } from '../../Integrations/IntegrationConnection' +import { IntegrationConnectionItem } from '../../Integrations/VercelGithub/IntegrationConnection' import SidePanelGitHubRepoLinker from './SidePanelGitHubRepoLinker' import SidePanelVercelProjectLinker from './SidePanelVercelProjectLinker' diff --git a/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx b/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx index 09d3c006af2..af9247685a7 100644 --- a/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx +++ b/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelGitHubRepoLinker.tsx @@ -1,7 +1,7 @@ import { useMemo } from 'react' import { toast } from 'sonner' -import ProjectLinker from 'components/interfaces/Integrations/ProjectLinker' +import ProjectLinker from 'components/interfaces/Integrations/VercelGithub/ProjectLinker' import { Markdown } from 'components/interfaces/Markdown' import { useGitHubAuthorizationQuery } from 'data/integrations/github-authorization-query' import { useGitHubConnectionCreateMutation } from 'data/integrations/github-connection-create-mutation' diff --git a/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx b/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx index db60e354dcd..e710f0b5245 100644 --- a/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx +++ b/apps/studio/components/interfaces/Organization/IntegrationSettings/SidePanelVercelProjectLinker.tsx @@ -3,8 +3,10 @@ import { useCallback, useMemo } from 'react' import { toast } from 'sonner' import { useParams } from 'common' -import { ENV_VAR_RAW_KEYS } from 'components/interfaces/Integrations/Integrations-Vercel.constants' -import ProjectLinker, { ForeignProject } from 'components/interfaces/Integrations/ProjectLinker' +import { ENV_VAR_RAW_KEYS } from 'components/interfaces/Integrations/Vercel/Integrations-Vercel.constants' +import ProjectLinker, { + ForeignProject, +} from 'components/interfaces/Integrations/VercelGithub/ProjectLinker' import { Markdown } from 'components/interfaces/Markdown' import { vercelIcon } from 'components/to-be-cleaned/ListIcons' import { useOrgIntegrationsQuery } from 'data/integrations/integrations-query-org-only' diff --git a/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx b/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx index c682d1e1e53..5383630a199 100644 --- a/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx +++ b/apps/studio/components/interfaces/Organization/Usage/UsageSection/DiskUsage.tsx @@ -12,7 +12,7 @@ import { AlertTitle_Shadcn_, AlertDescription_Shadcn_, } from 'ui' -import MotionNumber from 'motion-number' +import MotionNumber from '@number-flow/react' import Link from 'next/link' import { useMemo } from 'react' import { InfoTooltip } from 'ui-patterns/info-tooltip' @@ -190,9 +190,6 @@ const DiskUsage = ({ {' '} diff --git a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx index e5430310967..81d8460c339 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/GithubIntegration/GithubSection.tsx @@ -3,8 +3,8 @@ import { useCallback } from 'react' import { toast } from 'sonner' import { useParams } from 'common' -import { IntegrationConnectionItem } from 'components/interfaces/Integrations/IntegrationConnection' -import { EmptyIntegrationConnection } from 'components/interfaces/Integrations/IntegrationPanels' +import { IntegrationConnectionItem } from 'components/interfaces/Integrations/VercelGithub/IntegrationConnection' +import { EmptyIntegrationConnection } from 'components/interfaces/Integrations/VercelGithub/IntegrationPanels' import { Markdown } from 'components/interfaces/Markdown' import { ScaffoldContainer, diff --git a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx index c2825bfceee..131895eae2d 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx @@ -4,12 +4,12 @@ import { useCallback, useMemo } from 'react' import { toast } from 'sonner' import { PermissionAction } from '@supabase/shared-types/out/constants' -import { IntegrationConnectionItem } from 'components/interfaces/Integrations/IntegrationConnection' +import { IntegrationConnectionItem } from 'components/interfaces/Integrations/VercelGithub/IntegrationConnection' import { EmptyIntegrationConnection, IntegrationConnectionHeader, IntegrationInstallation, -} from 'components/interfaces/Integrations/IntegrationPanels' +} from 'components/interfaces/Integrations/VercelGithub/IntegrationPanels' import { Markdown } from 'components/interfaces/Markdown' import { ScaffoldContainer, diff --git a/apps/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeysManagement.tsx b/apps/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeysManagement.tsx index c290594207d..4a2e04fa20e 100644 --- a/apps/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeysManagement.tsx +++ b/apps/studio/components/interfaces/Settings/Vault/Keys/EncryptionKeysManagement.tsx @@ -19,7 +19,7 @@ import { Alert, Button, Form, Input, Listbox, Modal, Separator } from 'ui' const DEFAULT_KEY_NAME = 'No description provided' const EncryptionKeysManagement = () => { - const { id } = useParams() + const { search } = useParams() const { project } = useProjectContext() const [searchValue, setSearchValue] = useState('') @@ -29,8 +29,8 @@ const EncryptionKeysManagement = () => { const canManageKeys = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') useEffect(() => { - if (id !== undefined) setSearchValue(id) - }, [id]) + if (search !== undefined) setSearchValue(search) + }, [search]) const { data, isLoading } = usePgSodiumKeysQuery({ projectRef: project?.ref, @@ -90,7 +90,7 @@ const EncryptionKeysManagement = () => { return ( <> -
+
{ onSubmit={onUpdateSecret} > {({ isSubmitting, resetForm }: any) => { - // [Joshen] JFYI this is breaking rules of hooks, will be fixed once we move to - // using react hook form instead - // eslint-disable-next-line react-hooks/rules-of-hooks - const { isLoading: isLoadingSecretValue } = useVaultSecretDecryptedValueQuery( + const { + data, + isLoading: isLoadingSecretValue, + isSuccess: isSuccessSecretValue, + // [Joshen] JFYI this is breaking rules of hooks, will be fixed once we move to + // using react hook form instead + // eslint-disable-next-line react-hooks/rules-of-hooks + } = useVaultSecretDecryptedValueQuery( { projectRef: project?.ref!, id: selectedSecret?.id!, connectionString: project?.connectionString, }, - { - enabled: !!(project?.ref && selectedSecret?.id), - onSuccess: (res) => { - resetForm({ - values: { ...INITIAL_VALUES, secret: res }, - initialValues: { ...INITIAL_VALUES, secret: res }, - }) - }, - } + { enabled: !!(project?.ref && selectedSecret?.id) } ) + // [Joshen] JFYI this is breaking rules of hooks, will be fixed once we move to + // using react hook form instead + // eslint-disable-next-line react-hooks/rules-of-hooks + useEffect(() => { + if (selectedSecret !== undefined && isSuccessSecretValue) { + resetForm({ + values: { ...INITIAL_VALUES, secret: data }, + initialValues: { ...INITIAL_VALUES, secret: data }, + }) + } + }, [selectedSecret, isSuccessSecretValue]) + return isLoadingSecretValue ? ( diff --git a/apps/studio/components/interfaces/Settings/Vault/Secrets/SecretRow.tsx b/apps/studio/components/interfaces/Settings/Vault/Secrets/SecretRow.tsx index 75687538d5b..3988d05930c 100644 --- a/apps/studio/components/interfaces/Settings/Vault/Secrets/SecretRow.tsx +++ b/apps/studio/components/interfaces/Settings/Vault/Secrets/SecretRow.tsx @@ -16,8 +16,8 @@ import { import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' import { useVaultSecretDecryptedValueQuery } from 'data/vault/vault-secret-decrypted-value-query' import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { Edit3, Eye, EyeOff, Key, Loader, MoreVertical, Trash } from 'lucide-react' import type { VaultSecret } from 'types' -import { Key, Loader, Eye, EyeOff, MoreVertical, Edit3, Trash } from 'lucide-react' interface SecretRowProps { secret: VaultSecret @@ -65,7 +65,7 @@ const SecretRow = ({ secret, onSelectEdit, onSelectRemove }: SecretRowProps) => className="text-foreground-light transition group-hover:text-brand" /> diff --git a/apps/studio/components/interfaces/Settings/Vault/Secrets/SecretsManagement.tsx b/apps/studio/components/interfaces/Settings/Vault/Secrets/SecretsManagement.tsx index 9a9b0396852..f0fc8f56864 100644 --- a/apps/studio/components/interfaces/Settings/Vault/Secrets/SecretsManagement.tsx +++ b/apps/studio/components/interfaces/Settings/Vault/Secrets/SecretsManagement.tsx @@ -56,7 +56,7 @@ const SecretsManagement = () => { return ( <> -
+
+} + +export const Header = forwardRef(({ scroll }, ref) => { + const router = useRouter() + const { id } = useParams() + // Get project context + const { project } = useProjectContext() + // Find the integration details based on ID + const integration = INTEGRATIONS.find((i) => i.id === id) + // Check if we're on the main integrations page + const isIntegrationsHome = !id + + const layoutTransition = { duration: 0.15 } + + const headerRef = useRef(null) + + // Input range: The scrollY range for triggering the animation (e.g., 0 to 200px of scroll) + const scrollRange = [40, headerRef.current?.offsetHeight ?? 128] + + // Output range: The Y position range for the icon (e.g., 0 to 150px movement) + const iconYRange = [0, headerRef.current?.offsetHeight ? headerRef.current.offsetHeight / 2 : 64] // Change 150 to set the end Y position of the icon + // Output range: The size range for the icon container + const sizeRange = [32, 20] // From 24px (scrolled) to 32px (top) + // Output range: The padding range for the image + const iconPaddingRange = [3, 1.5] // From 1.5px (scrolled) to 4px (top) + + // Map scrollY to the icon's Y position + const iconY = useTransform(scroll?.scrollY!, scrollRange, iconYRange) + + const iconSize = useTransform(scroll?.scrollY!, scrollRange, sizeRange) + + const iconPadding = useTransform(scroll?.scrollY!, scrollRange, iconPaddingRange) + + if (!router?.isReady) { + return null + } + + return ( + <> + + + {/* Main header content */} +
+
+ {/* Container with animated padding */} + + {/* Navigation link back to integrations landing */} +
+ {/* Back arrow */} + + {!isIntegrationsHome && ( + + + + + + )} + + {/* Two separate spans with the same key */} + {isIntegrationsHome ? ( + + Integrations + + ) : ( + + Integrations + + )} +
+ + {/* Integration details section - only shown when viewing a specific integration */} + + {!isIntegrationsHome && integration && ( + + {/* Integration icon */} + + {integration.icon({ + style: { + padding: iconPadding.get(), + }, + })} + + + {/* Integration name and description */} + +
+
+ {integration.name} + {integration.beta && ( + + Beta + + )} +
+

{integration.description}

+
+
+
+ )} +
+
+
+
+
+ + ) +}) + +Header.displayName = 'Header' diff --git a/apps/studio/components/layouts/Integrations/layout.tsx b/apps/studio/components/layouts/Integrations/layout.tsx new file mode 100644 index 00000000000..4c94e4c3074 --- /dev/null +++ b/apps/studio/components/layouts/Integrations/layout.tsx @@ -0,0 +1,245 @@ +import { useRouter } from 'next/router' +import { PropsWithChildren, useEffect, useRef, useState } from 'react' + +import { IntegrationDefinition } from 'components/interfaces/Integrations/Landing/Integrations.constants' +import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' +import { Header } from 'components/layouts/Integrations/header' +import ProjectLayout from 'components/layouts/ProjectLayout/ProjectLayout' +import { ProductMenu } from 'components/ui/ProductMenu' +import { ProductMenuGroup } from 'components/ui/ProductMenu/ProductMenu.types' +import { useScroll } from 'framer-motion' +import { useSelectedProject } from 'hooks/misc/useSelectedProject' +import { withAuth } from 'hooks/misc/withAuth' +import { useFlag } from 'hooks/ui/useFlag' +import { IntegrationTabs } from './tabs' +import { Menu, Separator } from 'ui' +import ProductMenuItem from 'components/ui/ProductMenu/ProductMenuItem' +import { GenericSkeletonLoader } from 'ui-patterns' +import AlertError from 'components/ui/AlertError' + +/** + * Layout component for the Integrations section + * Handles scroll-based sticky header behavior and authentication + */ +const IntegrationsLayout = ({ ...props }: PropsWithChildren) => { + const layoutSidebar = useFlag('integrationLayoutSidebar') + if (layoutSidebar) { + return + } + return +} + +/** + * Top level layout + */ +const IntegrationTopHeaderLayout = ({ ...props }: PropsWithChildren) => { + const project = useSelectedProject() + const router = useRouter() + // Refs for the main scrollable area and header + const mainElementRef = useRef(null) + const headerRef = useRef(null) + // Track if header should be in sticky state + const [isSticky, setIsSticky] = useState(false) + + // State to hold the scrollable container element + const [container, setContainer] = useState(null) + + // Initialize framer-motion scroll tracking + // Only tracks scroll when container is available + const scroll = useScroll({ + container: container ? { current: container } : undefined, + }) + + // Set up container reference once mainElementRef is mounted + useEffect(() => { + if (mainElementRef.current) { + setContainer(mainElementRef.current) + } + }, [mainElementRef.current]) + + // Set up scroll event listener to handle sticky header behavior + useEffect(() => { + // Exit if scroll tracking isn't available yet + if (!scroll.scrollY) return + + // Update sticky state based on scroll position relative to header height + const handleScroll = (latest: number) => { + if (headerRef.current) { + setIsSticky(latest > headerRef.current.offsetHeight) + } + } + + // Subscribe to scroll position changes + const unsubscribe = scroll.scrollY.on('change', handleScroll) + + // Clean up scroll listener on unmount + return () => { + unsubscribe() + } + }, [scroll.scrollY]) + + const segments = router.asPath.split('/') + // construct the page url to be used to determine the active state for the sidebar + const page = `${segments[3]}${segments[4] ? `/${segments[4]}` : ''}` + + const { + installedIntegrations: integrations, + error, + isLoading, + isSuccess, + isError, + } = useInstalledIntegrations() + const installedIntegrationItems = integrations.map((integration) => ({ + name: integration.name, + label: integration.beta ? 'Beta' : undefined, + key: `integrations/${integration.id}`, + url: `/project/${project?.ref}/integrations/${integration.id}/overview`, + icon: ( +
+ {integration.icon({ className: 'p-1' })} +
+ ), + items: [], + })) + + return ( + + + +
+ + Installed integrations +
+ } + /> + {isLoading && } + {isError && ( + + )} + {isSuccess && ( +
+ {installedIntegrationItems.map((item) => ( + + ))} +
+ )} +
+ + } + > +
+ + {props.children} + + ) +} + +const IntegrationsLayoutSide = ({ ...props }: PropsWithChildren) => { + const router = useRouter() + const page = router.pathname.split('/')[4] + const project = useSelectedProject() + + const { + installedIntegrations: integrations, + error, + isLoading, + isError, + isSuccess, + } = useInstalledIntegrations() + const installedIntegrationItems = integrations.map((integration) => ({ + name: integration.name, + label: integration.beta ? 'Beta' : undefined, + key: `integrations/${integration.id}`, + url: `/project/${project?.ref}/integrations/${integration.id}/overview`, + icon: ( +
+ {integration.icon({ className: 'p-1' })} +
+ ), + items: [], + })) + + return ( + + + +
+ + Installed integrations +
+ } + /> + {isLoading && } + {isError && ( + + )} + {isSuccess && ( +
+ {installedIntegrationItems.map((item) => ( + + ))} +
+ )} +
+ + } + > + {props.children} + + ) +} + +// Wrap component with authentication HOC before exporting +export default withAuth(IntegrationsLayout) + +const generateIntegrationsMenu = ({ projectRef }: { projectRef?: string }): ProductMenuGroup[] => { + return [ + { + title: 'All Integrations', + items: [ + { + name: 'All Integrations', + key: 'integrations', + url: `/project/${projectRef}/integrations`, + items: [], + }, + ], + }, + ].filter(Boolean) +} diff --git a/apps/studio/components/layouts/Integrations/tabs.tsx b/apps/studio/components/layouts/Integrations/tabs.tsx new file mode 100644 index 00000000000..1d53f7c8567 --- /dev/null +++ b/apps/studio/components/layouts/Integrations/tabs.tsx @@ -0,0 +1,116 @@ +import { AnimatePresence, motion, MotionProps, useScroll, useTransform } from 'framer-motion' +import { ChevronRight } from 'lucide-react' +import Link from 'next/link' +import { ComponentProps, ComponentType, useRef } from 'react' + +import { useParams } from 'common' +import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' +import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' +import { NavMenu, NavMenuItem } from 'ui' +import { useProjectContext } from '../ProjectLayout/ProjectContext' + +const MotionNavMenu = motion(NavMenu) as ComponentType & MotionProps> + +// Output range: The padding range for the nav (from compact to expanded) +const paddingRange = [40, 86] + +// Output range: The padding range for the image +const iconPaddingRange = [3, 1.5] // From 1.5px (scrolled) to 4px (top) + +interface IntegrationTabsProps { + scroll: ReturnType + isSticky?: boolean +} + +export const IntegrationTabs = ({ scroll, isSticky }: IntegrationTabsProps) => { + const navRef = useRef(null) + const { project } = useProjectContext() + const { id, pageId, childId } = useParams() + + const { installedIntegrations } = useInstalledIntegrations() + // Find the integration details based on ID + const integration = INTEGRATIONS.find((i) => i.id === id) + + const headerRef = useRef(null) + + // Input range: The scrollY range for triggering the animation (e.g., 0 to 200px of scroll) + const scrollRange = [40, headerRef.current?.offsetHeight ?? 128] + const navInnerLeftPaddingX = useTransform(scroll?.scrollY!, scrollRange, paddingRange) + const iconPadding = useTransform(scroll?.scrollY!, scrollRange, iconPaddingRange) + + const installedIntegration = installedIntegrations?.find((i) => i.id === id) + + const tabs = installedIntegration + ? integration?.navigation ?? [] + : (integration?.navigation ?? []).filter((tab) => tab.route === 'overview') + + if (!integration) return null + + return ( + +
+ + {isSticky && ( + + {integration?.icon({ + style: { padding: iconPadding.get() }, + })} + + )} + + {tabs.map((tab) => { + const tabUrl = `/project/${project?.ref}/integrations/${integration?.id}/${tab.route}` + return ( +
+ + {tab.label} + + + + {tab.hasChild && childId && ( + <> + + + + + + {tab.childIcon} + + {childId} + + + + + )} + +
+ ) + })} +
+
+
+ ) +} + +IntegrationTabs.displayName = 'IntegrationTabs' diff --git a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx index 6d695d60116..d55afbd075c 100644 --- a/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx +++ b/apps/studio/components/layouts/ProjectLayout/NavigationBar/NavigationBar.utils.tsx @@ -145,7 +145,7 @@ export const generateOtherRoutes = (ref?: string, project?: Project): Route[] => key: 'integrations', label: 'Integrations', icon: , - link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/integrations/wrappers`), + link: ref && (isProjectBuilding ? buildingUrl : `/project/${ref}/integrations`), }, ] } diff --git a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx index 822a8882d9d..204958a3349 100644 --- a/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx +++ b/apps/studio/components/layouts/ProjectLayout/ProjectLayout.tsx @@ -1,9 +1,11 @@ import { useParams } from 'common' import Head from 'next/head' import { useRouter } from 'next/router' -import { Fragment, PropsWithChildren, ReactNode, useEffect } from 'react' +import { forwardRef, Fragment, PropsWithChildren, ReactNode, useEffect } from 'react' +import { useIsAssistantV2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' import ProjectAPIDocs from 'components/interfaces/ProjectAPIDocs/ProjectAPIDocs' +import { AiAssistantPanel } from 'components/ui/AIAssistantPanel/AIAssistantPanel' import AISettingsModal from 'components/ui/AISettingsModal' import { Loading } from 'components/ui/Loading' import ResourceExhaustionWarningBanner from 'components/ui/ResourceExhaustionWarningBanner/ResourceExhaustionWarningBanner' @@ -12,8 +14,9 @@ import { useSelectedProject } from 'hooks/misc/useSelectedProject' import { withAuth } from 'hooks/misc/withAuth' import { useFlag } from 'hooks/ui/useFlag' import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' +import { useAppStateSnapshot } from 'state/app-state' import { useDatabaseSelectorStateSnapshot } from 'state/database-selector' -import { ResizableHandle, ResizablePanel, ResizablePanelGroup, cn } from 'ui' +import { cn, ResizableHandle, ResizablePanel, ResizablePanelGroup } from 'ui' import AppLayout from '../AppLayout/AppLayout' import EnableBranchingModal from '../AppLayout/EnableBranchingButton/EnableBranchingModal' import BuildingState from './BuildingState' @@ -26,14 +29,11 @@ import PauseFailedState from './PauseFailedState' import PausingState from './PausingState' import ProductMenuBar from './ProductMenuBar' import { ProjectContextProvider } from './ProjectContext' +import { ResizingState } from './ResizingState' import RestartingState from './RestartingState' import RestoreFailedState from './RestoreFailedState' import RestoringState from './RestoringState' import { UpgradingState } from './UpgradingState' -import { ResizingState } from './ResizingState' -import { AiAssistantPanel } from 'components/ui/AIAssistantPanel/AIAssistantPanel' -import { useAppStateSnapshot } from 'state/app-state' -import { useIsAssistantV2Enabled } from 'components/interfaces/App/FeaturePreview/FeaturePreviewContext' // [Joshen] This is temporary while we unblock users from managing their project // if their project is not responding well for any reason. Eventually needs a bit of an overhaul @@ -72,143 +72,153 @@ export interface ProjectLayoutProps { resizableSidebar?: boolean } -const ProjectLayout = ({ - title, - isLoading = false, - isBlocking = true, - product = '', - productMenu, - children, - hideHeader = false, - hideIconBar = false, - selectedTable, - resizableSidebar = false, -}: PropsWithChildren) => { - const router = useRouter() - const { ref: projectRef } = useParams() - const selectedOrganization = useSelectedOrganization() - const selectedProject = useSelectedProject() - const { aiAssistantPanel, setAiAssistantPanel } = useAppStateSnapshot() - const { open } = aiAssistantPanel +const ProjectLayout = forwardRef>( + ( + { + title, + isLoading = false, + isBlocking = true, + product = '', + productMenu, + children, + hideHeader = false, + hideIconBar = false, + selectedTable, + resizableSidebar = false, + }, + ref + ) => { + const router = useRouter() + const { ref: projectRef } = useParams() + const selectedOrganization = useSelectedOrganization() + const selectedProject = useSelectedProject() + const { aiAssistantPanel, setAiAssistantPanel } = useAppStateSnapshot() + const { open } = aiAssistantPanel - const navLayoutV2 = useFlag('navigationLayoutV2') - const isAssistantV2Enabled = useIsAssistantV2Enabled() + const navLayoutV2 = useFlag('navigationLayoutV2') + const isAssistantV2Enabled = useIsAssistantV2Enabled() - const projectName = selectedProject?.name - const organizationName = selectedOrganization?.name + const projectName = selectedProject?.name + const organizationName = selectedOrganization?.name - const isPaused = selectedProject?.status === PROJECT_STATUS.INACTIVE - const showProductMenu = selectedProject - ? selectedProject.status === PROJECT_STATUS.ACTIVE_HEALTHY || - (selectedProject.status === PROJECT_STATUS.COMING_UP && - router.pathname.includes('/project/[ref]/settings')) - : true + const isPaused = selectedProject?.status === PROJECT_STATUS.INACTIVE + const showProductMenu = selectedProject + ? selectedProject.status === PROJECT_STATUS.ACTIVE_HEALTHY || + (selectedProject.status === PROJECT_STATUS.COMING_UP && + router.pathname.includes('/project/[ref]/settings')) + : true - const ignorePausedState = - router.pathname === '/project/[ref]' || router.pathname.includes('/project/[ref]/settings') - const showPausedState = isPaused && !ignorePausedState + const ignorePausedState = + router.pathname === '/project/[ref]' || router.pathname.includes('/project/[ref]/settings') + const showPausedState = isPaused && !ignorePausedState - useEffect(() => { - const handler = (e: KeyboardEvent) => { - if (e.metaKey && e.code === 'KeyI') setAiAssistantPanel({ open: !open }) - } - if (isAssistantV2Enabled) window.addEventListener('keydown', handler) - return () => window.removeEventListener('keydown', handler) - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [isAssistantV2Enabled, open]) + useEffect(() => { + const handler = (e: KeyboardEvent) => { + if (e.metaKey && e.code === 'KeyI') setAiAssistantPanel({ open: !open }) + } + if (isAssistantV2Enabled) window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isAssistantV2Enabled, open]) - return ( - - - - - {title - ? `${title} | Supabase` - : selectedTable - ? `${selectedTable} | ${projectName} | ${organizationName} | Supabase` - : projectName - ? `${projectName} | ${organizationName} | Supabase` - : organizationName - ? `${organizationName} | Supabase` - : 'Supabase'} - - - -
- {/* Left-most navigation side bar to access products */} - {!hideIconBar && } - {/* Product menu bar */} - - + + + + {title + ? `${title} | Supabase` + : selectedTable + ? `${selectedTable} | ${projectName} | ${organizationName} | Supabase` + : projectName + ? `${projectName} | ${organizationName} | Supabase` + : organizationName + ? `${organizationName} | Supabase` + : 'Supabase'} + + + +
+ {/* Left-most navigation side bar to access products */} + {!hideIconBar && } + {/* Product menu bar */} + - - {productMenu} - - - - - {!navLayoutV2 && !hideHeader && IS_PLATFORM && } - - -
- {showPausedState ? ( -
-
- -
-
- ) : ( - - - {children} - - )} -
-
- {aiAssistantPanel.open && ( - <> - - + {productMenu} + + + + + {!navLayoutV2 && !hideHeader && IS_PLATFORM && } + + +
- - - - )} - - - -
+ {showPausedState ? ( +
+
+ +
+
+ ) : ( + + + {children} + + )} + +
+ {aiAssistantPanel.open && ( + <> + + + + + + )} +
+ + +
- - - -
-
- ) -} + + + + + + ) + } +) + +ProjectLayout.displayName = 'ProjectLayout' export const ProjectLayoutWithAuth = withAuth(ProjectLayout) diff --git a/apps/studio/components/ui/AlertError.tsx b/apps/studio/components/ui/AlertError.tsx index e007b7c37da..95bd6bc6fcf 100644 --- a/apps/studio/components/ui/AlertError.tsx +++ b/apps/studio/components/ui/AlertError.tsx @@ -9,11 +9,18 @@ export interface AlertErrorProps { subject?: string error?: ResponseError | null className?: string + showIcon?: boolean } // [Joshen] To standardize the language for all error UIs -const AlertError = ({ projectRef, subject, error, className }: AlertErrorProps) => { +const AlertError = ({ + projectRef, + subject, + error, + className, + showIcon = true, +}: AlertErrorProps) => { const subjectString = subject?.replace(/ /g, '%20') let href = `/support/new?category=dashboard_bug` @@ -27,7 +34,7 @@ const AlertError = ({ projectRef, subject, error, className }: AlertErrorProps) return ( - + {showIcon && } {subject}
diff --git a/apps/studio/components/ui/CountdownTimer/CountdownTimerSpan.tsx b/apps/studio/components/ui/CountdownTimer/CountdownTimerSpan.tsx index 166e7818ae0..11af6fd0aa2 100644 --- a/apps/studio/components/ui/CountdownTimer/CountdownTimerSpan.tsx +++ b/apps/studio/components/ui/CountdownTimer/CountdownTimerSpan.tsx @@ -1,14 +1,10 @@ -import MotionNumber from 'motion-number' +import MotionNumber from '@number-flow/react' interface CountdownTimerSpanProps { seconds: number } const CountdownTimerSpan = ({ seconds }: CountdownTimerSpanProps) => { - const transitionConfig = { - y: { type: 'spring', duration: 0.35, bounce: 0 }, - } - const hours = Math.floor(seconds / 3600) const minutes = Math.floor((seconds % 3600) / 60) const remainingSeconds = seconds % 60 @@ -20,23 +16,11 @@ const CountdownTimerSpan = ({ seconds }: CountdownTimerSpanProps) => { Time remaining: - + hr - + m - + ) diff --git a/apps/studio/components/ui/ProductMenu/ProductMenu.tsx b/apps/studio/components/ui/ProductMenu/ProductMenu.tsx index 8e81e63af56..798118f10c3 100644 --- a/apps/studio/components/ui/ProductMenu/ProductMenu.tsx +++ b/apps/studio/components/ui/ProductMenu/ProductMenu.tsx @@ -43,7 +43,7 @@ const ProductMenu = ({ page, menu }: ProductMenuProps) => {
- {idx !== menu.length - 1 &&
} + {idx !== menu.length - 1 &&
}
))} diff --git a/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx b/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx index 5441340bb8d..e05c15edc2f 100644 --- a/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx +++ b/apps/studio/components/ui/ProductMenu/ProductMenuItem.tsx @@ -37,7 +37,11 @@ const ProductMenuItem = ({ className={'flex items-center gap-2 truncate w-full ' + textClassName} > {name} - {label !== undefined && {label}} + {label !== undefined && ( + + {label} + + )}
{rightIcon &&
{rightIcon}
}
diff --git a/apps/studio/next.config.js b/apps/studio/next.config.js index 75b21301149..4a6a4a7e86b 100644 --- a/apps/studio/next.config.js +++ b/apps/studio/next.config.js @@ -533,6 +533,12 @@ const nextConfig = { } }) + // .md files to be loaded as raw text + config.module.rules.push({ + test: /\.md$/, + type: 'asset/source', + }) + return config }, onDemandEntries: { diff --git a/apps/studio/package.json b/apps/studio/package.json index 4a1247b1c49..9d446523616 100644 --- a/apps/studio/package.json +++ b/apps/studio/package.json @@ -61,7 +61,7 @@ "dayjs": "^1.11.10", "dnd-core": "^16.0.1", "file-saver": "^2.0.5", - "framer-motion": "^11.0.3", + "framer-motion": "^11.11.17", "generate-password-browser": "^1.1.0", "html-to-image": "^1.10.8", "icons": "*", @@ -77,7 +77,7 @@ "mobx": "^6.10.2", "mobx-react-lite": "^4.0.5", "monaco-editor": "0.33.0", - "motion-number": "^0.1.7", + "@number-flow/react": "^0.3.2", "next": "^14.2.10", "next-themes": "^0.3.0", "nuqs": "^1.17.8", diff --git a/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx b/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx index 86f9f0ac60c..e9c5d5fdfd6 100644 --- a/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx +++ b/apps/studio/pages/integrations/vercel/[slug]/marketplace/choose-project.tsx @@ -3,8 +3,10 @@ import { useCallback, useMemo } from 'react' import { toast } from 'sonner' import { useParams } from 'common' -import { ENV_VAR_RAW_KEYS } from 'components/interfaces/Integrations/Integrations-Vercel.constants' -import ProjectLinker, { ForeignProject } from 'components/interfaces/Integrations/ProjectLinker' +import { ENV_VAR_RAW_KEYS } from 'components/interfaces/Integrations/Vercel/Integrations-Vercel.constants' +import ProjectLinker, { + ForeignProject, +} from 'components/interfaces/Integrations/VercelGithub/ProjectLinker' import { Markdown } from 'components/interfaces/Markdown' import VercelIntegrationWindowLayout from 'components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout' import { ScaffoldColumn, ScaffoldContainer } from 'components/layouts/Scaffold' diff --git a/apps/studio/pages/integrations/vercel/install.tsx b/apps/studio/pages/integrations/vercel/install.tsx index 03b0333121b..11f67f47342 100644 --- a/apps/studio/pages/integrations/vercel/install.tsx +++ b/apps/studio/pages/integrations/vercel/install.tsx @@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { AlertDescription_Shadcn_, AlertTitle_Shadcn_, Alert_Shadcn_, Button } from 'ui' -import OrganizationPicker from 'components/interfaces/Integrations/OrganizationPicker' +import OrganizationPicker from 'components/interfaces/Integrations/Vercel/OrganizationPicker' import { Markdown } from 'components/interfaces/Markdown' import { getHasInstalledObject } from 'components/layouts/IntegrationsLayout/Integrations.utils' import VercelIntegrationWindowLayout from 'components/layouts/IntegrationsLayout/VercelIntegrationWindowLayout' diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx new file mode 100644 index 00000000000..e25dc2980fe --- /dev/null +++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/[childId]/index.tsx @@ -0,0 +1,63 @@ +import { useParams } from 'common' +import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' +import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' +import IntegrationsLayout from 'components/layouts/Integrations/layout' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useRouter } from 'next/compat/router' +import { useEffect, useMemo } from 'react' +import { NextPageWithLayout } from 'types' + +const IntegrationPage: NextPageWithLayout = () => { + const router = useRouter() + const { ref, id, pageId, childId } = useParams() + + const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } = + useInstalledIntegrations() + + // everything is wrapped in useMemo to avoid UI resets when installing additional extensions like pg_net + const integration = useMemo(() => INTEGRATIONS.find((i) => i.id === id), [id]) + + const installation = useMemo( + () => installedIntegrations.find((inst) => inst.id === id), + [installedIntegrations, id] + ) + + // Get the corresponding component dynamically + const Component = useMemo( + () => integration?.navigate(id!, pageId, childId), + [integration, id, pageId, childId] + ) + + useEffect(() => { + // if the integration is not installed, redirect to the overview page + if ( + router && + router?.isReady && + !isIntegrationsLoading && + !installation && + pageId !== 'overview' + ) { + router.replace(`/project/${ref}/integrations/${id}/overview`) + } + }, [installation, isIntegrationsLoading, pageId, router]) + + if (!router?.isReady || isIntegrationsLoading) { + return ( +
+ +
+ ) + } + + if (!id || !integration) { + return
Integration not found
+ } + + if (!Component) return
Component not found
+ + return +} + +IntegrationPage.getLayout = (page) => {page} + +export default IntegrationPage diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx new file mode 100644 index 00000000000..e25dc2980fe --- /dev/null +++ b/apps/studio/pages/project/[ref]/integrations/[id]/[pageId]/index.tsx @@ -0,0 +1,63 @@ +import { useParams } from 'common' +import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' +import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' +import IntegrationsLayout from 'components/layouts/Integrations/layout' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useRouter } from 'next/compat/router' +import { useEffect, useMemo } from 'react' +import { NextPageWithLayout } from 'types' + +const IntegrationPage: NextPageWithLayout = () => { + const router = useRouter() + const { ref, id, pageId, childId } = useParams() + + const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } = + useInstalledIntegrations() + + // everything is wrapped in useMemo to avoid UI resets when installing additional extensions like pg_net + const integration = useMemo(() => INTEGRATIONS.find((i) => i.id === id), [id]) + + const installation = useMemo( + () => installedIntegrations.find((inst) => inst.id === id), + [installedIntegrations, id] + ) + + // Get the corresponding component dynamically + const Component = useMemo( + () => integration?.navigate(id!, pageId, childId), + [integration, id, pageId, childId] + ) + + useEffect(() => { + // if the integration is not installed, redirect to the overview page + if ( + router && + router?.isReady && + !isIntegrationsLoading && + !installation && + pageId !== 'overview' + ) { + router.replace(`/project/${ref}/integrations/${id}/overview`) + } + }, [installation, isIntegrationsLoading, pageId, router]) + + if (!router?.isReady || isIntegrationsLoading) { + return ( +
+ +
+ ) + } + + if (!id || !integration) { + return
Integration not found
+ } + + if (!Component) return
Component not found
+ + return +} + +IntegrationPage.getLayout = (page) => {page} + +export default IntegrationPage diff --git a/apps/studio/pages/project/[ref]/integrations/[id]/index.tsx b/apps/studio/pages/project/[ref]/integrations/[id]/index.tsx new file mode 100644 index 00000000000..e25dc2980fe --- /dev/null +++ b/apps/studio/pages/project/[ref]/integrations/[id]/index.tsx @@ -0,0 +1,63 @@ +import { useParams } from 'common' +import { INTEGRATIONS } from 'components/interfaces/Integrations/Landing/Integrations.constants' +import { useInstalledIntegrations } from 'components/interfaces/Integrations/Landing/useInstalledIntegrations' +import IntegrationsLayout from 'components/layouts/Integrations/layout' +import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' +import { useRouter } from 'next/compat/router' +import { useEffect, useMemo } from 'react' +import { NextPageWithLayout } from 'types' + +const IntegrationPage: NextPageWithLayout = () => { + const router = useRouter() + const { ref, id, pageId, childId } = useParams() + + const { installedIntegrations: installedIntegrations, isLoading: isIntegrationsLoading } = + useInstalledIntegrations() + + // everything is wrapped in useMemo to avoid UI resets when installing additional extensions like pg_net + const integration = useMemo(() => INTEGRATIONS.find((i) => i.id === id), [id]) + + const installation = useMemo( + () => installedIntegrations.find((inst) => inst.id === id), + [installedIntegrations, id] + ) + + // Get the corresponding component dynamically + const Component = useMemo( + () => integration?.navigate(id!, pageId, childId), + [integration, id, pageId, childId] + ) + + useEffect(() => { + // if the integration is not installed, redirect to the overview page + if ( + router && + router?.isReady && + !isIntegrationsLoading && + !installation && + pageId !== 'overview' + ) { + router.replace(`/project/${ref}/integrations/${id}/overview`) + } + }, [installation, isIntegrationsLoading, pageId, router]) + + if (!router?.isReady || isIntegrationsLoading) { + return ( +
+ +
+ ) + } + + if (!id || !integration) { + return
Integration not found
+ } + + if (!Component) return
Component not found
+ + return +} + +IntegrationPage.getLayout = (page) => {page} + +export default IntegrationPage diff --git a/apps/studio/pages/project/[ref]/integrations/cron-jobs.tsx b/apps/studio/pages/project/[ref]/integrations/cron-jobs.tsx deleted file mode 100644 index 1a59917e814..00000000000 --- a/apps/studio/pages/project/[ref]/integrations/cron-jobs.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { CronJobsListing } from 'components/interfaces/Integrations/CronJobs/CronJobsListing' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' -import { DocsButton } from 'components/ui/DocsButton' -import { FormHeader } from 'components/ui/Forms/FormHeader' -import { GenericSkeletonLoader } from 'components/ui/ShimmeringLoader' -import { CronJob, useCronJobsQuery } from 'data/database-cron-jobs/database-cron-jobs-query' -import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' -import { useEffect, useState } from 'react' -import type { NextPageWithLayout } from 'types' -import { Button } from 'ui' -import { CronJobsDisabledState } from '../../../../components/interfaces/Integrations/CronJobs/CronJobsDisabledState' - -import { CreateCronJobSheet } from 'components/interfaces/Integrations/CronJobs/CreateCronJobSheet' -import { Sheet, SheetContent } from 'ui' - -const CronJobsPage: NextPageWithLayout = () => { - const { project } = useProjectContext() - const [isClosingCreateCronJobSheet, setIsClosingCreateCronJobSheet] = useState(false) - const [createCronJobSheetShown, setCreateCronJobSheetShown] = useState< - Pick | undefined - >() - - const { isError, refetch } = useCronJobsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - const { data: extensions, isLoading: isLoadingExtensions } = useDatabaseExtensionsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - const pgCronExtension = (extensions ?? []).find((ext) => ext.name === 'pg_cron') - const pgCronExtensionInstalled = pgCronExtension?.installed_version - - useEffect(() => { - // refetch the cron jobs after the pgcron extension has been installed - if (pgCronExtensionInstalled && isError) { - refetch() - } - }, [isError, pgCronExtensionInstalled, refetch]) - - return ( - - -
- {pgCronExtensionInstalled && ( - - - - - } - /> - )} - {isLoadingExtensions ? ( - - ) : !pgCronExtensionInstalled ? ( - - ) : ( - - )} -
-
- - setIsClosingCreateCronJobSheet(true)} - > - - { - setIsClosingCreateCronJobSheet(false) - setCreateCronJobSheetShown(undefined) - }} - isClosing={isClosingCreateCronJobSheet} - setIsClosing={setIsClosingCreateCronJobSheet} - /> - - -
- ) -} - -CronJobsPage.getLayout = (page) => ( - {page} -) - -export default CronJobsPage diff --git a/apps/studio/pages/project/[ref]/integrations/hooks.tsx b/apps/studio/pages/project/[ref]/integrations/hooks.tsx deleted file mode 100644 index 980c6005e31..00000000000 --- a/apps/studio/pages/project/[ref]/integrations/hooks.tsx +++ /dev/null @@ -1,131 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useState } from 'react' -import { toast } from 'sonner' - -import { useParams } from 'common' -import DeleteHookModal from 'components/interfaces/Database/Hooks/DeleteHookModal' -import EditHookPanel from 'components/interfaces/Database/Hooks/EditHookPanel' -import HooksList from 'components/interfaces/Database/Hooks/HooksList/HooksList' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' -import ProductEmptyState from 'components/to-be-cleaned/ProductEmptyState' -import { FormHeader } from 'components/ui/Forms/FormHeader' -import NoPermission from 'components/ui/NoPermission' -import { useHooksEnableMutation } from 'data/database/hooks-enable-mutation' -import { useSchemasQuery } from 'data/database/schemas-query' -import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' -import type { NextPageWithLayout } from 'types' - -const HooksPage: NextPageWithLayout = () => { - const { project } = useProjectContext() - - const { ref: projectRef } = useParams() - - const { - data: schemas, - isSuccess: isSchemasLoaded, - refetch, - } = useSchemasQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - const [selectedHook, setSelectedHook] = useState() - const [showCreateHookForm, setShowCreateHookForm] = useState(false) - const [showDeleteHookForm, setShowDeleteHookForm] = useState(false) - - const isHooksEnabled = schemas?.some((schema) => schema.name === 'supabase_functions') - const canReadWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'triggers') - const canCreateWebhooks = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'triggers') - const isPermissionsLoaded = usePermissionsLoaded() - - const { mutate: enableHooks, isLoading: isEnablingHooks } = useHooksEnableMutation({ - onSuccess: () => { - refetch() - toast.success('Successfully enabled webhooks') - }, - }) - - const enableHooksForProject = async () => { - if (!projectRef) return console.error('Project ref is required') - enableHooks({ ref: projectRef }) - } - - const createHook = () => { - setSelectedHook(undefined) - setShowCreateHookForm(true) - } - - const editHook = (hook: any) => { - setSelectedHook(hook) - setShowCreateHookForm(true) - } - - const deleteHook = (hook: any) => { - setSelectedHook(hook) - setShowDeleteHookForm(true) - } - - if (isPermissionsLoaded && !canReadWebhooks) { - return - } - - if (isSchemasLoaded && !isHooksEnabled) { - return ( -
- enableHooksForProject()} - loading={isEnablingHooks} - disabled={isEnablingHooks || (isPermissionsLoaded && !canReadWebhooks)} - disabledMessage={ - isPermissionsLoaded && !canCreateWebhooks - ? 'You need additional permissions to enable webhooks' - : undefined - } - > -

- Database Webhooks can be used to trigger serverless functions or send requests to an - HTTP endpoint. -

-

Enable database webhooks on your project.

-
-
- ) - } - - return ( - <> - - -
- - -
-
-
- setShowCreateHookForm(false)} - /> - setShowDeleteHookForm(false)} - /> - - ) -} - -HooksPage.getLayout = (page) => ( - {page} -) - -export default HooksPage diff --git a/apps/studio/pages/project/[ref]/integrations/index.tsx b/apps/studio/pages/project/[ref]/integrations/index.tsx new file mode 100644 index 00000000000..58b1087c516 --- /dev/null +++ b/apps/studio/pages/project/[ref]/integrations/index.tsx @@ -0,0 +1,17 @@ +import { AvailableIntegrations } from 'components/interfaces/Integrations/Landing/AvailableIntegrations' +import { InstalledIntegrations } from 'components/interfaces/Integrations/Landing/InstalledIntegrations' +import IntegrationsLayout from 'components/layouts/Integrations/layout' +import type { NextPageWithLayout } from 'types' + +const IntegrationsPage: NextPageWithLayout = () => { + return ( +
+ + +
+ ) +} + +IntegrationsPage.getLayout = (page) => {page} + +export default IntegrationsPage diff --git a/apps/studio/pages/project/[ref]/integrations/queues/index.tsx b/apps/studio/pages/project/[ref]/integrations/queues/index.tsx deleted file mode 100644 index de0b4eb246f..00000000000 --- a/apps/studio/pages/project/[ref]/integrations/queues/index.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { QueuesListing } from 'components/interfaces/Integrations/Queues/QueuesListing' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' -import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' -import type { NextPageWithLayout } from 'types' - -const QueuesPage: NextPageWithLayout = () => { - // TODO: Change this to the correct permissions - // const canReadFunctions = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'functions') - // const isPermissionsLoaded = usePermissionsLoaded() - - // if (isPermissionsLoaded && !canReadFunctions) { - // return - // } - - return ( - - -
- -
-
-
- ) -} - -QueuesPage.getLayout = (page) => ( - {page} -) - -export default QueuesPage diff --git a/apps/studio/pages/project/[ref]/integrations/vault/keys.tsx b/apps/studio/pages/project/[ref]/integrations/vault/keys.tsx deleted file mode 100644 index 63e5fd1b4cb..00000000000 --- a/apps/studio/pages/project/[ref]/integrations/vault/keys.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import { useParams } from 'common' - -import { EncryptionKeysManagement, VaultToggle } from 'components/interfaces/Settings/Vault' -import VaultNavTabs from 'components/interfaces/Settings/Vault/VaultNavTabs' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import SettingsLayout from 'components/layouts/ProjectSettingsLayout/SettingsLayout' -import { - ScaffoldContainer, - ScaffoldDescription, - ScaffoldHeader, - ScaffoldTitle, -} from 'components/layouts/Scaffold' -import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' -import type { NextPageWithLayout } from 'types' - -const VaultSettingsSecrets: NextPageWithLayout = () => { - const { ref } = useParams() - const { project } = useProjectContext() - - const { data, isLoading } = useDatabaseExtensionsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - const vaultExtension = (data ?? []).find((ext) => ext.name === 'supabase_vault') - const isEnabled = vaultExtension !== undefined && vaultExtension.installed_version !== null - - return ( - <> - - - Vault - Application level encryption for your project - - - - {isLoading ? ( -
- - - -
- ) : !isEnabled ? ( - - ) : ( - <> - - - - )} -
- - ) -} - -VaultSettingsSecrets.getLayout = (page) => ( - {page} -) -export default VaultSettingsSecrets diff --git a/apps/studio/pages/project/[ref]/integrations/vault/secrets.tsx b/apps/studio/pages/project/[ref]/integrations/vault/secrets.tsx deleted file mode 100644 index bd3c1c7eade..00000000000 --- a/apps/studio/pages/project/[ref]/integrations/vault/secrets.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useParams } from 'common/hooks' -import { SecretsManagement, VaultToggle } from 'components/interfaces/Settings/Vault' -import VaultNavTabs from 'components/interfaces/Settings/Vault/VaultNavTabs' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' - -import { - ScaffoldContainer, - ScaffoldDescription, - ScaffoldHeader, - ScaffoldTitle, -} from 'components/layouts/Scaffold' -import ShimmeringLoader from 'components/ui/ShimmeringLoader' -import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' -import { useRouter } from 'next/router' -import type { NextPageWithLayout } from 'types' - -const VaultSettingsSecrets: NextPageWithLayout = () => { - const router = useRouter() - const { ref } = useParams() - const { project } = useProjectContext() - - const { data, isLoading } = useDatabaseExtensionsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - - const vaultExtension = (data ?? []).find((ext) => ext.name === 'supabase_vault') - const isEnabled = vaultExtension !== undefined && vaultExtension.installed_version !== null - - return ( - <> - - - Vault - Application level encryption for your project - - - - {isLoading ? ( -
- - - -
- ) : !isEnabled ? ( - - ) : ( - <> - - - - )} -
- - ) -} - -VaultSettingsSecrets.getLayout = (page) => ( - {page} -) -export default VaultSettingsSecrets diff --git a/apps/studio/pages/project/[ref]/integrations/wrappers/[id].tsx b/apps/studio/pages/project/[ref]/integrations/wrappers/[id].tsx deleted file mode 100644 index d9110015d27..00000000000 --- a/apps/studio/pages/project/[ref]/integrations/wrappers/[id].tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' - -import { EditWrapper } from 'components/interfaces/Database' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' -import NoPermission from 'components/ui/NoPermission' -import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' -import type { NextPageWithLayout } from 'types' - -const DatabaseWrappersNew: NextPageWithLayout = () => { - const canReadWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'wrappers') - const isPermissionsLoaded = usePermissionsLoaded() - - if (isPermissionsLoaded && !canReadWrappers) { - return - } - - return -} - -DatabaseWrappersNew.getLayout = (page) => ( - {page} -) - -export default DatabaseWrappersNew diff --git a/apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx b/apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx deleted file mode 100644 index fa21318f6c4..00000000000 --- a/apps/studio/pages/project/[ref]/integrations/wrappers/index.tsx +++ /dev/null @@ -1,80 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' - -import { Wrappers } from 'components/interfaces/Database' -import WrappersDropdown from 'components/interfaces/Database/Wrappers/WrappersDropdown' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' -import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext' -import { - ScaffoldContainer, - ScaffoldSection, - ScaffoldSectionContent, - ScaffoldSectionDetail, -} from 'components/layouts/Scaffold' -import { DocsButton } from 'components/ui/DocsButton' -import { FormHeader } from 'components/ui/Forms/FormHeader' -import NoPermission from 'components/ui/NoPermission' -import { useDatabaseExtensionsQuery } from 'data/database-extensions/database-extensions-query' -import { useFDWsQuery } from 'data/fdw/fdws-query' -import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' -import type { NextPageWithLayout } from 'types' -import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' - -const DatabaseWrappers: NextPageWithLayout = () => { - const { project } = useProjectContext() - const isPermissionsLoaded = usePermissionsLoaded() - const canReadWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'tables') - - const { isLoading: isLoadingFDWs } = useFDWsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - const { data: exts, isLoading: isLoadingExtensions } = useDatabaseExtensionsQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - }) - const wrappersExtension = (exts ?? []).find((ext) => ext.name === 'wrappers') - const vaultExtension = (exts ?? []).find((ext) => ext.name === 'supabase_vault') - - const isWrappersEnabled = - wrappersExtension !== undefined && - wrappersExtension.installed_version !== null && - vaultExtension !== undefined && - vaultExtension.installed_version !== null - - return ( - - - - - - - - {isWrappersEnabled && } - - -
- {isLoadingExtensions || isLoadingFDWs ? ( - - ) : isPermissionsLoaded && !canReadWrappers ? ( - - ) : ( - - )} -
-
-
- ) -} - -DatabaseWrappers.getLayout = (page) => ( - {page} -) - -export default DatabaseWrappers diff --git a/apps/studio/pages/project/[ref]/integrations/wrappers/new.tsx b/apps/studio/pages/project/[ref]/integrations/wrappers/new.tsx deleted file mode 100644 index babc1819c1e..00000000000 --- a/apps/studio/pages/project/[ref]/integrations/wrappers/new.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { PermissionAction } from '@supabase/shared-types/out/constants' - -import { CreateWrapper } from 'components/interfaces/Database' -import ProjectIntegrationsLayout from 'components/layouts/ProjectIntegrationsLayout/ProjectIntegrationsLayout' -import NoPermission from 'components/ui/NoPermission' -import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' -import type { NextPageWithLayout } from 'types' - -const DatabaseWrappersNew: NextPageWithLayout = () => { - const canCreateWrappers = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') - const isPermissionsLoaded = usePermissionsLoaded() - - if (isPermissionsLoaded && !canCreateWrappers) { - return - } - - return -} - -DatabaseWrappersNew.getLayout = (page) => ( - {page} -) - -export default DatabaseWrappersNew diff --git a/apps/studio/static-data/integrations/airtable_wrapper/overview.md b/apps/studio/static-data/integrations/airtable_wrapper/overview.md new file mode 100644 index 00000000000..be72d1fc9c1 --- /dev/null +++ b/apps/studio/static-data/integrations/airtable_wrapper/overview.md @@ -0,0 +1,3 @@ +Airtable is an easy-to-use online platform for creating and sharing relational databases. + +The Airtable Wrapper allows you to read data from your Airtable bases/tables within your Postgres database. diff --git a/apps/studio/static-data/integrations/auth0_wrapper/overview.md b/apps/studio/static-data/integrations/auth0_wrapper/overview.md new file mode 100644 index 00000000000..a18f4b27ec5 --- /dev/null +++ b/apps/studio/static-data/integrations/auth0_wrapper/overview.md @@ -0,0 +1,3 @@ +[Auth0](https://auth0.com/) is a flexible, drop-in solution to add authentication and authorization services to your applications + +The Auth0 Wrapper allows you to read data from your Auth0 tenant for use within your Postgres database. diff --git a/apps/studio/static-data/integrations/bigquery_wrapper/overview.md b/apps/studio/static-data/integrations/bigquery_wrapper/overview.md new file mode 100644 index 00000000000..ebab5c701bc --- /dev/null +++ b/apps/studio/static-data/integrations/bigquery_wrapper/overview.md @@ -0,0 +1,3 @@ +BigQuery is a completely serverless and cost-effective enterprise data warehouse that works across clouds and scales with your data, with BI, machine learning and AI built in. + +The BigQuery Wrapper allows you to read and write data from BigQuery within your Postgres database. diff --git a/apps/studio/static-data/integrations/clickhouse_wrapper/overview.md b/apps/studio/static-data/integrations/clickhouse_wrapper/overview.md new file mode 100644 index 00000000000..ee7c42c6f93 --- /dev/null +++ b/apps/studio/static-data/integrations/clickhouse_wrapper/overview.md @@ -0,0 +1,3 @@ +ClickHouse is a fast open-source column-oriented database management system that allows generating analytical data reports in real-time using SQL queries. + +The ClickHouse Wrapper allows you to read and write data from ClickHouse within your Postgres database. diff --git a/apps/studio/static-data/integrations/cognito_wrapper/overview.md b/apps/studio/static-data/integrations/cognito_wrapper/overview.md new file mode 100644 index 00000000000..ae2274e4681 --- /dev/null +++ b/apps/studio/static-data/integrations/cognito_wrapper/overview.md @@ -0,0 +1,3 @@ +[AWS Cognito](https://docs.aws.amazon.com/cognito/latest/developerguide/what-is-amazon-cognito.html) is an identity platform for web and mobile apps. + +The Cognito wrapper allows you to read data from your Cognito Userpool within your Postgres database. diff --git a/apps/studio/static-data/integrations/cron-jobs/overview.md b/apps/studio/static-data/integrations/cron-jobs/overview.md new file mode 100644 index 00000000000..b9cfc653dfa --- /dev/null +++ b/apps/studio/static-data/integrations/cron-jobs/overview.md @@ -0,0 +1 @@ +Cron jobs uses the `pg_cron` Postgres extension, which is a simple cron-based job scheduler for Postgres that runs inside the database. It uses the same syntax as a regular cron, but it allows you to schedule Postgres SQL commands directly from the database. diff --git a/apps/studio/static-data/integrations/firebase_wrapper/overview.md b/apps/studio/static-data/integrations/firebase_wrapper/overview.md new file mode 100644 index 00000000000..9433175384d --- /dev/null +++ b/apps/studio/static-data/integrations/firebase_wrapper/overview.md @@ -0,0 +1,3 @@ +Firebase is an app development platform built around non-relational technologies. The Firebase Wrapper supports connecting to below objects. + +The Firebase Wrapper is aforeign data wrapper which allows you to read data from Firebase within your Postgres database. diff --git a/apps/studio/static-data/integrations/graphiql/overview.md b/apps/studio/static-data/integrations/graphiql/overview.md new file mode 100644 index 00000000000..60258d5f534 --- /dev/null +++ b/apps/studio/static-data/integrations/graphiql/overview.md @@ -0,0 +1,3 @@ +GraphiQL is the GraphQL integrated development environment (IDE). It’s a powerful (and all-around awesome) tool you’ll use often while building Gatsby websites. It offers syntax highlighting, intellisense autocompletion, automatic documentation, and much more. + +Use GraphiQL to interactively build your page and static queries. diff --git a/apps/studio/static-data/integrations/logflare_wrapper/overview.md b/apps/studio/static-data/integrations/logflare_wrapper/overview.md new file mode 100644 index 00000000000..5cb4e5cad0e --- /dev/null +++ b/apps/studio/static-data/integrations/logflare_wrapper/overview.md @@ -0,0 +1,3 @@ +[Logflare](https://logflare.app/) is a centralized web-based log management solution to easily access Cloudflare, Vercel & Elixir logs. + +The Logflare Wrapper allows you to read data from Logflare endpoints within your Postgres database. diff --git a/apps/studio/static-data/integrations/mssql_wrapper/overview.md b/apps/studio/static-data/integrations/mssql_wrapper/overview.md new file mode 100644 index 00000000000..648dfa0247b --- /dev/null +++ b/apps/studio/static-data/integrations/mssql_wrapper/overview.md @@ -0,0 +1,3 @@ +[Microsoft SQL Server](https://www.microsoft.com/en-au/sql-server/) is a proprietary relational database management system developed by Microsoft. + +The SQL Server Wrapper allows you to read data from Microsoft SQL Server within your Postgres database. diff --git a/apps/studio/static-data/integrations/paddle_wrapper/overview.md b/apps/studio/static-data/integrations/paddle_wrapper/overview.md new file mode 100644 index 00000000000..7eb5fd4fb7f --- /dev/null +++ b/apps/studio/static-data/integrations/paddle_wrapper/overview.md @@ -0,0 +1,3 @@ +[Paddle](https://www.paddle.com/) is a merchant of record that acts to provide a payment infrastructure to thousands of software companies around the world. + +The Paddle Wrapper is a WebAssembly(Wasm) foreign data wrapper which allows you to read and write data from Paddle within your Postgres database. diff --git a/apps/studio/static-data/integrations/queues/overview.md b/apps/studio/static-data/integrations/queues/overview.md new file mode 100644 index 00000000000..09bebecd8c3 --- /dev/null +++ b/apps/studio/static-data/integrations/queues/overview.md @@ -0,0 +1 @@ +Queues uses the `pgmq` Postgres extension, which is a lightweight message queue like [AWS SQS](https://aws.amazon.com/sqs/) and [RSMQ](https://github.com/smrchy/rsmq) but on Postgres. diff --git a/apps/studio/static-data/integrations/redis_wrapper/overview.md b/apps/studio/static-data/integrations/redis_wrapper/overview.md new file mode 100644 index 00000000000..dc7bd959d3c --- /dev/null +++ b/apps/studio/static-data/integrations/redis_wrapper/overview.md @@ -0,0 +1,3 @@ +[Redis](https://redis.io/) is an open-source in-memory storage, used as a distributed, in-memory key–value database, cache and message broker, with optional durability. + +The Redis Wrapper allows you to read data from Redis within your Postgres database. diff --git a/apps/studio/static-data/integrations/s3_wrapper/overview.md b/apps/studio/static-data/integrations/s3_wrapper/overview.md new file mode 100644 index 00000000000..b6952048470 --- /dev/null +++ b/apps/studio/static-data/integrations/s3_wrapper/overview.md @@ -0,0 +1,15 @@ +AWS S3 is an object storage service offering industry-leading scalability, data availability, security, and performance. It is read-only and supports below file formats: + +- CSV (with or without header line) +- [JSON Lines](https://jsonlines.org/) +- [Parquet](https://parquet.apache.org/) + +The S3 Wrapper also supports below compression algorithms: + +- gzip +- bzip2 +- xz +- zlib + +Note for CSV and JSONL files: currently all columns in S3 files must be defined in the foreign table and their types must be `text` type. +Note for Parquet files: the whole Parquet file will be loaded into local memory if it is compressed, so keep the file size as small as possible. diff --git a/apps/studio/static-data/integrations/snowflake_wrapper/overview.md b/apps/studio/static-data/integrations/snowflake_wrapper/overview.md new file mode 100644 index 00000000000..b38a0db9b58 --- /dev/null +++ b/apps/studio/static-data/integrations/snowflake_wrapper/overview.md @@ -0,0 +1,3 @@ +[Snowflake](https://www.snowflake.com/) is a cloud-based data platform provided as a DaaS (Data-as-a-Service) solution with data storage and analytics service. + +The Snowflake Wrapper is a WebAssembly(Wasm) foreign data wrapper which allows you to read and write data from Snowflake within your Postgres database. diff --git a/apps/studio/static-data/integrations/stripe_wrapper/overview.md b/apps/studio/static-data/integrations/stripe_wrapper/overview.md new file mode 100644 index 00000000000..fd7aea7683f --- /dev/null +++ b/apps/studio/static-data/integrations/stripe_wrapper/overview.md @@ -0,0 +1,3 @@ +[Stripe](https://stripe.com/) is an API driven payment processing and subscription management platform. + +The Stripe Wrapper is a foreign data wrapper which allows you to read and write data from Stripe within your Postgres database. diff --git a/apps/studio/static-data/integrations/vault/overview.md b/apps/studio/static-data/integrations/vault/overview.md new file mode 100644 index 00000000000..f2f0e8a7928 --- /dev/null +++ b/apps/studio/static-data/integrations/vault/overview.md @@ -0,0 +1,3 @@ +Vault is a Postgres extension and accompanying Supabase UI that makes it safe and easy to store encrypted secrets and other data in your database. This opens up a lot of possibilities to use Postgres in ways that go beyond what is available in a stock distribution. + +Under the hood, the Vault is a table of Secrets and Encryption Keys that are stored using Authenticated Encryption on disk. They are then available in decrypted form through a Postgres view so that the secrets can be used by applications from SQL. Because the secrets are stored on disk encrypted and authenticated, any backups or replication streams also preserve this encryption in a way that can't be decrypted or forged. diff --git a/apps/studio/static-data/integrations/webhooks/overview.md b/apps/studio/static-data/integrations/webhooks/overview.md new file mode 100644 index 00000000000..b7d1f308a90 --- /dev/null +++ b/apps/studio/static-data/integrations/webhooks/overview.md @@ -0,0 +1,3 @@ +Database Webhooks allow you to send real-time data from your database to another system whenever a table event occurs. + +You can hook into three table events: `INSERT`, `UPDATE`, and `DELETE`. All events are fired after a database row is changed. diff --git a/apps/studio/styles/main.scss b/apps/studio/styles/main.scss index 76b53d65087..ad2f81cd43f 100644 --- a/apps/studio/styles/main.scss +++ b/apps/studio/styles/main.scss @@ -42,7 +42,7 @@ html, body, #__next, .main { - @apply bg-studio; + @apply bg-studio relative; @apply text-foreground; height: 100%; padding: 0; diff --git a/package-lock.json b/package-lock.json index fb802fb1465..059e4815b1c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1792,6 +1792,7 @@ "@hookform/resolvers": "^3.1.1", "@monaco-editor/react": "^4.6.0", "@next/bundle-analyzer": "^14.2.3", + "@number-flow/react": "^0.3.2", "@radix-ui/react-popover": "^1.0.7", "@radix-ui/react-tooltip": "^1.0.7", "@sentry/nextjs": "^8.34.0", @@ -1821,7 +1822,7 @@ "dayjs": "^1.11.10", "dnd-core": "^16.0.1", "file-saver": "^2.0.5", - "framer-motion": "^11.0.3", + "framer-motion": "^11.11.17", "generate-password-browser": "^1.1.0", "html-to-image": "^1.10.8", "icons": "*", @@ -1837,7 +1838,6 @@ "mobx": "^6.10.2", "mobx-react-lite": "^4.0.5", "monaco-editor": "0.33.0", - "motion-number": "^0.1.7", "next": "^14.2.10", "next-themes": "^0.3.0", "nuqs": "^1.17.8", @@ -2938,9 +2938,9 @@ } }, "apps/studio/node_modules/framer-motion": { - "version": "11.3.31", - "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.3.31.tgz", - "integrity": "sha512-Xmxs08WBXnc2tNzNZbFSpquI33lvleJg4Y+hmZ+vFkn+laN9ZnR3gbZnNGKDtuz7c/x3u8dLg05OU3EhLobCsg==", + "version": "11.11.17", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.11.17.tgz", + "integrity": "sha512-O8QzvoKiuzI5HSAHbcYuL6xU+ZLXbrH7C8Akaato4JzQbX2ULNeniqC2Vo5eiCtFktX9XsJ+7nUhxcl2E2IjpA==", "dependencies": { "tslib": "^2.4.0" }, @@ -3003,16 +3003,6 @@ "node": ">= 0.6" } }, - "apps/studio/node_modules/motion-number": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/motion-number/-/motion-number-0.1.7.tgz", - "integrity": "sha512-WOqZSqimX/OTNoFsz6Hk67r1omeUmtC2jH2x6Zy7/gNVnh98AOTrkINygXLR2xoef8k7iVDoKwk4PUcrxI+bog==", - "peerDependencies": { - "framer-motion": "^11.3.19", - "react": "^18.0.0", - "react-dom": "^18.0.0" - } - }, "apps/studio/node_modules/openapi-fetch": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/openapi-fetch/-/openapi-fetch-0.6.2.tgz", @@ -10095,6 +10085,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/@number-flow/react": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/@number-flow/react/-/react-0.3.2.tgz", + "integrity": "sha512-/Rg7WjIZR/yjHJAzRHN7+Cif+s9U02QewMl9WEKPoAY9O6jg0wA/IsAl3lJgeM1ic31bDJ92wfCkwE9ud62VmQ==", + "dependencies": { + "number-flow": "0.3.7" + }, + "peerDependencies": { + "react": "^18 || ^19.0.0-rc-915b914b3a-20240515", + "react-dom": "^18" + } + }, "node_modules/@octokit/auth-app": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/@octokit/auth-app/-/auth-app-6.0.3.tgz", @@ -34363,6 +34365,11 @@ "version": "1.1.1", "license": "MIT" }, + "node_modules/number-flow": { + "version": "0.3.7", + "resolved": "https://registry.npmjs.org/number-flow/-/number-flow-0.3.7.tgz", + "integrity": "sha512-N3pKXV7hw4PhdhZ3Z6QQspRO4djveotVBetxedHB3QrFw9oDluGfdwh7ju7mK9GO9CjTV9XmGjEcaCIwJ3IJMQ==" + }, "node_modules/nuqs": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/nuqs/-/nuqs-1.19.1.tgz",