diff --git a/apps/docs/spec/common-api-sections.json b/apps/docs/spec/common-api-sections.json index 733b6ffb8149b..0e81bfb4e9400 100644 --- a/apps/docs/spec/common-api-sections.json +++ b/apps/docs/spec/common-api-sections.json @@ -196,12 +196,6 @@ "slug": "v1-apply-a-migration", "type": "operation" }, - { - "id": "v1-create-restore-point", - "title": "Create restore point", - "slug": "v1-create-restore-point", - "type": "operation" - }, { "id": "v1-disable-readonly-mode-temporarily", "title": "Disable readonly mode temporarily", @@ -256,12 +250,6 @@ "slug": "v1-get-readonly-mode-status", "type": "operation" }, - { - "id": "v1-get-restore-point", - "title": "Get restore point", - "slug": "v1-get-restore-point", - "type": "operation" - }, { "id": "v1-get-ssl-enforcement-config", "title": "Get ssl enforcement config", @@ -310,12 +298,6 @@ "slug": "v1-setup-a-read-replica", "type": "operation" }, - { - "id": "v1-undo", - "title": "Undo", - "slug": "v1-undo", - "type": "operation" - }, { "id": "v1-update-pooler-config", "title": "Update pooler config", @@ -538,12 +520,6 @@ "slug": "v1-exchange-oauth-token", "type": "operation" }, - { - "id": "v1-oauth-authorize-project-claim", - "title": "Oauth authorize project claim", - "slug": "v1-oauth-authorize-project-claim", - "type": "operation" - }, { "id": "v1-revoke-token", "title": "Revoke token", @@ -556,12 +532,6 @@ "type": "category", "title": "Organizations", "items": [ - { - "id": "v1-claim-project-for-organization", - "title": "Claim project for organization", - "slug": "v1-claim-project-for-organization", - "type": "operation" - }, { "id": "v1-create-an-organization", "title": "Create an organization", @@ -574,12 +544,6 @@ "slug": "v1-get-an-organization", "type": "operation" }, - { - "id": "v1-get-organization-project-claim", - "title": "Get organization project claim", - "slug": "v1-get-organization-project-claim", - "type": "operation" - }, { "id": "v1-list-all-organizations", "title": "List all organizations", @@ -610,12 +574,6 @@ "slug": "v1-create-a-project", "type": "operation" }, - { - "id": "v1-create-project-claim-token", - "title": "Create project claim token", - "slug": "v1-create-project-claim-token", - "type": "operation" - }, { "id": "v1-delete-a-project", "title": "Delete a project", @@ -628,12 +586,6 @@ "slug": "v1-delete-network-bans", "type": "operation" }, - { - "id": "v1-delete-project-claim-token", - "title": "Delete project claim token", - "slug": "v1-delete-project-claim-token", - "type": "operation" - }, { "id": "v1-get-network-restrictions", "title": "Get network restrictions", @@ -658,12 +610,6 @@ "slug": "v1-get-project", "type": "operation" }, - { - "id": "v1-get-project-claim-token", - "title": "Get project claim token", - "slug": "v1-get-project-claim-token", - "type": "operation" - }, { "id": "v1-get-services-health", "title": "Get services health", diff --git a/apps/docs/spec/sections/generateMgmtApiSections.cts b/apps/docs/spec/sections/generateMgmtApiSections.cts index 580acb24866ee..c235b7eccf2ef 100644 --- a/apps/docs/spec/sections/generateMgmtApiSections.cts +++ b/apps/docs/spec/sections/generateMgmtApiSections.cts @@ -41,6 +41,12 @@ function extractSectionsFromOpenApi(filePath, outputPath) { for (const route in openApiJson.paths) { const methods = openApiJson.paths[route] for (const method in methods) { + // We are using `x-internal` to hide endpoints from the docs, + // but still have them included in the spec so they generate types and can be used. + if (methods[method]['x-internal']) { + continue + } + const tag = methods[method].tags?.[0] const operationId = methods[method].operationId // If operationId is not in the form of a slug ignore it. diff --git a/apps/docs/spec/supabase_dart_v2.yml b/apps/docs/spec/supabase_dart_v2.yml index 2a1dd1b2d4e1b..6219bea93b790 100644 --- a/apps/docs/spec/supabase_dart_v2.yml +++ b/apps/docs/spec/supabase_dart_v2.yml @@ -1341,7 +1341,7 @@ functions: isSpotlight: true code: | ```dart - // retrieve all identites linked to a user + // retrieve all identities linked to a user final identities = await supabase.auth.getUserIdentities(); // find the google identity diff --git a/apps/docs/spec/supabase_js_v2.yml b/apps/docs/spec/supabase_js_v2.yml index dcaf090330929..2e6c387f8d8a9 100644 --- a/apps/docs/spec/supabase_js_v2.yml +++ b/apps/docs/spec/supabase_js_v2.yml @@ -1625,7 +1625,7 @@ functions: isSpotlight: true code: | ```js - // retrieve all identites linked to a user + // retrieve all identities linked to a user const identities = await supabase.auth.getUserIdentities() // find the google identity diff --git a/apps/docs/spec/supabase_py_v2.yml b/apps/docs/spec/supabase_py_v2.yml index 661cf1e884e8d..31f21f040112f 100644 --- a/apps/docs/spec/supabase_py_v2.yml +++ b/apps/docs/spec/supabase_py_v2.yml @@ -1550,7 +1550,7 @@ functions: isSpotlight: true code: | ```python - # retrieve all identites linked to a user + # retrieve all identities linked to a user response = supabase.auth.get_user_identities() # find the google identity diff --git a/apps/docs/spec/supabase_swift_v2.yml b/apps/docs/spec/supabase_swift_v2.yml index 37e8587aabd57..9970a629f1209 100644 --- a/apps/docs/spec/supabase_swift_v2.yml +++ b/apps/docs/spec/supabase_swift_v2.yml @@ -944,7 +944,7 @@ functions: isSpotlight: true code: | ```swift - // retrieve all identites linked to a user + // retrieve all identities linked to a user let identities = try await supabase.auth.userIdentities() // find the google identity diff --git a/apps/studio/components/interfaces/Home/Home.tsx b/apps/studio/components/interfaces/Home/Home.tsx new file mode 100644 index 0000000000000..014d8749a84a5 --- /dev/null +++ b/apps/studio/components/interfaces/Home/Home.tsx @@ -0,0 +1,297 @@ +import dayjs from 'dayjs' +import Link from 'next/link' +import { useEffect, useMemo, useRef } from 'react' + +import { useParams } from 'common' +import { ClientLibrary } from 'components/interfaces/Home' +import { AdvisorWidget } from 'components/interfaces/Home/AdvisorWidget' +import { ExampleProject } from 'components/interfaces/Home/ExampleProject' +import { CLIENT_LIBRARIES, EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants' +import { NewProjectPanel } from 'components/interfaces/Home/NewProjectPanel/NewProjectPanel' +import { ProjectUsageSection } from 'components/interfaces/Home/ProjectUsageSection' +import { ServiceStatus } from 'components/interfaces/Home/ServiceStatus' +import { ProjectPausedState } from 'components/layouts/ProjectLayout/PausedState/ProjectPausedState' +import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper' +import { InlineLink } from 'components/ui/InlineLink' +import { ProjectUpgradeFailedBanner } from 'components/ui/ProjectUpgradeFailedBanner' +import { useBranchesQuery } from 'data/branches/branches-query' +import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' +import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' +import { useTablesQuery } from 'data/tables/tables-query' +import { useCustomContent } from 'hooks/custom-content/useCustomContent' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { + useIsOrioleDb, + useProjectByRefQuery, + useSelectedProjectQuery, +} from 'hooks/misc/useSelectedProject' +import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' +import { useAppStateSnapshot } from 'state/app-state' +import { + Badge, + cn, + Tabs_Shadcn_, + TabsContent_Shadcn_, + TabsList_Shadcn_, + TabsTrigger_Shadcn_, + Tooltip, + TooltipContent, + TooltipTrigger, +} from 'ui' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' + +export const Home = () => { + const { data: project } = useSelectedProjectQuery() + const { data: organization } = useSelectedOrganizationQuery() + const { data: parentProject } = useProjectByRefQuery(project?.parent_project_ref) + const isOrioleDb = useIsOrioleDb() + const snap = useAppStateSnapshot() + const { ref, enableBranching } = useParams() + + const { projectHomepageExampleProjects } = useCustomContent(['project_homepage:example_projects']) + + const { + projectHomepageShowAllClientLibraries: showAllClientLibraries, + projectHomepageShowInstanceSize: showInstanceSize, + projectHomepageShowExamples: showExamples, + } = useIsFeatureEnabled([ + 'project_homepage:show_all_client_libraries', + 'project_homepage:show_instance_size', + 'project_homepage:show_examples', + ]) + + const clientLibraries = useMemo(() => { + if (showAllClientLibraries) { + return CLIENT_LIBRARIES + } + return CLIENT_LIBRARIES.filter((library) => library.language === 'JavaScript') + }, [showAllClientLibraries]) + + const hasShownEnableBranchingModalRef = useRef(false) + const isPaused = project?.status === PROJECT_STATUS.INACTIVE + const isNewProject = dayjs(project?.inserted_at).isAfter(dayjs().subtract(2, 'day')) + + useEffect(() => { + if (enableBranching && !hasShownEnableBranchingModalRef.current) { + hasShownEnableBranchingModalRef.current = true + snap.setShowCreateBranchModal(true) + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [enableBranching]) + + const { data: tablesData, isLoading: isLoadingTables } = useTablesQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + schema: 'public', + }) + const { data: functionsData, isLoading: isLoadingFunctions } = useEdgeFunctionsQuery({ + projectRef: project?.ref, + }) + const { data: replicasData, isLoading: isLoadingReplicas } = useReadReplicasQuery({ + projectRef: project?.ref, + }) + + const { data: branches } = useBranchesQuery({ + projectRef: project?.parent_project_ref ?? project?.ref, + }) + + const mainBranch = branches?.find((branch) => branch.is_default) + const currentBranch = branches?.find((branch) => branch.project_ref === project?.ref) + const isMainBranch = currentBranch?.name === mainBranch?.name + let projectName = 'Welcome to your project' + + if (currentBranch && !isMainBranch) { + projectName = currentBranch?.name + } else if (project?.name) { + projectName = project?.name + } + + const tablesCount = Math.max(0, tablesData?.length ?? 0) + const functionsCount = Math.max(0, functionsData?.length ?? 0) + // [Joshen] JFYI minus 1 as the replicas endpoint returns the primary DB minimally + const replicasCount = Math.max(0, (replicasData?.length ?? 1) - 1) + + return ( +
+
+
+
+
+
+ {!isMainBranch && ( + + {parentProject?.name} + + )} +

{projectName}

+
+
+ {isOrioleDb && ( + + + OrioleDB + + + This project is using Postgres with OrioleDB which is currently in preview and + not suitable for production workloads. View our{' '} + + documentation + {' '} + for all limitations. + + + )} + {showInstanceSize && ( + + )} +
+
+
+ {project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && ( +
+
+ + Tables + + + {isLoadingTables ? ( + + ) : ( +

{tablesCount}

+ )} +
+ + {IS_PLATFORM && ( +
+ + Functions + + {isLoadingFunctions ? ( + + ) : ( +

{functionsCount}

+ )} +
+ )} + + {IS_PLATFORM && ( +
+ + Replicas + + {isLoadingReplicas ? ( + + ) : ( +

{replicasCount}

+ )} +
+ )} +
+ )} + {IS_PLATFORM && project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && ( +
+ +
+ )} +
+
+ + {isPaused && } +
+
+ + {!isPaused && ( + <> +
+
+ {IS_PLATFORM && project?.status !== PROJECT_STATUS.INACTIVE && ( + <>{isNewProject ? : } + )} + {!isNewProject && project?.status !== PROJECT_STATUS.INACTIVE && } +
+
+ +
+
+ {project?.status !== PROJECT_STATUS.INACTIVE && ( + <> +
+

Client libraries

+
+ {clientLibraries.map((library) => ( + + ))} +
+
+ {showExamples && ( +
+

Example projects

+ {!!projectHomepageExampleProjects ? ( +
+ {projectHomepageExampleProjects + .sort((a, b) => a.title.localeCompare(b.title)) + .map((project) => ( + + ))} +
+ ) : ( +
+ + + App Frameworks + + Mobile Frameworks + + + +
+ {EXAMPLE_PROJECTS.filter((project) => project.type === 'app') + .sort((a, b) => a.title.localeCompare(b.title)) + .map((project) => ( + + ))} +
+
+ +
+ {EXAMPLE_PROJECTS.filter((project) => project.type === 'mobile') + .sort((a, b) => a.title.localeCompare(b.title)) + .map((project) => ( + + ))} +
+
+
+
+ )} +
+ )} + + )} +
+
+ + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/HomeNew/ActivityStats.tsx b/apps/studio/components/interfaces/HomeNew/ActivityStats.tsx new file mode 100644 index 0000000000000..22464a7a5fbc3 --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/ActivityStats.tsx @@ -0,0 +1,131 @@ +import dayjs from 'dayjs' +import { GitBranch } from 'lucide-react' +import Link from 'next/link' +import { useMemo } from 'react' + +import { useParams } from 'common' +import { useBranchesQuery } from 'data/branches/branches-query' +import { useBackupsQuery } from 'data/database/backups-query' +import { useMigrationsQuery } from 'data/database/migrations-query' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { cn, Skeleton } from 'ui' +import { TimestampInfo } from 'ui-patterns' +import { ServiceStatus } from './ServiceStatus' + +export const ActivityStats = () => { + const { ref } = useParams() + const { data: project } = useSelectedProjectQuery() + + const { data: branchesData, isLoading: isLoadingBranches } = useBranchesQuery({ + projectRef: project?.parent_project_ref ?? project?.ref, + }) + const isDefaultProject = project?.parent_project_ref === undefined + const currentBranch = useMemo( + () => (branchesData ?? []).find((b) => b.project_ref === ref), + [branchesData, ref] + ) + const latestNonDefaultBranch = useMemo(() => { + const list = (branchesData ?? []).filter((b) => !b.is_default) + if (list.length === 0) return undefined + return list + .slice() + .sort( + (a, b) => + new Date(b.created_at ?? b.updated_at).valueOf() - + new Date(a.created_at ?? a.updated_at).valueOf() + )[0] + }, [branchesData]) + + const { data: migrationsData, isLoading: isLoadingMigrations } = useMigrationsQuery({ + projectRef: project?.ref, + connectionString: project?.connectionString, + }) + const latestMigration = useMemo(() => (migrationsData ?? [])[0], [migrationsData]) + + const { data: backupsData, isLoading: isLoadingBackups } = useBackupsQuery({ + projectRef: project?.ref, + }) + const latestBackup = useMemo(() => { + const list = backupsData?.backups ?? [] + if (list.length === 0) return undefined + return list + .slice() + .sort((a, b) => new Date(b.inserted_at).valueOf() - new Date(a.inserted_at).valueOf())[0] + }, [backupsData]) + + return ( +
+
+
+

Status

+ +
+ + +

Last migration

+ +
+ {isLoadingMigrations ? ( + + ) : latestMigration ? ( + + ) : ( +

No migrations

+ )} +
+ + + +

Last backup

+ +
+ {isLoadingBackups ? ( + + ) : backupsData?.pitr_enabled ? ( +

PITR enabled

+ ) : latestBackup ? ( + + ) : ( +

No backups

+ )} +
+ + + +

+ {isDefaultProject ? 'Recent branch' : 'Branch Created'} +

+ +
+ {isLoadingBranches ? ( + + ) : isDefaultProject ? ( +
+ +

+ {latestNonDefaultBranch?.name ?? 'No branches'} +

+
+ ) : currentBranch?.created_at ? ( + + ) : ( +

Unknown

+ )} +
+ +
+
+ ) +} diff --git a/apps/studio/components/interfaces/HomeNew/Home.tsx b/apps/studio/components/interfaces/HomeNew/Home.tsx new file mode 100644 index 0000000000000..5c2390d1c473f --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/Home.tsx @@ -0,0 +1,115 @@ +import { DndContext, DragEndEvent, PointerSensor, useSensor, useSensors } from '@dnd-kit/core' +import { arrayMove, SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable' +import { useEffect, useRef } from 'react' + +import { useParams } from 'common' +import { SortableSection } from 'components/interfaces/HomeNew/SortableSection' +import { TopSection } from 'components/interfaces/HomeNew/TopSection' +import { ScaffoldContainer, ScaffoldSection } from 'components/layouts/Scaffold' +import { useBranchesQuery } from 'data/branches/branches-query' +import { useLocalStorage } from 'hooks/misc/useLocalStorage' +import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' +import { + useIsOrioleDb, + useProjectByRefQuery, + useSelectedProjectQuery, +} from 'hooks/misc/useSelectedProject' +import { PROJECT_STATUS } from 'lib/constants' +import { useAppStateSnapshot } from 'state/app-state' + +export const HomeV2 = () => { + const { ref, enableBranching } = useParams() + const isOrioleDb = useIsOrioleDb() + const snap = useAppStateSnapshot() + const { data: project } = useSelectedProjectQuery() + const { data: organization } = useSelectedOrganizationQuery() + const { data: parentProject } = useProjectByRefQuery(project?.parent_project_ref) + + const hasShownEnableBranchingModalRef = useRef(false) + const isPaused = project?.status === PROJECT_STATUS.INACTIVE + + const { data: branches } = useBranchesQuery({ + projectRef: project?.parent_project_ref ?? project?.ref, + }) + + const mainBranch = branches?.find((branch) => branch.is_default) + const currentBranch = branches?.find((branch) => branch.project_ref === project?.ref) + const isMainBranch = currentBranch?.name === mainBranch?.name + + const projectName = + currentBranch && !isMainBranch + ? currentBranch.name + : project?.name + ? project.name + : 'Welcome to your project' + + const [sectionOrder, setSectionOrder] = useLocalStorage( + `home-section-order-${project?.ref || 'default'}`, + ['getting-started', 'usage', 'advisor', 'custom-report'] + ) + + const [gettingStartedState] = useLocalStorage<'empty' | 'code' | 'no-code' | 'hidden'>( + `home-getting-started-${project?.ref || 'default'}`, + 'empty' + ) + + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 8 } })) + + const handleDragEnd = (event: DragEndEvent) => { + const { active, over } = event + if (!over || active.id === over.id) return + setSectionOrder((items) => { + const oldIndex = items.indexOf(String(active.id)) + const newIndex = items.indexOf(String(over.id)) + if (oldIndex === -1 || newIndex === -1) return items + return arrayMove(items, oldIndex, newIndex) + }) + } + + useEffect(() => { + if (enableBranching && !hasShownEnableBranchingModalRef.current) { + hasShownEnableBranchingModalRef.current = true + snap.setShowCreateBranchModal(true) + } + }, [enableBranching, snap]) + + return ( +
+ + + + + + + {!isPaused && ( + + + + id !== 'getting-started' || gettingStartedState !== 'hidden' + )} + strategy={verticalListSortingStrategy} + > + {sectionOrder.map((id) => ( + + {id} + + ))} + + + + + )} +
+ ) +} diff --git a/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx b/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx new file mode 100644 index 0000000000000..768c0de480a36 --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/ServiceStatus.tsx @@ -0,0 +1,331 @@ +import { AlertTriangle, CheckCircle2, ChevronDown, ChevronRight, Loader2 } from 'lucide-react' +import Link from 'next/link' +import { useState } from 'react' + +import { PopoverSeparator } from '@ui/components/shadcn/ui/popover' +import { useParams } from 'common' +import { useBranchesQuery } from 'data/branches/branches-query' +import { useEdgeFunctionServiceStatusQuery } from 'data/service-status/edge-functions-status-query' +import { + ProjectServiceStatus, + useProjectServiceStatusQuery, +} from 'data/service-status/service-status-query' +import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' +import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' +import { + Button, + InfoIcon, + PopoverContent_Shadcn_, + PopoverTrigger_Shadcn_, + Popover_Shadcn_, + cn, +} from 'ui' + +/** + * [Joshen] JFYI before we go live with this, we need to revisit the migrations section + * as I don't think it should live in the ServiceStatus component since its not indicative + * of a project's "service". ServiceStatus's intention is to be an ongoing health/status check. + * + * For context, migrations are meant to be indicative for only when creating branches or projects + * with an initial SQL, so "healthy" migrations just means that migrations have all been successfully + * ran. So it might be a matter of decoupling "ready" state vs "health checks" + * [Edit] Now that migrations are only showing up if the project is a branch, i think its okay for now + * + * [Joshen] Another issue that requires investigation before we go live with the changes: + * We've removed the isProjectNew check in this component which we had that logic cause new + * projects would show unhealthy as the services are still starting up - but it causes a + * perceived negative impression as new projects were showing unhealthy, hence the 5 minute + * threshold check (we’d show “Coming up” instead of “unhealthy” if the project is within 5 + * minutes of when it was created). Might be related to decoupling "ready" state vs "health checks" + */ + +const StatusMessage = ({ + status, + isLoading, + isHealthy, +}: { + isLoading: boolean + isHealthy: boolean + status?: ProjectServiceStatus +}) => { + if (isHealthy) return 'Healthy' + if (isLoading) return 'Checking status' + if (status === 'UNHEALTHY') return 'Unhealthy' + if (status === 'COMING_UP') return 'Coming up...' + if (status === 'ACTIVE_HEALTHY') return 'Healthy' + if (status) return status + return 'Unable to connect' +} + +const iconProps = { + size: 18, + strokeWidth: 1.5, +} +const LoaderIcon = () => +const AlertIcon = () => +const CheckIcon = () => + +const StatusIcon = ({ + isLoading, + isHealthy, + projectStatus, +}: { + isLoading: boolean + isHealthy: boolean + projectStatus?: ProjectServiceStatus +}) => { + if (isHealthy) return + if (isLoading) return + if (projectStatus === 'UNHEALTHY') return + if (projectStatus === 'COMING_UP') return + if (projectStatus === 'ACTIVE_HEALTHY') return + return +} + +export const ServiceStatus = () => { + const { ref } = useParams() + const { data: project } = useSelectedProjectQuery() + const [open, setOpen] = useState(false) + + const { + projectAuthAll: authEnabled, + projectEdgeFunctionAll: edgeFunctionsEnabled, + realtimeAll: realtimeEnabled, + projectStorageAll: storageEnabled, + } = useIsFeatureEnabled([ + 'project_auth:all', + 'project_edge_function:all', + 'realtime:all', + 'project_storage:all', + ]) + + const isBranch = project?.parentRef !== project?.ref + + // Get branches data when on a branch + const { data: branches, isLoading: isBranchesLoading } = useBranchesQuery( + { projectRef: isBranch ? project?.parentRef : undefined }, + { + enabled: isBranch, + } + ) + + const currentBranch = isBranch + ? branches?.find((branch) => branch.project_ref === ref) + : undefined + + // [Joshen] Need pooler service check eventually + const { data: status, isLoading } = useProjectServiceStatusQuery( + { projectRef: ref }, + { refetchInterval: (data) => (data?.some((service) => !service.healthy) ? 5000 : false) } + ) + const { data: edgeFunctionsStatus } = useEdgeFunctionServiceStatusQuery( + { projectRef: ref }, + { refetchInterval: (data) => (!data?.healthy ? 5000 : false) } + ) + + const authStatus = status?.find((service) => service.name === 'auth') + const restStatus = status?.find((service) => service.name === 'rest') + const realtimeStatus = status?.find((service) => service.name === 'realtime') + const storageStatus = status?.find((service) => service.name === 'storage') + const dbStatus = status?.find((service) => service.name === 'db') + + const isMigrationLoading = + project?.status === 'COMING_UP' || + (isBranch && + (isBranchesLoading || + currentBranch?.status === 'CREATING_PROJECT' || + currentBranch?.status === 'RUNNING_MIGRATIONS')) + + // [Joshen] Need individual troubleshooting docs for each service eventually for users to self serve + const services: { + name: string + error?: string + docsUrl?: string + isLoading: boolean + isHealthy: boolean + status: ProjectServiceStatus + logsUrl: string + }[] = [ + { + name: 'Database', + error: undefined, + docsUrl: undefined, + isLoading: isLoading, + isHealthy: !!dbStatus?.healthy, + status: dbStatus?.status ?? 'UNHEALTHY', + logsUrl: '/logs/postgres-logs', + }, + { + name: 'PostgREST', + error: restStatus?.error, + docsUrl: undefined, + isLoading, + isHealthy: !!restStatus?.healthy, + status: restStatus?.status ?? 'UNHEALTHY', + logsUrl: '/logs/postgrest-logs', + }, + ...(authEnabled + ? [ + { + name: 'Auth', + error: authStatus?.error, + docsUrl: undefined, + isLoading, + isHealthy: !!authStatus?.healthy, + status: authStatus?.status ?? 'UNHEALTHY', + logsUrl: '/logs/auth-logs', + }, + ] + : []), + ...(realtimeEnabled + ? [ + { + name: 'Realtime', + error: realtimeStatus?.error, + docsUrl: undefined, + isLoading, + isHealthy: !!realtimeStatus?.healthy, + status: realtimeStatus?.status ?? 'UNHEALTHY', + logsUrl: '/logs/realtime-logs', + }, + ] + : []), + ...(storageEnabled + ? [ + { + name: 'Storage', + error: storageStatus?.error, + docsUrl: undefined, + isLoading, + isHealthy: !!storageStatus?.healthy, + status: storageStatus?.status ?? 'UNHEALTHY', + logsUrl: '/logs/storage-logs', + }, + ] + : []), + ...(edgeFunctionsEnabled + ? [ + { + name: 'Edge Functions', + error: undefined, + docsUrl: 'https://supabase.com/docs/guides/functions/troubleshooting', + isLoading, + isHealthy: !!edgeFunctionsStatus?.healthy, + status: edgeFunctionsStatus?.healthy + ? 'ACTIVE_HEALTHY' + : isLoading + ? 'COMING_UP' + : ('UNHEALTHY' as ProjectServiceStatus), + logsUrl: '/logs/edge-functions-logs', + }, + ] + : []), + ...(isBranch + ? [ + { + name: 'Migrations', + error: undefined, + docsUrl: undefined, + isLoading: isBranchesLoading, + isHealthy: isBranch + ? currentBranch?.status === 'FUNCTIONS_DEPLOYED' + : !isMigrationLoading, + status: (isBranch + ? currentBranch?.status === 'FUNCTIONS_DEPLOYED' + ? 'ACTIVE_HEALTHY' + : currentBranch?.status === 'FUNCTIONS_FAILED' || + currentBranch?.status === 'MIGRATIONS_FAILED' + ? 'UNHEALTHY' + : 'COMING_UP' + : isMigrationLoading + ? 'COMING_UP' + : 'ACTIVE_HEALTHY') as ProjectServiceStatus, + logsUrl: isBranch ? '/branches' : '/logs/database-logs', + }, + ] + : []), + ] + + const isLoadingChecks = services.some((service) => service.isLoading) + const allServicesOperational = services.every((service) => service.isHealthy) + + const anyUnhealthy = services.some( + (service) => !service.isHealthy && service.status !== 'COMING_UP' + ) + const anyComingUp = services.some((service) => service.status === 'COMING_UP') + const overallStatusLabel = isLoadingChecks + ? 'Checking...' + : anyUnhealthy + ? 'Unhealthy' + : anyComingUp || isMigrationLoading + ? 'Coming up...' + : 'Healthy' + + return ( + + + + + + {services.map((service) => ( + +
+ +
+

{service.name}

+

+ +

+
+
+
+ View logs + +
+ + ))} + {allServicesOperational ? null : ( + <> + +
+
+ +
+ Recently restored projects can take up to 5 minutes to become fully operational. +
+ + )} +
+
+ ) +} diff --git a/apps/studio/components/interfaces/HomeNew/SortableSection.tsx b/apps/studio/components/interfaces/HomeNew/SortableSection.tsx new file mode 100644 index 0000000000000..78704e042da48 --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/SortableSection.tsx @@ -0,0 +1,36 @@ +import { useSortable } from '@dnd-kit/sortable' +import { GripVertical } from 'lucide-react' +import type { CSSProperties, ReactNode } from 'react' +import { cn } from 'ui' + +type SortableSectionProps = { + id: string + children: ReactNode +} + +export const SortableSection = ({ id, children }: SortableSectionProps) => { + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id, + }) + + const style: CSSProperties = { + transform: transform + ? `translate3d(${Math.round(transform.x)}px, ${Math.round(transform.y)}px, 0)` + : undefined, + transition, + } + + return ( +
+ +
{children}
+
+ ) +} diff --git a/apps/studio/components/interfaces/HomeNew/TopSection.tsx b/apps/studio/components/interfaces/HomeNew/TopSection.tsx new file mode 100644 index 0000000000000..56c026709ebb2 --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/TopSection.tsx @@ -0,0 +1,94 @@ +import Link from 'next/link' + +import { ActivityStats } from 'components/interfaces/HomeNew/ActivityStats' +import { ProjectPausedState } from 'components/layouts/ProjectLayout/PausedState/ProjectPausedState' +import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper' +import { InlineLink } from 'components/ui/InlineLink' +import { ProjectUpgradeFailedBanner } from 'components/ui/ProjectUpgradeFailedBanner' +import { ReactFlowProvider } from 'reactflow' +import { Badge, cn, Tooltip, TooltipContent, TooltipTrigger } from 'ui' +import { InstanceConfiguration } from '../Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration' + +interface TopSectionProps { + projectName: string + isMainBranch?: boolean + parentProject?: { ref?: string; name?: string } | null + isOrioleDb?: boolean + project: any + organization: any + projectRef?: string + isPaused: boolean +} + +export const TopSection = ({ + projectName, + isMainBranch, + parentProject, + isOrioleDb, + project, + organization, + isPaused, +}: TopSectionProps) => { + return ( +
+
+
+
+
+ {!isMainBranch && ( + + {parentProject?.name} + + )} +

{projectName}

+
+
+ {isOrioleDb && ( + + + OrioleDB + + + This project is using Postgres with OrioleDB which is currently in preview and + not suitable for production workloads. View our{' '} + + documentation + {' '} + for all limitations. + + + )} + +
+
+
+ +
+
+
+
+ + + +
+
+
+ + {isPaused && } +
+ ) +} diff --git a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx index 54d50a87d7778..c52f590d526f2 100644 --- a/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx +++ b/apps/studio/components/interfaces/Organization/BillingSettings/BillingCustomerData/BillingCustomerData.tsx @@ -61,6 +61,7 @@ export const BillingCustomerData = () => { line1: customerProfile?.address?.line1, line2: customerProfile?.address?.line2 ?? undefined, postal_code: customerProfile?.address?.postal_code ?? undefined, + state: customerProfile?.address?.state ?? undefined, billing_name: customerProfile?.billing_name, tax_id_type: taxId?.type, tax_id_value: taxId?.value, diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx index ea3cee853fcb0..f74301cc8c6ab 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureConfiguration/InstanceConfiguration.tsx @@ -1,12 +1,13 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' -import { useParams } from 'common' import { partition } from 'lodash' import { ChevronDown, Globe2, Loader2, Network } from 'lucide-react' import { useTheme } from 'next-themes' +import Link from 'next/link' import { useEffect, useMemo, useRef, useState } from 'react' import ReactFlow, { Background, Edge, ReactFlowProvider, useReactFlow } from 'reactflow' import 'reactflow/dist/style.css' +import { useParams } from 'common' import AlertError from 'components/ui/AlertError' import { ButtonTooltip } from 'components/ui/ButtonTooltip' import { useLoadBalancersQuery } from 'data/read-replicas/load-balancers-query' @@ -18,7 +19,6 @@ import { import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useIsOrioleDb, useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { timeout } from 'lib/helpers' -import Link from 'next/link' import { type AWS_REGIONS_KEYS } from 'shared-data' import { Button, @@ -40,7 +40,11 @@ import MapView from './MapView' import { RestartReplicaConfirmationModal } from './RestartReplicaConfirmationModal' import { useShowNewReplicaPanel } from './use-show-new-replica' -const InstanceConfigurationUI = () => { +interface InstanceConfigurationUIProps { + diagramOnly?: boolean +} + +const InstanceConfigurationUI = ({ diagramOnly = false }: InstanceConfigurationUIProps) => { const reactFlow = useReactFlow() const isOrioleDb = useIsOrioleDb() const { resolvedTheme } = useTheme() @@ -210,9 +214,9 @@ const InstanceConfigurationUI = () => { }, [isSuccessReplicas, isSuccessLoadBalancers, nodes, edges, view]) return ( -
+
@@ -224,70 +228,72 @@ const InstanceConfigurationUI = () => { {isError && } {isSuccessReplicas && !isLoadingProject && ( <> -
-
- 0 ? 'rounded-r-none' : '')} - onClick={() => setShowNewReplicaPanel(true)} - tooltip={{ - content: { - side: 'bottom', - text: !canManageReplicas - ? 'You need additional permissions to deploy replicas' - : isOrioleDb - ? 'Read replicas are not supported with OrioleDB' - : undefined, - }, - }} - > - Deploy a new replica - - {replicas.length > 0 && ( - - -
- {project?.cloud_provider === 'AWS' && ( + {!diagramOnly && ( +
-
- )} -
+ {project?.cloud_provider === 'AWS' && ( +
+
+ )} +
+ )} {view === 'flow' ? ( { )}
- setRefetchInterval(5000)} - onClose={() => { - setNewReplicaRegion(undefined) - setShowNewReplicaPanel(false) - }} - /> + {!diagramOnly && ( + <> + setRefetchInterval(5000)} + onClose={() => { + setNewReplicaRegion(undefined) + setShowNewReplicaPanel(false) + }} + /> - setRefetchInterval(5000)} - onCancel={() => setSelectedReplicaToDrop(undefined)} - /> + setRefetchInterval(5000)} + onCancel={() => setSelectedReplicaToDrop(undefined)} + /> - setRefetchInterval(5000)} - onCancel={() => setShowDeleteAllModal(false)} - /> + setRefetchInterval(5000)} + onCancel={() => setShowDeleteAllModal(false)} + /> - setRefetchInterval(5000)} - onCancel={() => setSelectedReplicaToRestart(undefined)} - /> + setRefetchInterval(5000)} + onCancel={() => setSelectedReplicaToRestart(undefined)} + /> + + )}
) } -const InstanceConfiguration = () => { +interface InstanceConfigurationProps { + diagramOnly?: boolean +} + +export const InstanceConfiguration = ({ diagramOnly = false }: InstanceConfigurationProps) => { return ( - + ) } - -export default InstanceConfiguration diff --git a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx index c2e375ade9bf2..88c9a63eba5dc 100644 --- a/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx +++ b/apps/studio/components/interfaces/Settings/Infrastructure/InfrastructureInfo.tsx @@ -24,7 +24,7 @@ import { TooltipTrigger, } from 'ui' import { ProjectUpgradeAlert } from '../General/Infrastructure/ProjectUpgradeAlert' -import InstanceConfiguration from './InfrastructureConfiguration/InstanceConfiguration' +import { InstanceConfiguration } from './InfrastructureConfiguration/InstanceConfiguration' import { ObjectsToBeDroppedWarning, ReadReplicasWarning, diff --git a/apps/studio/pages/project/[ref]/index.tsx b/apps/studio/pages/project/[ref]/index.tsx index 567ecaa180002..b786f450fe37b 100644 --- a/apps/studio/pages/project/[ref]/index.tsx +++ b/apps/studio/pages/project/[ref]/index.tsx @@ -1,308 +1,22 @@ -import dayjs from 'dayjs' -import Link from 'next/link' -import { useEffect, useMemo, useRef } from 'react' - -import { useParams } from 'common' -import { ClientLibrary } from 'components/interfaces/Home' -import { AdvisorWidget } from 'components/interfaces/Home/AdvisorWidget' -import { ExampleProject } from 'components/interfaces/Home/ExampleProject' -import { CLIENT_LIBRARIES, EXAMPLE_PROJECTS } from 'components/interfaces/Home/Home.constants' -import { NewProjectPanel } from 'components/interfaces/Home/NewProjectPanel/NewProjectPanel' -import { ProjectUsageSection } from 'components/interfaces/Home/ProjectUsageSection' -import { ServiceStatus } from 'components/interfaces/Home/ServiceStatus' +import { useFlag } from 'common' +import { Home } from 'components/interfaces/Home/Home' +import { HomeV2 } from 'components/interfaces/HomeNew/Home' import DefaultLayout from 'components/layouts/DefaultLayout' -import { ProjectPausedState } from 'components/layouts/ProjectLayout/PausedState/ProjectPausedState' import { ProjectLayoutWithAuth } from 'components/layouts/ProjectLayout/ProjectLayout' -import { ComputeBadgeWrapper } from 'components/ui/ComputeBadgeWrapper' -import { InlineLink } from 'components/ui/InlineLink' -import { ProjectUpgradeFailedBanner } from 'components/ui/ProjectUpgradeFailedBanner' -import { useBranchesQuery } from 'data/branches/branches-query' -import { useEdgeFunctionsQuery } from 'data/edge-functions/edge-functions-query' -import { useReadReplicasQuery } from 'data/read-replicas/replicas-query' -import { useTablesQuery } from 'data/tables/tables-query' -import { useCustomContent } from 'hooks/custom-content/useCustomContent' -import { useIsFeatureEnabled } from 'hooks/misc/useIsFeatureEnabled' -import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' -import { - useIsOrioleDb, - useProjectByRefQuery, - useSelectedProjectQuery, -} from 'hooks/misc/useSelectedProject' -import { IS_PLATFORM, PROJECT_STATUS } from 'lib/constants' -import { useAppStateSnapshot } from 'state/app-state' import type { NextPageWithLayout } from 'types' -import { - Badge, - cn, - Tabs_Shadcn_, - TabsContent_Shadcn_, - TabsList_Shadcn_, - TabsTrigger_Shadcn_, - Tooltip, - TooltipContent, - TooltipTrigger, -} from 'ui' -import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' - -const Home: NextPageWithLayout = () => { - const { data: project } = useSelectedProjectQuery() - const { data: organization } = useSelectedOrganizationQuery() - const { data: parentProject } = useProjectByRefQuery(project?.parent_project_ref) - const isOrioleDb = useIsOrioleDb() - const snap = useAppStateSnapshot() - const { ref, enableBranching } = useParams() - - const { projectHomepageExampleProjects } = useCustomContent(['project_homepage:example_projects']) - - const { - projectHomepageShowAllClientLibraries: showAllClientLibraries, - projectHomepageShowInstanceSize: showInstanceSize, - projectHomepageShowExamples: showExamples, - } = useIsFeatureEnabled([ - 'project_homepage:show_all_client_libraries', - 'project_homepage:show_instance_size', - 'project_homepage:show_examples', - ]) - - const clientLibraries = useMemo(() => { - if (showAllClientLibraries) { - return CLIENT_LIBRARIES - } - return CLIENT_LIBRARIES.filter((library) => library.language === 'JavaScript') - }, [showAllClientLibraries]) - - const hasShownEnableBranchingModalRef = useRef(false) - const isPaused = project?.status === PROJECT_STATUS.INACTIVE - const isNewProject = dayjs(project?.inserted_at).isAfter(dayjs().subtract(2, 'day')) - - useEffect(() => { - if (enableBranching && !hasShownEnableBranchingModalRef.current) { - hasShownEnableBranchingModalRef.current = true - snap.setShowCreateBranchModal(true) - } - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [enableBranching]) - - const { data: tablesData, isLoading: isLoadingTables } = useTablesQuery({ - projectRef: project?.ref, - connectionString: project?.connectionString, - schema: 'public', - }) - const { data: functionsData, isLoading: isLoadingFunctions } = useEdgeFunctionsQuery({ - projectRef: project?.ref, - }) - const { data: replicasData, isLoading: isLoadingReplicas } = useReadReplicasQuery({ - projectRef: project?.ref, - }) - - const { data: branches } = useBranchesQuery({ - projectRef: project?.parent_project_ref ?? project?.ref, - }) - const mainBranch = branches?.find((branch) => branch.is_default) - const currentBranch = branches?.find((branch) => branch.project_ref === project?.ref) - const isMainBranch = currentBranch?.name === mainBranch?.name - let projectName = 'Welcome to your project' - - if (currentBranch && !isMainBranch) { - projectName = currentBranch?.name - } else if (project?.name) { - projectName = project?.name +const HomePage: NextPageWithLayout = () => { + const isHomeNew = useFlag('homeNew') + if (isHomeNew) { + return } - - const tablesCount = Math.max(0, tablesData?.length ?? 0) - const functionsCount = Math.max(0, functionsData?.length ?? 0) - // [Joshen] JFYI minus 1 as the replicas endpoint returns the primary DB minimally - const replicasCount = Math.max(0, (replicasData?.length ?? 1) - 1) - - return ( -
-
-
-
-
-
- {!isMainBranch && ( - - {parentProject?.name} - - )} -

{projectName}

-
-
- {isOrioleDb && ( - - - OrioleDB - - - This project is using Postgres with OrioleDB which is currently in preview and - not suitable for production workloads. View our{' '} - - documentation - {' '} - for all limitations. - - - )} - {showInstanceSize && ( - - )} -
-
-
- {project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && ( -
-
- - Tables - - - {isLoadingTables ? ( - - ) : ( -

{tablesCount}

- )} -
- - {IS_PLATFORM && ( -
- - Functions - - {isLoadingFunctions ? ( - - ) : ( -

{functionsCount}

- )} -
- )} - - {IS_PLATFORM && ( -
- - Replicas - - {isLoadingReplicas ? ( - - ) : ( -

{replicasCount}

- )} -
- )} -
- )} - {IS_PLATFORM && project?.status === PROJECT_STATUS.ACTIVE_HEALTHY && ( -
- -
- )} -
-
- - {isPaused && } -
-
- - {!isPaused && ( - <> -
-
- {IS_PLATFORM && project?.status !== PROJECT_STATUS.INACTIVE && ( - <>{isNewProject ? : } - )} - {!isNewProject && project?.status !== PROJECT_STATUS.INACTIVE && } -
-
- -
-
- {project?.status !== PROJECT_STATUS.INACTIVE && ( - <> -
-

Client libraries

-
- {clientLibraries.map((library) => ( - - ))} -
-
- {showExamples && ( -
-

Example projects

- {!!projectHomepageExampleProjects ? ( -
- {projectHomepageExampleProjects - .sort((a, b) => a.title.localeCompare(b.title)) - .map((project) => ( - - ))} -
- ) : ( -
- - - App Frameworks - - Mobile Frameworks - - - -
- {EXAMPLE_PROJECTS.filter((project) => project.type === 'app') - .sort((a, b) => a.title.localeCompare(b.title)) - .map((project) => ( - - ))} -
-
- -
- {EXAMPLE_PROJECTS.filter((project) => project.type === 'mobile') - .sort((a, b) => a.title.localeCompare(b.title)) - .map((project) => ( - - ))} -
-
-
-
- )} -
- )} - - )} -
-
- - )} -
- ) + return } -Home.getLayout = (page) => ( +HomePage.getLayout = (page) => ( {page} ) -export default Home +export default HomePage diff --git a/apps/www/components/Sections/TwitterSocialProof.tsx b/apps/www/components/Sections/TwitterSocialProof.tsx index ec9d6a33eec3c..1c436bdc959fb 100644 --- a/apps/www/components/Sections/TwitterSocialProof.tsx +++ b/apps/www/components/Sections/TwitterSocialProof.tsx @@ -4,7 +4,7 @@ import { cn } from 'ui' import { TweetCard } from 'ui-patterns/TweetCard' import { range } from 'lib/helpers' -import Tweets from '~/data/tweets/Tweets.json' +import tweets from 'shared-data/tweets' import { useBreakpoint } from 'common' import React from 'react' @@ -16,7 +16,7 @@ const TwitterSocialProof: React.FC = ({ className }) => { const { basePath } = useRouter() const isSm = useBreakpoint() const isMd = useBreakpoint(1024) - const tweets = Tweets.slice(0, 18) + const tweetsData = tweets.slice(0, 18) return ( <> @@ -39,7 +39,7 @@ const TwitterSocialProof: React.FC = ({ className }) => { 'will-change-transform transition-transform' )} > - {tweets.slice(0, isSm ? 9 : isMd ? 12 : 18).map((tweet: any, i: number) => ( + {tweetsData.slice(0, isSm ? 9 : isMd ? 12 : 18).map((tweet: any, i: number) => ( + {progressBars.map((bar, index) => ( +
+ {/* Background bar (static) */} +
+ + {/* Animated foreground bar */} +
+
+ ))} +
+ ) +} diff --git a/apps/www/components/SurveyResults/SurveyChapter.tsx b/apps/www/components/SurveyResults/SurveyChapter.tsx new file mode 100644 index 0000000000000..dfee436750215 --- /dev/null +++ b/apps/www/components/SurveyResults/SurveyChapter.tsx @@ -0,0 +1,71 @@ +import { SurveyPullQuote } from './SurveyPullQuote' +import './surveyResults.css' +import { DecorativeProgressBar } from './DecorativeProgressBar' + +interface SurveyChapterProps { + number: number + title: string + shortTitle: string + description: string + pullQuote?: { + quote: string + author: string + authorPosition: string + authorAvatar: string + } + children: React.ReactNode +} + +export function SurveyChapter({ + number, + title, + shortTitle, + description, + pullQuote, + children, +}: SurveyChapterProps) { + return ( +
+
+ {/* Chapter header */} +
+ {/* Decorative progress bar */} + + {/* Text content */} +
+
+

+ {shortTitle} +

+

+ {title} +

+
+

+ {description} +

+
+
+ {/* Chapter content */} +
{children}
+
+ + {pullQuote && ( + + )} +
+ ) +} diff --git a/apps/www/components/SurveyResults/SurveyChapterSection.tsx b/apps/www/components/SurveyResults/SurveyChapterSection.tsx new file mode 100644 index 0000000000000..f834a8323d62b --- /dev/null +++ b/apps/www/components/SurveyResults/SurveyChapterSection.tsx @@ -0,0 +1,108 @@ +import { SurveyStatCard } from './SurveyStatCard' +import { SurveyWordCloud } from './SurveyWordCloud' +import { SurveySummarizedAnswer } from './SurveySummarizedAnswer' +import { SurveyRankedAnswersPair } from './SurveyRankedAnswersPair' +import { SurveySectionBreak } from './SurveySectionBreak' +import { AcceleratorParticipationChart } from './charts/AcceleratorParticipationChart' +import { RoleChart } from './charts/RoleChart' +import { IndustryChart } from './charts/IndustryChart' +import { FundingStageChart } from './charts/FundingStageChart' +import { DatabasesChart } from './charts/DatabasesChart' +import { AIModelsChart } from './charts/AIModelsChart' +import { LocationChart } from './charts/LocationChart' +import { SalesToolsChart } from './charts/SalesToolsChart' +import { AICodingToolsChart } from './charts/AICodingToolsChart' +import { RegularSocialMediaUseChart } from './charts/RegularSocialMediaUseChart' +import { NewIdeasChart } from './charts/NewIdeasChart' +import { InitialPayingCustomersChart } from './charts/InitialPayingCustomersChart' +import { WorldOutlookChart } from './charts/WorldOutlookChart' +import { BiggestChallengeChart } from './charts/BiggestChallengeChart' + +interface SurveyChapterSectionProps { + title: string + description: string + stats?: Array<{ percent: number; label: string }> + charts?: string[] + + wordCloud?: { + label: string + words: { text: string; count: number }[] + } + summarizedAnswer?: { + label: string + answers: string[] + } + rankedAnswersPair?: Array<{ label: string; answers: string[] }> + children?: React.ReactNode +} + +export function SurveyChapterSection({ + title, + description, + stats, + charts, + wordCloud, + summarizedAnswer, + rankedAnswersPair, + children, +}: SurveyChapterSectionProps) { + const chartComponents = { + RoleChart, + IndustryChart, + FundingStageChart, + AcceleratorParticipationChart, + DatabasesChart, + AICodingToolsChart, + AIModelsChart, + RegularSocialMediaUseChart, + NewIdeasChart, + InitialPayingCustomersChart, + SalesToolsChart, + WorldOutlookChart, + BiggestChallengeChart, + LocationChart, + } + + return ( +
+
+
+

+ {title} +

+

+ {description} +

+
+ + {stats && ( + + )} + + {charts?.map((chartName, index) => { + const ChartComponent = chartComponents[chartName as keyof typeof chartComponents] + return ChartComponent ? : null + })} + + {rankedAnswersPair && } + + {wordCloud && } + + {summarizedAnswer && ( + + )} + + {children} +
+ + +
+ ) +} diff --git a/apps/www/components/SurveyResults/SurveyChart.tsx b/apps/www/components/SurveyResults/SurveyChart.tsx new file mode 100644 index 0000000000000..070a269f98b23 --- /dev/null +++ b/apps/www/components/SurveyResults/SurveyChart.tsx @@ -0,0 +1,566 @@ +'use client' +import { useEffect, useState, useRef, useCallback } from 'react' +import { createClient } from '@supabase/supabase-js' +import { motion } from 'framer-motion' +import { + Button, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from 'ui' +import { ChevronsUpDown } from 'lucide-react' +import TwoOptionToggle from '../../../studio/components/ui/TwoOptionToggle' +import CodeBlock from '~/components/CodeBlock/CodeBlock' + +// Separate Supabase client for survey project +const externalSupabase = createClient( + process.env.NEXT_PUBLIC_SURVEY_SUPABASE_URL!, + process.env.NEXT_PUBLIC_SURVEY_SUPABASE_ANON_KEY! +) + +// Sentinel for “no filter” +const NO_FILTER = 'unset' + +interface FilterOption { + value: string + label: string +} + +interface FilterConfig { + label: string + options: FilterOption[] +} + +interface Filters { + [key: string]: FilterConfig +} + +interface ChartDataItem { + label: string + value: number + rawValue: number +} + +interface FilterColumnConfig { + label: string + options: string[] +} + +// Sometimes the label doesn't match the value, so we need to map them +// Also, our options aren't always in a predicatable order, so we need to map them too +const FILTER_COLUMN_CONFIGS: Record = { + team_size: { + label: 'Team Size', + options: ['1–10', '11–50', '51–100', '101–250', '250+'], + }, + money_raised: { + label: 'Money Raised', + options: ['USD $0–10M', 'USD $11–50M', 'USD $51–100M', 'USD $100M+'], + }, + person_age: { + label: 'Age', + options: ['18–21', '22–29', '30–39', '40–49', '50–59', '60+'], + }, + location: { + label: 'Location', + options: [ + 'Africa', + 'Asia', + 'Europe', + 'Middle East', + 'North America', + 'South America', + 'Remote', + ], + }, +} + +function useFilterOptions(filterColumns: string[]) { + // Build filters synchronously since everything is predefined + const filters: Filters = {} + + for (const column of filterColumns) { + const config = FILTER_COLUMN_CONFIGS[column] + + if (!config) { + console.warn(`No configuration found for filter column: ${column}`) + continue + } + + filters[column] = { + label: config.label, + options: config.options.map((option) => ({ value: option, label: option })), + } + } + + return { filters } +} + +// Fetch survey data using secure database functions +function useSurveyData( + shouldFetch: boolean, + functionName: string, + functionParams: (activeFilters: Record) => Record, + activeFilters: Record +) { + const [chartData, setChartData] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + useEffect(() => { + if (!shouldFetch) return + + async function fetchData() { + try { + setIsLoading(true) + setError(null) + + let data, fetchError + + const functionParamsData = functionParams(activeFilters) + const { data: functionData, error: functionError } = await externalSupabase.rpc( + functionName, + functionParamsData + ) + data = functionData + fetchError = functionError + + if (fetchError) { + console.error('Error executing SQL query:', fetchError) + setError(fetchError.message) + return + } + + // Calculate total for percentage calculation + const total = data.reduce( + (sum: number, row: any) => sum + parseInt(row.count || row.total), + 0 + ) + + // Transform the data to match chart format + const processedData = data.map((row: any) => { + const count = parseInt(row.count || row.total) + const rawPercentage = total > 0 ? (count / total) * 100 : 0 + const roundedPercentage = Math.round(rawPercentage) + + return { + label: row.label || row.value || row[Object.keys(row)[0]], // Get the first column as label + value: roundedPercentage, + rawValue: rawPercentage, // Keep the raw value for bar scaling + } + }) + + setChartData(processedData) + } catch (err: any) { + console.error('Error in fetchData:', err) + setError(err.message) + } finally { + setIsLoading(false) + } + } + + fetchData() + }, [shouldFetch, functionName, functionParams, activeFilters]) + + return { chartData, isLoading, error } +} + +interface SurveyChartProps { + title: string + targetColumn: string + filterColumns: string[] + generateSQLQuery?: (activeFilters: Record) => string + functionName: string +} + +export function SurveyChart({ + title, + targetColumn, // Used for SQL query generation when generateSQLQuery is provided + filterColumns, + generateSQLQuery, + functionName, +}: SurveyChartProps) { + const [isInView, setIsInView] = useState(false) + const chartRef = useRef(null) + const [shouldAnimateBars, setShouldAnimateBars] = useState(false) + const [hasLoadedOnce, setHasLoadedOnce] = useState(false) + + // Intersection observer to trigger chart data loading via database function + useEffect(() => { + const chartRefCurrent = chartRef.current + + const observer = new IntersectionObserver( + (entries) => { + entries.forEach((entry) => { + if (entry.isIntersecting && !hasLoadedOnce) { + setIsInView(true) + setHasLoadedOnce(true) + observer.disconnect() // Only trigger once + } + }) + }, + { + threshold: 0.1, + rootMargin: '128px', // Start loading before the component comes into view + } + ) + + if (chartRefCurrent) { + observer.observe(chartRefCurrent) + } + + return () => { + if (chartRefCurrent) { + observer.unobserve(chartRefCurrent) + } + } + }, [hasLoadedOnce]) + + // Each chart uses a subset of available filters, defined below + const { filters } = useFilterOptions(filterColumns) + + // Start with all filters unset (showing "all") + const [activeFilters, setActiveFilters] = useState( + filterColumns.reduce( + (acc: Record, col: string) => ({ ...acc, [col]: NO_FILTER }), + {} + ) + ) + + // Build function parameters based on filterColumns + const buildFunctionParams = useCallback((activeFilters: Record) => { + const params: Record = {} + + // Convert single values to arrays for the function parameters + if (activeFilters.person_age && activeFilters.person_age !== 'unset') { + params.person_age_filter = [activeFilters.person_age] + } + if (activeFilters.location && activeFilters.location !== 'unset') { + params.location_filter = [activeFilters.location] + } + if (activeFilters.money_raised && activeFilters.money_raised !== 'unset') { + params.money_raised_filter = [activeFilters.money_raised] + } + if (activeFilters.team_size && activeFilters.team_size !== 'unset') { + params.team_size_filter = [activeFilters.team_size] + } + + return params + }, []) + + // Use the custom hook to fetch data + const { + chartData, + isLoading: dataLoading, + error: dataError, + } = useSurveyData(isInView, functionName, buildFunctionParams, activeFilters) + + // Reset animation state when filters change + useEffect(() => { + setShouldAnimateBars(false) + }, [activeFilters]) + + // Trigger bar animation when data loads + useEffect(() => { + if (!dataLoading && chartData.length > 0 && !shouldAnimateBars) { + // Small delay to ensure DOM is ready + const timer = setTimeout(() => { + setShouldAnimateBars(true) + }, 100) + + return () => clearTimeout(timer) + } + }, [dataLoading, chartData.length, shouldAnimateBars]) + + const [view, setView] = useState<'chart' | 'sql'>('chart') + + // Handle both view change and expansion via a wrapper function + const handleViewChange = (newView: 'chart' | 'sql') => { + setView(newView) + setIsExpanded(true) + } + + const setFilterValue = (filterKey: string, value: string) => { + setActiveFilters((prev: Record) => ({ + ...prev, + [filterKey]: value, + })) + } + + // Find the maximum value for scaling the bars + const maxValue = chartData.length > 0 ? Math.max(...chartData.map((item) => item.value)) : 0 + + // State for expand/collapse button + const [isExpanded, setIsExpanded] = useState(false) + + // Fixed height for all states (loading, error, loaded collapsed) + const FIXED_HEIGHT = 300 // px + const BUTTON_AREA_HEIGHT = 40 // px + const CHART_HEIGHT = FIXED_HEIGHT - BUTTON_AREA_HEIGHT // px + + const skeletonData = [ + { label: 'Loading', value: 0, rawValue: 0 }, + { label: 'Loading', value: 0, rawValue: 0 }, + { label: 'Loading', value: 0, rawValue: 0 }, + ] + + const displayData = dataLoading ? skeletonData : chartData || [] + + return ( +
+
+

Q&A

+

{title}

+
+ +
+ {/* Filters and toggle */} +
+ {filters && activeFilters && setFilterValue && ( +
+ {Object.entries(filters).map(([filterKey, filterConfig]) => ( + + ))} +
+ )} +
+ +
+
+ + {dataError ? ( +
+

Error: {dataError}

+
+ ) : view === 'chart' ? ( +
+ {chartData.length > 0 ? ( +
+ {displayData.map((item, index) => ( +
+ {/* Text above the bar */} +
+ {item.label} + {item.value < 1 ? '<1%' : `${item.value}%`} +
+ + {/* Progress bar */} +
+ {/* Background pattern for the entire bar */} +
+ + {/* Filled portion of the bar */} +
+ {/* Foreground pattern for the filled portion */} +
+
+
+
+ ))} +
+ ) : ( +
+

+ No responses match those filters. Maybe next year? +

+ +
+ )} +
+ ) : ( +
+ {generateSQLQuery ? ( + {generateSQLQuery(activeFilters)} + ) : ( + + {`// Function call: ${functionName}(${JSON.stringify(buildFunctionParams(activeFilters), null, 2)})`} + + )} +
+ )} + + {/* Expand button overlay - only show for chart view */} + {view === 'chart' && + !isExpanded && + !dataLoading && + !dataError && + chartData.length > 3 && ( +
+ +
+ )} + +
+
+ ) +} + +// Helper to build SQL WHERE clauses from active filters +// Accepts optional initialClauses for charts that need extra constraints +// (e.g., "column IS NOT NULL") +// Note: This function is used by chart components that generate SQL queries +export function buildWhereClause( + activeFilters: Record, + initialClauses: string[] = [] +) { + const whereClauses: string[] = [...initialClauses] + + for (const [column, value] of Object.entries(activeFilters)) { + if (value && value !== NO_FILTER) { + whereClauses.push(`${column} = '${value}'`) + } + } + + return whereClauses.length > 0 ? `WHERE ${whereClauses.join('\n AND ')}` : '' +} + +// Dropdown filter component +function SurveyFilter({ + filterKey, + filterConfig, + selectedValue, + setFilterValue, +}: { + filterKey: string + filterConfig: { + label: string + options: { value: string; label: string }[] + } + selectedValue: string + setFilterValue: (filterKey: string, value: string) => void +}) { + return ( + + + + + + {filterConfig.options + .filter((opt) => opt.value !== NO_FILTER) + .map((option) => ( + setFilterValue(filterKey, option.value)} + className={selectedValue === option.value ? 'text-brand-600' : ''} + > + {option.label} + + ))} + + {selectedValue !== NO_FILTER && ( +
+ setFilterValue(filterKey, NO_FILTER)} + className="text-foreground-lighter" + > + Clear + +
+ )} +
+
+ ) +} diff --git a/apps/www/components/SurveyResults/SurveyPullQuote.tsx b/apps/www/components/SurveyResults/SurveyPullQuote.tsx new file mode 100644 index 0000000000000..de3bea6c1b62b --- /dev/null +++ b/apps/www/components/SurveyResults/SurveyPullQuote.tsx @@ -0,0 +1,48 @@ +import Image from 'next/image' +import { SurveySectionBreak } from './SurveySectionBreak' + +export function SurveyPullQuote({ + quote, + author, + authorPosition, + authorAvatar, +}: { + quote: string + author: string + authorPosition: string + authorAvatar: string +}) { + return ( + <> + + + + ) +} diff --git a/apps/www/components/SurveyResults/SurveyRankedAnswersPair.tsx b/apps/www/components/SurveyResults/SurveyRankedAnswersPair.tsx new file mode 100644 index 0000000000000..70ab8054ebe4a --- /dev/null +++ b/apps/www/components/SurveyResults/SurveyRankedAnswersPair.tsx @@ -0,0 +1,56 @@ +export function SurveyRankedAnswersPair({ + rankedAnswersPair, +}: { + rankedAnswersPair: Array<{ label: string; answers: string[] }> +}) { + return ( + + ) +} diff --git a/apps/www/components/SurveyResults/SurveySectionBreak.tsx b/apps/www/components/SurveyResults/SurveySectionBreak.tsx new file mode 100644 index 0000000000000..12612a9f0d8b0 --- /dev/null +++ b/apps/www/components/SurveyResults/SurveySectionBreak.tsx @@ -0,0 +1,19 @@ +import { cn } from 'ui' + +export function SurveySectionBreak({ className }: { className?: string }) { + return ( +