From 3791e39471b98d9b6eacaa188682379828125378 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Tue, 9 Sep 2025 18:14:56 -0400 Subject: [PATCH 1/6] fix: add 'use client' to fix docs build (#38568) --- packages/ui-patterns/src/FilterBar/hooks.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ui-patterns/src/FilterBar/hooks.ts b/packages/ui-patterns/src/FilterBar/hooks.ts index 0a70881ae28ef..290cbc189f22d 100644 --- a/packages/ui-patterns/src/FilterBar/hooks.ts +++ b/packages/ui-patterns/src/FilterBar/hooks.ts @@ -1,3 +1,5 @@ +'use client' + import { useState, useRef, useCallback, useEffect } from 'react' import { FilterProperty, FilterOptionObject, AsyncOptionsFunction } from './types' import { isAsyncOptionsFunction } from './utils' From 9eb7b64ec4ce2977fd24c5f6dbd726428499df9c Mon Sep 17 00:00:00 2001 From: Taishi Date: Tue, 9 Sep 2025 18:30:23 -0400 Subject: [PATCH 2/6] Show an error message with a row number when a user uploads CSV file with an issue (#38422) * change the location of importing SpreadsheetImportPreview component * Refactor error handling in SpreadsheetImport components - show the row number with an issue to give users more actionable message - show only the first error since Papaparse has an issue for row value in errors array * Refactor error message for spreadsheet import alerts * Remove items from `results.errors` with duplicate row and code values * add comment * Update error badge to show dynamic issue count * Make errors stand out * Refactor import/export for SpreadsheetImportPreview component * Refactor import statements for SpreadsheetImport component * use size-[value] class * Refactor error handling to remove duplicates in preview * Make only items with details clickable * use proper list element * use `button` element for clickable items * add transition for transform --------- Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> --- .../SidePanelEditor/SidePanelEditor.tsx | 2 +- .../SpreadsheetImport/SpreadsheetImport.tsx | 17 +- .../SpreadsheetImport.utils.tsx | 2 +- .../SpreadsheetImportPreview.tsx | 203 +++++++++++------- .../TableEditor/TableEditor.tsx | 2 +- 5 files changed, 134 insertions(+), 92 deletions(-) diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx index c9ca072bd52cb..594198ea3d08b 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SidePanelEditor.tsx @@ -47,7 +47,7 @@ import { updateColumn, updateTable, } from './SidePanelEditor.utils' -import SpreadsheetImport from './SpreadsheetImport/SpreadsheetImport' +import { SpreadsheetImport } from './SpreadsheetImport/SpreadsheetImport' import { TableEditor } from './TableEditor/TableEditor' import type { ImportContent } from './TableEditor/TableEditor.types' diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.tsx index 9eee2b101057a..fcee168621025 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.tsx @@ -11,6 +11,7 @@ import { SidePanel, Tabs } from 'ui' import ActionBar from '../ActionBar' import type { ImportContent } from '../TableEditor/TableEditor.types' import SpreadSheetFileUpload from './SpreadSheetFileUpload' +import { SpreadsheetImportPreview } from './SpreadsheetImportPreview' import SpreadsheetImportConfiguration from './SpreadSheetImportConfiguration' import SpreadSheetTextInput from './SpreadSheetTextInput' import { EMPTY_SPREADSHEET_DATA } from './SpreadsheetImport.constants' @@ -20,7 +21,6 @@ import { parseSpreadsheet, parseSpreadsheetText, } from './SpreadsheetImport.utils' -import SpreadsheetImportPreview from './SpreadsheetImportPreview' import { useChanged } from 'hooks/misc/useChanged' interface SpreadsheetImportProps { @@ -34,7 +34,10 @@ interface SpreadsheetImportProps { updateEditorDirty?: (value: boolean) => void } -const SpreadsheetImport = ({ +const csvParseErrorMessage = + 'Some issues have been detected. More details below the content preview.' + +export const SpreadsheetImport = ({ visible = false, debounceDuration = 250, headers = [], @@ -90,9 +93,7 @@ const SpreadsheetImport = ({ ) if (errors.length > 0) { - toast.error( - `Some issues have been detected on ${errors.length} rows. More details below the content preview.` - ) + toast.error(csvParseErrorMessage) } setErrors(errors) @@ -127,9 +128,7 @@ const SpreadsheetImport = ({ if (text.length > 0) { const { headers, rows, columnTypeMap, errors } = await parseSpreadsheetText(text) if (errors.length > 0) { - toast.error( - `Some issues have been detected on ${errors.length} rows. More details below the content preview.` - ) + toast.error(csvParseErrorMessage) } setErrors(errors) setSelectedHeaders(headers) @@ -256,5 +255,3 @@ const SpreadsheetImport = ({ ) } - -export default SpreadsheetImport diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.utils.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.utils.tsx index 0148b5ec8d051..8ffd77b6b2cc2 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.utils.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImport.utils.tsx @@ -85,7 +85,7 @@ export const parseSpreadsheet = ( previewRows = results.data.slice(0, 20) if (results.errors.length > 0) { const formattedErrors = results.errors.map((error) => { - return { ...error, data: results.data[error.row] } + if (error) return { ...error, data: results.data[error.row] } }) errors.push(...formattedErrors) } diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImportPreview.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImportPreview.tsx index ab79e678df592..bcd5239d338bf 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImportPreview.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/SpreadsheetImport/SpreadsheetImportPreview.tsx @@ -1,7 +1,17 @@ import { AlertCircle, ArrowRight, ChevronDown, ChevronRight } from 'lucide-react' import { useEffect, useState } from 'react' -import { Badge, Button, cn, Collapsible, SidePanel } from 'ui' +import { + Badge, + Button, + cn, + Collapsible, + SidePanel, + Alert_Shadcn_, + AlertTitle_Shadcn_, + AlertDescription_Shadcn_, + WarningIcon, +} from 'ui' import type { SpreadsheetData } from './SpreadsheetImport.types' import SpreadsheetPreviewGrid from './SpreadsheetPreviewGrid' @@ -16,7 +26,7 @@ interface SpreadsheetImportPreviewProps { incompatibleHeaders: string[] } -const SpreadsheetImportPreview = ({ +export const SpreadsheetImportPreview = ({ selectedTable, spreadsheetData, errors = [], @@ -46,6 +56,15 @@ const SpreadsheetImportPreview = ({ } } + /** + * Remove items with duplicate row and code values because of the papaparse issue + * @link https://github.com/supabase/supabase/pull/38422#issue-3381886843 + **/ + const dedupedErrors = errors.filter( + (error, index, self) => + index === self.findIndex((t) => t.row === error.row && t.code === error.code) + ) + return ( @@ -54,7 +73,11 @@ const SpreadsheetImportPreview = ({

Preview data to be imported

{!isCompatible && Data incompatible} - {errors.length > 0 && {errors.length} issues found} + {dedupedErrors.length > 0 && ( + + {dedupedErrors.length} {dedupedErrors.length === 1 ? 'issue' : 'issues'} found + + )}
+ ) : ( +
+ + )} + {errorData !== undefined && isExpanded && ( + + )} + + ) + })} + +
+ + )}
) } - -export default SpreadsheetImportPreview diff --git a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx index cb4f1d26eebd8..ad75a7398601d 100644 --- a/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/SidePanelEditor/TableEditor/TableEditor.tsx @@ -30,7 +30,7 @@ import ActionBar from '../ActionBar' import type { ForeignKey } from '../ForeignKeySelector/ForeignKeySelector.types' import { formatForeignKeys } from '../ForeignKeySelector/ForeignKeySelector.utils' import type { ColumnField } from '../SidePanelEditor.types' -import SpreadsheetImport from '../SpreadsheetImport/SpreadsheetImport' +import { SpreadsheetImport } from '../SpreadsheetImport/SpreadsheetImport' import ColumnManagement from './ColumnManagement' import { ForeignKeysManagement } from './ForeignKeysManagement/ForeignKeysManagement' import HeaderTitle from './HeaderTitle' From b65a0aefb9dc77f9f6ad5ae9a2b8fdde25eb3634 Mon Sep 17 00:00:00 2001 From: Charis <26616127+charislam@users.noreply.github.com> Date: Tue, 9 Sep 2025 18:33:11 -0400 Subject: [PATCH 3/6] fix: turn compliance docs back on for main site (#38567) --- packages/common/enabled-features/enabled-features.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/common/enabled-features/enabled-features.json b/packages/common/enabled-features/enabled-features.json index 84aaf5ed99dda..be8eb9c1e7e30 100644 --- a/packages/common/enabled-features/enabled-features.json +++ b/packages/common/enabled-features/enabled-features.json @@ -32,7 +32,7 @@ "database:replication": true, "database:roles": true, - "docs:compliance": false, + "docs:compliance": true, "docs:self-hosting": true, "feedback:docs": true, From 8da4cbc46ed73383f660815b39869b6b2ce1cd7b Mon Sep 17 00:00:00 2001 From: Saxon Fletcher Date: Wed, 10 Sep 2025 12:49:34 +1000 Subject: [PATCH 4/6] Home New: Advisors (#38337) * new home top * advisors * fix ts * add advisor section * Update apps/studio/components/interfaces/Linter/Linter.utils.tsx Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> * Update apps/studio/components/interfaces/Linter/LintDetail.tsx Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> * Update apps/studio/components/interfaces/Linter/LinterDataGrid.tsx Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> * Update packages/ui-patterns/src/Row/index.tsx Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> * Update packages/ui-patterns/src/Row/index.tsx Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> * Update apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> * row refactor --------- Co-authored-by: Charis <26616127+charislam@users.noreply.github.com> Co-authored-by: Joshen Lim --- .../interfaces/HomeNew/AdvisorSection.tsx | 167 +++++++++++++++ .../components/interfaces/HomeNew/Home.tsx | 15 +- .../interfaces/Linter/LintDetail.tsx | 73 +++++++ .../interfaces/Linter/LintPageTabs.tsx | 10 +- .../interfaces/Linter/Linter.utils.tsx | 20 ++ .../interfaces/Linter/LinterDataGrid.tsx | 149 ++------------ .../project/[ref]/advisors/performance.tsx | 11 +- .../pages/project/[ref]/advisors/security.tsx | 11 +- packages/ui-patterns/index.tsx | 1 + packages/ui-patterns/src/Row/index.tsx | 193 ++++++++++++++++++ 10 files changed, 493 insertions(+), 157 deletions(-) create mode 100644 apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx create mode 100644 apps/studio/components/interfaces/Linter/LintDetail.tsx create mode 100644 packages/ui-patterns/src/Row/index.tsx diff --git a/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx b/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx new file mode 100644 index 0000000000000..710b4c1f0ec72 --- /dev/null +++ b/apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx @@ -0,0 +1,167 @@ +import { BarChart, Shield } from 'lucide-react' +import { useCallback, useMemo, useState } from 'react' + +import { useParams } from 'common' +import { LINTER_LEVELS } from 'components/interfaces/Linter/Linter.constants' +import { + createLintSummaryPrompt, + LintCategoryBadge, + lintInfoMap, +} from 'components/interfaces/Linter/Linter.utils' +import { ButtonTooltip } from 'components/ui/ButtonTooltip' +import { Lint, useProjectLintsQuery } from 'data/lint/lint-query' +import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' +import { + AiIconAnimation, + Button, + Card, + CardContent, + CardHeader, + CardTitle, + Sheet, + SheetContent, + SheetHeader, + SheetSection, + SheetTitle, +} from 'ui' +import { Row } from 'ui-patterns' +import ShimmeringLoader from 'ui-patterns/ShimmeringLoader' +import LintDetail from 'components/interfaces/Linter/LintDetail' + +export const AdvisorSection = () => { + const { ref: projectRef } = useParams() + const { data: lints, isLoading: isLoadingLints } = useProjectLintsQuery({ projectRef }) + const snap = useAiAssistantStateSnapshot() + + const [selectedLint, setSelectedLint] = useState(null) + + const errorLints: Lint[] = useMemo(() => { + return lints?.filter((lint) => lint.level === LINTER_LEVELS.ERROR) ?? [] + }, [lints]) + + const totalErrors = errorLints.length + + const titleContent = useMemo(() => { + if (totalErrors === 0) return

Assistant found no issues

+ const issuesText = totalErrors === 1 ? 'issue' : 'issues' + const numberDisplay = totalErrors.toString() + return ( +

+ Assistant found {numberDisplay} {issuesText} +

+ ) + }, [totalErrors]) + + const handleAskAssistant = useCallback(() => { + snap.toggleAssistant() + }, [snap]) + + const handleCardClick = useCallback((lint: Lint) => { + setSelectedLint(lint) + }, []) + + return ( +
+ {isLoadingLints ? ( + + ) : ( +
+ {titleContent} + +
+ )} + {isLoadingLints ? ( +
+ + + +
+ ) : errorLints.length > 0 ? ( + <> + + {errorLints.map((lint) => { + return ( + { + handleCardClick(lint) + }} + > + +
+ {lint.categories[0] === 'SECURITY' ? ( + + ) : ( + + )} + {lint.categories[0]} +
+ } + onClick={(e) => { + e.stopPropagation() + e.preventDefault() + snap.newChat({ + name: 'Summarize lint', + open: true, + initialInput: createLintSummaryPrompt(lint), + }) + }} + tooltip={{ + content: { side: 'bottom', text: 'Help me fix this issue' }, + }} + /> +
+ + {lint.detail ? lint.detail.substring(0, 100) : lint.title} + {lint.detail && lint.detail.length > 100 && '...'} + +
+ ) + })} +
+ setSelectedLint(null)}> + + {selectedLint && ( + <> + +
+ + {lintInfoMap.find((item) => item.name === selectedLint.name)?.title ?? + 'Unknown'} + + +
+
+ + {selectedLint && projectRef && ( + setSelectedLint(null)} + /> + )} + + + )} +
+
+ + ) : ( + + + +

+ No security or performance errors found +

+
+
+ )} +
+ ) +} diff --git a/apps/studio/components/interfaces/HomeNew/Home.tsx b/apps/studio/components/interfaces/HomeNew/Home.tsx index 5c2390d1c473f..d2cc0c2908719 100644 --- a/apps/studio/components/interfaces/HomeNew/Home.tsx +++ b/apps/studio/components/interfaces/HomeNew/Home.tsx @@ -16,6 +16,7 @@ import { } from 'hooks/misc/useSelectedProject' import { PROJECT_STATUS } from 'lib/constants' import { useAppStateSnapshot } from 'state/app-state' +import { AdvisorSection } from './AdvisorSection' export const HomeV2 = () => { const { ref, enableBranching } = useParams() @@ -100,11 +101,15 @@ export const HomeV2 = () => { )} strategy={verticalListSortingStrategy} > - {sectionOrder.map((id) => ( - - {id} - - ))} + {sectionOrder.map((id) => { + if (id === 'advisor') { + return ( + + + + ) + } + })} diff --git a/apps/studio/components/interfaces/Linter/LintDetail.tsx b/apps/studio/components/interfaces/Linter/LintDetail.tsx new file mode 100644 index 0000000000000..d499ac91e0f73 --- /dev/null +++ b/apps/studio/components/interfaces/Linter/LintDetail.tsx @@ -0,0 +1,73 @@ +import Link from 'next/link' +import ReactMarkdown from 'react-markdown' + +import { createLintSummaryPrompt, lintInfoMap } from 'components/interfaces/Linter/Linter.utils' +import { EntityTypeIcon, LintCTA, LintCategoryBadge, LintEntity } from './Linter.utils' +import { Lint } from 'data/lint/lint-query' +import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' +import { AiIconAnimation, Button } from 'ui' +import { ExternalLink } from 'lucide-react' + +interface LintDetailProps { + lint: Lint + projectRef: string + onAskAssistant?: () => void +} + +const LintDetail = ({ lint, projectRef, onAskAssistant }: LintDetailProps) => { + const snap = useAiAssistantStateSnapshot() + + return ( +
+

Entity

+
+ + +
+ +

Issue

+ + {lint.detail.replace(/\\`/g, '`')} + +

Description

+ + {lint.description.replace(/\\`/g, '`')} + + +

Resolve

+
+ + + +
+
+ ) +} + +export default LintDetail diff --git a/apps/studio/components/interfaces/Linter/LintPageTabs.tsx b/apps/studio/components/interfaces/Linter/LintPageTabs.tsx index ede1abe89bff2..e4c5b9cb97dd6 100644 --- a/apps/studio/components/interfaces/Linter/LintPageTabs.tsx +++ b/apps/studio/components/interfaces/Linter/LintPageTabs.tsx @@ -20,17 +20,10 @@ import { useRouter } from 'next/router' interface LintPageTabsProps { currentTab: string setCurrentTab: (value: LINTER_LEVELS) => void - setSelectedLint: (value: Lint | null) => void isLoading: boolean activeLints: Lint[] } -const LintPageTabs = ({ - currentTab, - setCurrentTab, - setSelectedLint, - isLoading, - activeLints, -}: LintPageTabsProps) => { +const LintPageTabs = ({ currentTab, setCurrentTab, isLoading, activeLints }: LintPageTabsProps) => { const router = useRouter() const warnLintsCount = activeLints.filter((x) => x.level === 'WARN').length @@ -73,7 +66,6 @@ const LintPageTabs = ({ defaultValue={currentTab} onValueChange={(value) => { setCurrentTab(value as LINTER_LEVELS) - setSelectedLint(null) const { sort, search, ...rest } = router.query router.push({ ...router, query: { ...rest, preset: value, id: null } }) }} diff --git a/apps/studio/components/interfaces/Linter/Linter.utils.tsx b/apps/studio/components/interfaces/Linter/Linter.utils.tsx index 8aed1dac9868e..0b114eb79fcf1 100644 --- a/apps/studio/components/interfaces/Linter/Linter.utils.tsx +++ b/apps/studio/components/interfaces/Linter/Linter.utils.tsx @@ -382,3 +382,23 @@ export const NoIssuesFound = ({ level }: { level: string }) => { ) } + +export const createLintSummaryPrompt = (lint: Lint) => { + const title = lintInfoMap.find((item) => item.name === lint.name)?.title ?? lint.title + const entity = + (lint.metadata && + (lint.metadata.entity || + (lint.metadata.schema && + lint.metadata.name && + `${lint.metadata.schema}.${lint.metadata.name}`))) || + 'N/A' + const schema = lint.metadata?.schema ?? 'N/A' + const issue = lint.detail ? lint.detail.replace(/\\`/g, '`') : 'N/A' + const description = lint.description ? lint.description.replace(/\\`/g, '`') : 'N/A' + return `Summarize the issue and suggest fixes for the following lint item: +Title: ${title} +Entity: ${entity} +Schema: ${schema} +Issue Details: ${issue} +Description: ${description}` +} diff --git a/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx b/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx index a8c8873ecb3dd..a7e9c29e8a9d3 100644 --- a/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx +++ b/apps/studio/components/interfaces/Linter/LinterDataGrid.tsx @@ -1,35 +1,27 @@ -import { ExternalLink, X } from 'lucide-react' -import Link from 'next/link' +import { X } from 'lucide-react' import { useRef, useState } from 'react' import DataGrid, { Column, DataGridHandle, Row } from 'react-data-grid' import ReactMarkdown from 'react-markdown' import { useParams } from 'common' import { LINTER_LEVELS } from 'components/interfaces/Linter/Linter.constants' -import { LintEntity, NoIssuesFound, lintInfoMap } from 'components/interfaces/Linter/Linter.utils' +import { + LintCategoryBadge, + LintEntity, + NoIssuesFound, + lintInfoMap, +} from 'components/interfaces/Linter/Linter.utils' import { Lint } from 'data/lint/lint-query' import { useRouter } from 'next/router' -import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' -import { - AiIconAnimation, - Button, - ResizableHandle, - ResizablePanel, - ResizablePanelGroup, - TabsContent_Shadcn_, - TabsList_Shadcn_, - TabsTrigger_Shadcn_, - Tabs_Shadcn_, - cn, -} from 'ui' +import { Button, ResizableHandle, ResizablePanel, ResizablePanelGroup, cn } from 'ui' import { GenericSkeletonLoader } from 'ui-patterns/ShimmeringLoader' -import { EntityTypeIcon, LintCTA, LintCategoryBadge } from './Linter.utils' +import { EntityTypeIcon } from './Linter.utils' +import LintDetail from './LintDetail' interface LinterDataGridProps { isLoading: boolean filteredLints: Lint[] selectedLint: Lint | null - setSelectedLint: (value: Lint | null) => void currentTab: LINTER_LEVELS } @@ -37,15 +29,11 @@ const LinterDataGrid = ({ isLoading, filteredLints, selectedLint, - setSelectedLint, currentTab, }: LinterDataGridProps) => { const gridRef = useRef(null) const { ref } = useParams() const router = useRouter() - const snap = useAiAssistantStateSnapshot() - - const [view, setView] = useState<'details' | 'suggestion'>('details') const lintCols = [ { @@ -121,7 +109,6 @@ const LinterDataGrid = ({ }) function handleSidepanelClose() { - setSelectedLint(null) const { id, ...otherParams } = router.query router.push({ query: otherParams }) } @@ -158,7 +145,6 @@ const LinterDataGrid = ({ {...props} onClick={() => { if (typeof idx === 'number' && idx >= 0) { - setSelectedLint(props.row) gridRef.current?.scrollToCell({ idx: 0, rowIdx: idx }) const { id, ...rest } = router.query router.push({ ...router, query: { ...rest, id: props.row.cache_key } }) @@ -181,109 +167,18 @@ const LinterDataGrid = ({ <> - - - - - - - - )} - - +
+
+

+ {lintInfoMap.find((item) => item.name === selectedLint.name)?.title ?? 'Unknown'} +

+ +
+
+
+ +
)} diff --git a/apps/studio/pages/project/[ref]/advisors/performance.tsx b/apps/studio/pages/project/[ref]/advisors/performance.tsx index 8bccf30af725e..ff4321175aa58 100644 --- a/apps/studio/pages/project/[ref]/advisors/performance.tsx +++ b/apps/studio/pages/project/[ref]/advisors/performance.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useMemo, useState } from 'react' import { useParams } from 'common' import LintPageTabs from 'components/interfaces/Linter/LintPageTabs' @@ -28,8 +28,6 @@ const ProjectLints: NextPageWithLayout = () => { const [currentTab, setCurrentTab] = useState( (preset as LINTER_LEVELS) ?? LINTER_LEVELS.ERROR ) - const [selectedLint, setSelectedLint] = useState(null) - const { data, isLoading, isRefetching, refetch } = useProjectLintsQuery({ projectRef: project?.ref, }) @@ -53,9 +51,8 @@ const ProjectLints: NextPageWithLayout = () => { value: type.name, })) - useEffect(() => { - // check the URL for an ID and set the selected lint - if (id) setSelectedLint(activeLints.find((lint) => lint.cache_key === id) ?? null) + const selectedLint: Lint | null = useMemo(() => { + return activeLints.find((lint) => lint.cache_key === id) ?? null }, [id, activeLints]) return ( @@ -70,7 +67,6 @@ const ProjectLints: NextPageWithLayout = () => { isLoading={isLoading} currentTab={currentTab} setCurrentTab={setCurrentTab} - setSelectedLint={setSelectedLint} /> { filteredLints={filteredLints} currentTab={currentTab} selectedLint={selectedLint} - setSelectedLint={setSelectedLint} isLoading={isLoading} /> diff --git a/apps/studio/pages/project/[ref]/advisors/security.tsx b/apps/studio/pages/project/[ref]/advisors/security.tsx index dba3e9245b8f0..cd324bed41ffb 100644 --- a/apps/studio/pages/project/[ref]/advisors/security.tsx +++ b/apps/studio/pages/project/[ref]/advisors/security.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from 'react' +import { useMemo, useState } from 'react' import { useParams } from 'common' import LintPageTabs from 'components/interfaces/Linter/LintPageTabs' @@ -28,8 +28,6 @@ const ProjectLints: NextPageWithLayout = () => { const [currentTab, setCurrentTab] = useState( (preset as LINTER_LEVELS) ?? LINTER_LEVELS.ERROR ) - const [selectedLint, setSelectedLint] = useState(null) - const { data, isLoading, isRefetching, refetch } = useProjectLintsQuery({ projectRef: project?.ref, }) @@ -55,9 +53,8 @@ const ProjectLints: NextPageWithLayout = () => { value: type.name, })) - useEffect(() => { - // check the URL for an ID and set the selected lint - if (id) setSelectedLint(activeLints.find((lint) => lint.cache_key === id) ?? null) + const selectedLint: Lint | null = useMemo(() => { + return activeLints.find((lint) => lint.cache_key === id) ?? null }, [id, activeLints]) return ( @@ -72,7 +69,6 @@ const ProjectLints: NextPageWithLayout = () => { isLoading={isLoading} currentTab={currentTab} setCurrentTab={setCurrentTab} - setSelectedLint={setSelectedLint} /> { filteredLints={filteredLints} currentTab={currentTab} selectedLint={selectedLint} - setSelectedLint={setSelectedLint} isLoading={isLoading} /> { + // columns can be a fixed number or an array [lg, md, sm] + columns: number | [number, number, number] + children: ReactNode + className?: string + /** gap between items in pixels */ + gap?: number + /** show left/right arrow buttons */ + showArrows?: boolean + /** scrolling behavior for arrow navigation */ + scrollBehavior?: ScrollBehavior +} + +export const Row = forwardRef(function Row( + { columns, children, className, gap = 16, showArrows = true, scrollBehavior = 'smooth', ...rest }, + ref +) { + const containerRef = useRef(null) + // We forward the ref to the outer wrapper; consumers needing the scroll container + // can use a separate ref prop in the future if required. + + const childrenArray = useMemo(() => (Array.isArray(children) ? children : [children]), [children]) + + const [scrollPosition, setScrollPosition] = useState(0) + const [maxScroll, setMaxScroll] = useState(0) + + const resolveColumnsForWidth = (width: number): number => { + if (!Array.isArray(columns)) return columns + // Interpret as [lg, md, sm] + const [lgCols, mdCols, smCols] = columns + if (width >= 1024) return lgCols + if (width >= 768) return mdCols + return smCols + } + + const getRenderColumns = (): number => { + const width = containerRef.current?.getBoundingClientRect().width ?? 0 + return resolveColumnsForWidth(width) + } + + const scrollByStep = (direction: -1 | 1) => { + const el = containerRef.current + if (!el) return + const widthLocal = el.getBoundingClientRect().width + const colsLocal = resolveColumnsForWidth(widthLocal) + const columnWidth = (widthLocal - (colsLocal - 1) * gap) / colsLocal + const scrollAmount = columnWidth + gap + setScrollPosition((prev) => Math.max(0, Math.min(maxScroll, prev + direction * scrollAmount))) + } + + const scrollLeft = () => scrollByStep(-1) + const scrollRight = () => scrollByStep(1) + + const canScrollLeft = scrollPosition > 0 + const canScrollRight = scrollPosition < maxScroll + + useEffect(() => { + const element = containerRef.current + if (!element) return + + const computeMaxScroll = (width: number) => { + const colsLocal = resolveColumnsForWidth(width) + const columnWidth = (width - (colsLocal - 1) * gap) / colsLocal + const totalWidth = childrenArray.length * columnWidth + (childrenArray.length - 1) * gap + const maxScrollValue = Math.max(0, totalWidth - width) + setMaxScroll(maxScrollValue) + } + + // Initial calculation + computeMaxScroll(element.getBoundingClientRect().width) + + if (typeof ResizeObserver !== 'undefined') { + const resizeObserver = new ResizeObserver((entries) => { + for (const entry of entries) { + computeMaxScroll(entry.contentRect.width) + } + }) + resizeObserver.observe(element) + return () => resizeObserver.disconnect() + } else { + const handleResize = () => computeMaxScroll(element.getBoundingClientRect().width) + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + } + }, [childrenArray.length, gap, columns]) + + useEffect(() => { + const handleWheel = (e: WheelEvent) => { + if (containerRef.current && containerRef.current.contains(e.target as Node)) { + if (e.deltaX !== 0) { + e.preventDefault() + + const scrollAmount = Math.abs(e.deltaX) * 2 + const direction = e.deltaX > 0 ? 1 : -1 + + setScrollPosition((prev) => { + const newPosition = prev + scrollAmount * direction + return Math.max(0, Math.min(maxScroll, newPosition)) + }) + } + } + } + + const container = containerRef.current + if (container) { + container.addEventListener('wheel', handleWheel, { passive: false }) + return () => container.removeEventListener('wheel', handleWheel) + } + }, [maxScroll]) + + useEffect(() => { + setScrollPosition((prev) => Math.min(prev, maxScroll)) + }, [maxScroll]) + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (containerRef.current && document.activeElement === containerRef.current) { + if (e.key === 'ArrowLeft' && canScrollLeft) { + e.preventDefault() + scrollLeft() + } else if (e.key === 'ArrowRight' && canScrollRight) { + e.preventDefault() + scrollRight() + } + } + } + + document.addEventListener('keydown', handleKeyDown) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [canScrollLeft, canScrollRight]) + + return ( +
+ {showArrows && canScrollLeft && ( + + )} + + {showArrows && canScrollRight && ( + + )} + +
+
+ {childrenArray.map((child, index) => ( +
+ {child} +
+ ))} +
+
+
+ ) +}) + +Row.displayName = 'Row' From bc3a47317067f4fd80674051e18f08e8538e9c17 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 10 Sep 2025 11:26:26 +0800 Subject: [PATCH 5/6] Swap useCheckPermissions with useAsyncCheckProjectPermissions Part 6 (#38559) More swapping --- .../VercelIntegration/VercelSection.tsx | 15 +++++-------- .../TableGridEditor/TableGridEditor.tsx | 12 +++++++--- .../BranchingPITRNotice.tsx | 4 ++-- .../PausedState/ProjectPausedState.tsx | 4 ++-- .../layouts/ReportsLayout/ReportMenuItem.tsx | 22 +++++++++++-------- .../layouts/ReportsLayout/ReportsMenu.tsx | 14 +++++++----- .../layouts/SQLEditorLayout/SQLEditorMenu.tsx | 14 +++++++----- .../SQLEditorLayout/SqlEditor.Commands.tsx | 14 +++++++----- .../TableEditorLayout/TableEditorLayout.tsx | 10 +++++++-- .../TableEditorLayout/TableEditorMenu.tsx | 9 +++++--- .../studio/components/layouts/Tabs/NewTab.tsx | 14 +++++++----- .../AIAssistantPanel/DisplayBlockRenderer.tsx | 14 +++++++----- .../ui/AIAssistantPanel/MessageMarkdown.tsx | 14 +++++++----- apps/studio/components/ui/UpgradeToPro.tsx | 4 ++-- .../data/config/project-settings-v2-query.ts | 7 ++++-- .../pages/project/[ref]/logs/auth-logs.tsx | 7 ++++-- 16 files changed, 111 insertions(+), 67 deletions(-) diff --git a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx index 94b1230f34c72..74c721dbe2ffe 100644 --- a/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx +++ b/apps/studio/components/interfaces/Settings/Integrations/VercelIntegration/VercelSection.tsx @@ -26,7 +26,7 @@ import type { IntegrationName, IntegrationProjectConnection, } from 'data/integrations/integrations.types' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { pluralize } from 'lib/helpers' @@ -43,18 +43,13 @@ const VercelSection = ({ isProjectScoped }: { isProjectScoped: boolean }) => { const sidePanelsStateSnapshot = useSidePanelsStateSnapshot() const isBranch = project?.parent_project_ref !== undefined - // placeholder for isLoading state when a useAsyncCheckOrgPermissions hook is added - // This component in used both in /org/[slug]/integrations and /project/[slug]/settings/integrations - const isLoadingPermissions = false - const canReadVercelConnection = useCheckPermissions( - PermissionAction.READ, - 'integrations.vercel_connections' - ) - const canCreateVercelConnection = useCheckPermissions( + const { can: canReadVercelConnection, isLoading: isLoadingPermissions } = + useAsyncCheckProjectPermissions(PermissionAction.READ, 'integrations.vercel_connections') + const { can: canCreateVercelConnection } = useAsyncCheckProjectPermissions( PermissionAction.CREATE, 'integrations.vercel_connections' ) - const canUpdateVercelConnection = useCheckPermissions( + const { can: canUpdateVercelConnection } = useAsyncCheckProjectPermissions( PermissionAction.UPDATE, 'integrations.vercel_connections' ) diff --git a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx index f0c1904650a5f..fdeef8867e42e 100644 --- a/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx +++ b/apps/studio/components/interfaces/TableGridEditor/TableGridEditor.tsx @@ -12,7 +12,7 @@ import { isTableLike, isView, } from 'data/table-editor/table-editor-types' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useUrlState } from 'hooks/ui/useUrlState' import { useIsProtectedSchema } from 'hooks/useProtectedSchemas' import { useAppStateSnapshot } from 'state/app-state' @@ -46,8 +46,14 @@ export const TableGridEditor = ({ const [{ view: selectedView = 'data' }] = useUrlState() - const canEditTables = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') - const canEditColumns = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'columns') + const { can: canEditTables } = useAsyncCheckProjectPermissions( + PermissionAction.TENANT_SQL_ADMIN_WRITE, + 'tables' + ) + const { can: canEditColumns } = useAsyncCheckProjectPermissions( + PermissionAction.TENANT_SQL_ADMIN_WRITE, + 'columns' + ) const isReadOnly = !canEditTables && !canEditColumns const tabId = !!id ? tabs.openTabs.find((x) => x.endsWith(id)) : undefined const openTabs = tabs.openTabs.filter((x) => !x.startsWith('sql')) diff --git a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice.tsx b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice.tsx index 32d66d380a55a..de2a75a3580aa 100644 --- a/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice.tsx +++ b/apps/studio/components/layouts/AppLayout/EnableBranchingButton/BranchingPITRNotice.tsx @@ -4,7 +4,7 @@ import Link from 'next/link' import { useParams } from 'common' import { ButtonTooltip } from 'components/ui/ButtonTooltip' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useAppStateSnapshot } from 'state/app-state' import { Button } from 'ui' @@ -12,7 +12,7 @@ export const BranchingPITRNotice = () => { const { ref } = useParams() const snap = useAppStateSnapshot() - const canUpdateSubscription = useCheckPermissions( + const { can: canUpdateSubscription } = useAsyncCheckProjectPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions' ) diff --git a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx index a369a28f2600e..353d948b00b1c 100644 --- a/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx +++ b/apps/studio/components/layouts/ProjectLayout/PausedState/ProjectPausedState.tsx @@ -18,7 +18,7 @@ import { PostgresEngine, ReleaseChannel } from 'data/projects/new-project.consta import { useProjectPauseStatusQuery } from 'data/projects/project-pause-status-query' import { useProjectRestoreMutation } from 'data/projects/project-restore-mutation' import { setProjectStatus } from 'data/projects/projects-query' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { usePHFlag } from 'hooks/ui/useFlag' @@ -94,7 +94,7 @@ export const ProjectPausedState = ({ product }: ProjectPausedStateProps) => { }, }) - const canResumeProject = useCheckPermissions( + const { can: canResumeProject } = useAsyncCheckProjectPermissions( PermissionAction.INFRA_EXECUTE, 'queue_jobs.projects.initialize_or_resume' ) diff --git a/apps/studio/components/layouts/ReportsLayout/ReportMenuItem.tsx b/apps/studio/components/layouts/ReportsLayout/ReportMenuItem.tsx index 5e8c78ac452d9..3ccba9049a7c3 100644 --- a/apps/studio/components/layouts/ReportsLayout/ReportMenuItem.tsx +++ b/apps/studio/components/layouts/ReportsLayout/ReportMenuItem.tsx @@ -3,7 +3,7 @@ import { ChevronDown, Edit2, Trash } from 'lucide-react' import Link from 'next/link' import { ContentBase } from 'data/content/content-query' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useProfile } from 'lib/profile' import { Dashboards } from 'types' import { @@ -41,14 +41,18 @@ export const ReportMenuItem = ({ onSelectDelete, }: ReportMenuItemProps) => { const { profile } = useProfile() - const canUpdateCustomReport = useCheckPermissions(PermissionAction.UPDATE, 'user_content', { - resource: { - type: 'report', - visibility: item.report.visibility, - owner_id: item.report.owner_id, - }, - subject: { id: profile?.id }, - }) + const { can: canUpdateCustomReport } = useAsyncCheckProjectPermissions( + PermissionAction.UPDATE, + 'user_content', + { + resource: { + type: 'report', + visibility: item.report.visibility, + owner_id: item.report.owner_id, + }, + subject: { id: profile?.id }, + } + ) return ( { const storageSupported = useIsFeatureEnabled('project_storage:all') const storageEnabled = storageReportEnabled && storageSupported - const canCreateCustomReport = useCheckPermissions(PermissionAction.CREATE, 'user_content', { - resource: { type: 'report', owner_id: profile?.id }, - subject: { id: profile?.id }, - }) + const { can: canCreateCustomReport } = useAsyncCheckProjectPermissions( + PermissionAction.CREATE, + 'user_content', + { + resource: { type: 'report', owner_id: profile?.id }, + subject: { id: profile?.id }, + } + ) // Preserve date range query parameters when navigating const preservedQueryParams = useMemo(() => { diff --git a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx index 002618991329b..bbc045e308314 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SQLEditorMenu.tsx @@ -6,7 +6,7 @@ import { useRouter } from 'next/router' import { useEffect, useState } from 'react' import { toast } from 'sonner' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useProfile } from 'lib/profile' @@ -48,10 +48,14 @@ export const SQLEditorMenu = () => { const appState = getAppStateSnapshot() const debouncedSearch = useDebounce(search, 500) - const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { - resource: { type: 'sql', owner_id: profile?.id }, - subject: { id: profile?.id }, - }) + const { can: canCreateSQLSnippet } = useAsyncCheckProjectPermissions( + PermissionAction.CREATE, + 'user_content', + { + resource: { type: 'sql', owner_id: profile?.id }, + subject: { id: profile?.id }, + } + ) const createNewFolder = () => { if (!ref) return console.error('Project ref is required') diff --git a/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx b/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx index 512bc97ec9723..dc7be6eb36363 100644 --- a/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx +++ b/apps/studio/components/layouts/SQLEditorLayout/SqlEditor.Commands.tsx @@ -9,7 +9,7 @@ import { COMMAND_MENU_SECTIONS } from 'components/interfaces/App/CommandMenu/Com import { orderCommandSectionsByPriority } from 'components/interfaces/App/CommandMenu/ordering' import { useSqlSnippetsQuery, type SqlSnippet } from 'data/content/sql-snippets-query' import { usePrefetchTables, useTablesQuery, type TablesData } from 'data/tables/tables-query' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useProtectedSchemas } from 'hooks/useProtectedSchemas' import { useProfile } from 'lib/profile' @@ -103,10 +103,14 @@ function RunSnippetPage() { const snippets = snippetPages?.pages.flatMap((page) => page.contents) const { profile } = useProfile() - const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { - resource: { type: 'sql', owner_id: profile?.id }, - subject: { id: profile?.id }, - }) + const { can: canCreateSQLSnippet } = useAsyncCheckProjectPermissions( + PermissionAction.CREATE, + 'user_content', + { + resource: { type: 'sql', owner_id: profile?.id }, + subject: { id: profile?.id }, + } + ) useSetCommandMenuSize('xlarge') diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx index c9b6b7a342fed..656f395ae4e8d 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorLayout.tsx @@ -2,12 +2,18 @@ import { PermissionAction } from '@supabase/shared-types/out/constants' import { PropsWithChildren } from 'react' import NoPermission from 'components/ui/NoPermission' -import { useCheckPermissions, usePermissionsLoaded } from 'hooks/misc/useCheckPermissions' +import { + useAsyncCheckProjectPermissions, + usePermissionsLoaded, +} from 'hooks/misc/useCheckPermissions' import { ProjectLayoutWithAuth } from '../ProjectLayout/ProjectLayout' const TableEditorLayout = ({ children }: PropsWithChildren<{}>) => { const isPermissionsLoaded = usePermissionsLoaded() - const canReadTables = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_READ, 'tables') + const { can: canReadTables } = useAsyncCheckProjectPermissions( + PermissionAction.TENANT_SQL_ADMIN_READ, + 'tables' + ) if (isPermissionsLoaded && !canReadTables) { return ( diff --git a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx index 24cbb07735b46..298b3f0c73032 100644 --- a/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx +++ b/apps/studio/components/layouts/TableEditorLayout/TableEditorMenu.tsx @@ -16,7 +16,7 @@ import SchemaSelector from 'components/ui/SchemaSelector' import { ENTITY_TYPE } from 'data/entity-types/entity-type-constants' import { useEntityTypesQuery } from 'data/entity-types/entity-types-infinite-query' import { getTableEditor, useTableEditorQuery } from 'data/table-editor/table-editor-query' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useLocalStorage } from 'hooks/misc/useLocalStorage' import { useQuerySchemaState } from 'hooks/misc/useSchemaQueryState' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' @@ -85,9 +85,12 @@ export const TableEditorMenu = () => { [data?.pages] ) - const canCreateTables = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, 'tables') + const { can: canCreateTables } = useAsyncCheckProjectPermissions( + PermissionAction.TENANT_SQL_ADMIN_WRITE, + 'tables' + ) - const { isSchemaLocked, reason } = useIsProtectedSchema({ schema: selectedSchema }) + const { isSchemaLocked } = useIsProtectedSchema({ schema: selectedSchema }) const { data: selectedTable } = useTableEditorQuery({ projectRef: project?.ref, diff --git a/apps/studio/components/layouts/Tabs/NewTab.tsx b/apps/studio/components/layouts/Tabs/NewTab.tsx index 2170a76ee3c49..182b85e96645b 100644 --- a/apps/studio/components/layouts/Tabs/NewTab.tsx +++ b/apps/studio/components/layouts/Tabs/NewTab.tsx @@ -9,7 +9,7 @@ import { useParams } from 'common' import { SQL_TEMPLATES } from 'components/interfaces/SQLEditor/SQLEditor.queries' import { createSqlSnippetSkeletonV2 } from 'components/interfaces/SQLEditor/SQLEditor.utils' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { uuidv4 } from 'lib/helpers' @@ -46,10 +46,14 @@ export function NewTab() { const [quickstarts] = partition(SQL_TEMPLATES, { type: 'quickstart' }) const { mutate: sendEvent } = useSendEventMutation() - const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { - resource: { type: 'sql', owner_id: profile?.id }, - subject: { id: profile?.id }, - }) + const { can: canCreateSQLSnippet } = useAsyncCheckProjectPermissions( + PermissionAction.CREATE, + 'user_content', + { + resource: { type: 'sql', owner_id: profile?.id }, + subject: { id: profile?.id }, + } + ) const tableEditorActions = [ { diff --git a/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx b/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx index bc8aa71e71c4c..deb418a749ecf 100644 --- a/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/DisplayBlockRenderer.tsx @@ -6,7 +6,7 @@ import { DragEvent, PropsWithChildren, useMemo, useState } from 'react' import { useParams } from 'common' import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useProfile } from 'lib/profile' import { useAiAssistantStateSnapshot } from 'state/ai-assistant-state' @@ -48,10 +48,14 @@ export const DisplayBlockRenderer = ({ const snap = useAiAssistantStateSnapshot() const { mutate: sendEvent } = useSendEventMutation() - const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { - resource: { type: 'sql', owner_id: profile?.id }, - subject: { id: profile?.id }, - }) + const { can: canCreateSQLSnippet } = useAsyncCheckProjectPermissions( + PermissionAction.CREATE, + 'user_content', + { + resource: { type: 'sql', owner_id: profile?.id }, + subject: { id: profile?.id }, + } + ) const [chartConfig, setChartConfig] = useState(() => ({ ...DEFAULT_CHART_CONFIG, diff --git a/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx b/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx index f6b5d3a9868c6..f3acd11c622a2 100644 --- a/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx +++ b/apps/studio/components/ui/AIAssistantPanel/MessageMarkdown.tsx @@ -13,7 +13,7 @@ import { import { ChartConfig } from 'components/interfaces/SQLEditor/UtilityPanel/ChartConfig' import { useSendEventMutation } from 'data/telemetry/send-event-mutation' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { useProfile } from 'lib/profile' @@ -232,10 +232,14 @@ export const MarkdownPre = ({ const { data: project } = useSelectedProjectQuery() const { data: org } = useSelectedOrganizationQuery() - const canCreateSQLSnippet = useCheckPermissions(PermissionAction.CREATE, 'user_content', { - resource: { type: 'sql', owner_id: profile?.id }, - subject: { id: profile?.id }, - }) + const { can: canCreateSQLSnippet } = useAsyncCheckProjectPermissions( + PermissionAction.CREATE, + 'user_content', + { + resource: { type: 'sql', owner_id: profile?.id }, + subject: { id: profile?.id }, + } + ) // [Joshen] Using a ref as this data doesn't need to trigger a re-render const chartConfig = useRef({ diff --git a/apps/studio/components/ui/UpgradeToPro.tsx b/apps/studio/components/ui/UpgradeToPro.tsx index 7c1406ef389e8..8e4b2a9d96622 100644 --- a/apps/studio/components/ui/UpgradeToPro.tsx +++ b/apps/studio/components/ui/UpgradeToPro.tsx @@ -3,7 +3,7 @@ import Link from 'next/link' import { ReactNode } from 'react' import { useFlag } from 'common' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedOrganizationQuery } from 'hooks/misc/useSelectedOrganization' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import { Button, cn } from 'ui' @@ -34,7 +34,7 @@ const UpgradeToPro = ({ const { data: organization } = useSelectedOrganizationQuery() const plan = organization?.plan?.id - const canUpdateSubscription = useCheckPermissions( + const { can: canUpdateSubscription } = useAsyncCheckProjectPermissions( PermissionAction.BILLING_WRITE, 'stripe.subscriptions' ) diff --git a/apps/studio/data/config/project-settings-v2-query.ts b/apps/studio/data/config/project-settings-v2-query.ts index 1827d14be5d68..1bba0b1806a43 100644 --- a/apps/studio/data/config/project-settings-v2-query.ts +++ b/apps/studio/data/config/project-settings-v2-query.ts @@ -3,7 +3,7 @@ import { useQuery, UseQueryOptions } from '@tanstack/react-query' import type { components } from 'data/api' import { get, handleError } from 'data/fetchers' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import type { ResponseError } from 'types' import { configKeys } from './keys' @@ -44,7 +44,10 @@ export const useProjectSettingsV2Query = ( ) => { // [Joshen] Sync with API perms checking here - shouldReturnApiKeys // https://github.com/supabase/infrastructure/blob/develop/api/src/routes/platform/projects/ref/settings.controller.ts#L92 - const canReadAPIKeys = useCheckPermissions(PermissionAction.TENANT_SQL_ADMIN_WRITE, '*') + const { can: canReadAPIKeys } = useAsyncCheckProjectPermissions( + PermissionAction.TENANT_SQL_ADMIN_WRITE, + '*' + ) return useQuery( configKeys.settingsV2(projectRef), diff --git a/apps/studio/pages/project/[ref]/logs/auth-logs.tsx b/apps/studio/pages/project/[ref]/logs/auth-logs.tsx index 44efb7b1dceeb..3992a84cf048f 100644 --- a/apps/studio/pages/project/[ref]/logs/auth-logs.tsx +++ b/apps/studio/pages/project/[ref]/logs/auth-logs.tsx @@ -4,13 +4,16 @@ import LogsPreviewer from 'components/interfaces/Settings/Logs/LogsPreviewer' import DefaultLayout from 'components/layouts/DefaultLayout' import LogsLayout from 'components/layouts/LogsLayout/LogsLayout' import NoPermission from 'components/ui/NoPermission' -import { useCheckPermissions } from 'hooks/misc/useCheckPermissions' +import { useAsyncCheckProjectPermissions } from 'hooks/misc/useCheckPermissions' import { useSelectedProjectQuery } from 'hooks/misc/useSelectedProject' import type { NextPageWithLayout } from 'types' const LogsPage: NextPageWithLayout = () => { const { data: project } = useSelectedProjectQuery() - const canReadAuthLogs = useCheckPermissions(PermissionAction.ANALYTICS_READ, 'logflare') + const { can: canReadAuthLogs } = useAsyncCheckProjectPermissions( + PermissionAction.ANALYTICS_READ, + 'logflare' + ) return !canReadAuthLogs ? ( From 3963cbdf8f0dc05fc29b7c0da85f272c13cec7b8 Mon Sep 17 00:00:00 2001 From: Joshen Lim Date: Wed, 10 Sep 2025 12:00:16 +0800 Subject: [PATCH 6/6] Gracefully handle feature flag set up (#38394) * Gracefully handle feature flag set up * Update FeatureFlagProvider to support granular provider toggling, and make getConfigCatFlags an optional prop * Nit * Fix TS --- apps/studio/pages/_app.tsx | 2 +- apps/www/components/Footer/index.tsx | 2 +- apps/www/lib/supabase.ts | 4 +-- apps/www/pages/_app.tsx | 4 +-- packages/common/configcat.ts | 21 ++++++++++----- packages/common/feature-flags.tsx | 39 +++++++++++++++++++--------- 6 files changed, 47 insertions(+), 25 deletions(-) diff --git a/apps/studio/pages/_app.tsx b/apps/studio/pages/_app.tsx index 34b2be5f2d161..c1113bbac65ea 100644 --- a/apps/studio/pages/_app.tsx +++ b/apps/studio/pages/_app.tsx @@ -101,10 +101,10 @@ function CustomApp({ Component, pageProps }: AppPropsWithLayout) { const isTestEnv = process.env.NEXT_PUBLIC_NODE_ENV === 'test' const cloudProvider = useDefaultProvider() + const getConfigCatFlags = useCallback( (userEmail?: string) => { const customAttributes = cloudProvider ? { cloud_provider: cloudProvider } : undefined - return getFlags(userEmail, customAttributes) }, [cloudProvider] diff --git a/apps/www/components/Footer/index.tsx b/apps/www/components/Footer/index.tsx index 26fba97af5c40..381e9a03105db 100644 --- a/apps/www/components/Footer/index.tsx +++ b/apps/www/components/Footer/index.tsx @@ -9,12 +9,12 @@ import { useEffect } from 'react' import * as supabaseLogoWordmarkDark from 'common/assets/images/supabase-logo-wordmark--dark.png' import * as supabaseLogoWordmarkLight from 'common/assets/images/supabase-logo-wordmark--light.png' import footerData from 'data/Footer' +import { usePathname } from 'next/navigation' import { Badge, IconDiscord, IconGitHubSolid, IconTwitterX, IconYoutubeSolid, cn } from 'ui' import { ThemeToggle } from 'ui-patterns/ThemeToggle' import supabase from '~/lib/supabase' import useDarkLaunchWeeks from '../../hooks/useDarkLaunchWeeks' import SectionContainer from '../Layouts/SectionContainer' -import { usePathname } from 'next/navigation' interface Props { className?: string diff --git a/apps/www/lib/supabase.ts b/apps/www/lib/supabase.ts index 9d8b881ebe491..22237c346a510 100644 --- a/apps/www/lib/supabase.ts +++ b/apps/www/lib/supabase.ts @@ -2,8 +2,8 @@ import { createClient } from '@supabase/supabase-js' import { Database } from './database.types' const supabase = createClient( - process.env.NEXT_PUBLIC_SUPABASE_URL!, - process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!, + process.env.NEXT_PUBLIC_SUPABASE_URL ?? '', + process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? '', { realtime: { params: { diff --git a/apps/www/pages/_app.tsx b/apps/www/pages/_app.tsx index be7a9e4042e7e..f839f5cbd4042 100644 --- a/apps/www/pages/_app.tsx +++ b/apps/www/pages/_app.tsx @@ -5,7 +5,6 @@ import '../styles/index.css' import { AuthProvider, FeatureFlagProvider, - getFlags as getConfigCatFlags, IS_PLATFORM, PageTelemetry, TelemetryTagManager, @@ -87,7 +86,8 @@ export default function App({ Component, pageProps }: AppProps) { /> - + {/* [TODO] I think we need to deconflict with the providers in layout.tsx? */} + theme.value)} enableSystem diff --git a/packages/common/configcat.ts b/packages/common/configcat.ts index 410f40addcc97..419055cbb90ef 100644 --- a/packages/common/configcat.ts +++ b/packages/common/configcat.ts @@ -27,17 +27,22 @@ export const fetchHandler: typeof fetch = async (input, init) => { } async function getClient() { - if (!process.env.NEXT_PUBLIC_CONFIGCAT_PROXY_URL) { - console.error( - 'Failed to get ConfigCat client: missing env var "NEXT_PUBLIC_CONFIGCAT_PROXY_URL"' - ) - } - if (client) return client + if (!process.env.NEXT_PUBLIC_CONFIGCAT_SDK_KEY && !process.env.NEXT_PUBLIC_CONFIGCAT_PROXY_URL) { + console.log('Skipping ConfigCat set up as env vars are not present') + return undefined + } + const response = await fetchHandler(process.env.NEXT_PUBLIC_CONFIGCAT_PROXY_URL + endpoint) const options = { pollIntervalSeconds: 7 * 60 } // 7 minutes + if (response.status !== 200) { + if (!process.env.NEXT_PUBLIC_CONFIGCAT_SDK_KEY) { + console.error('Failed to set up ConfigCat: SDK Key is missing') + return undefined + } + // proxy is down, use default client client = configcat.getClient( process.env.NEXT_PUBLIC_CONFIGCAT_SDK_KEY ?? '', @@ -57,7 +62,9 @@ async function getClient() { export async function getFlags(userEmail: string = '', customAttributes?: Record) { const client = await getClient() - if (userEmail) { + if (!client) { + return [] + } else if (userEmail) { return client.getAllValuesAsync( new configcat.User(userEmail, undefined, undefined, customAttributes) ) diff --git a/packages/common/feature-flags.tsx b/packages/common/feature-flags.tsx index 85413bff5caa6..ee8b1c3b77d2b 100644 --- a/packages/common/feature-flags.tsx +++ b/packages/common/feature-flags.tsx @@ -5,6 +5,7 @@ import { createContext, PropsWithChildren, useContext, useEffect, useState } fro import { components } from 'api-types' import { useUser } from './auth' +import { getFlags as getDefaultConfigCatFlags } from './configcat' import { hasConsented } from './consent-state' import { get, post } from './fetchWrappers' import { ensurePlatformSuffix } from './helpers' @@ -13,9 +14,15 @@ type TrackFeatureFlagVariables = components['schemas']['TelemetryFeatureFlagBody export type CallFeatureFlagsResponse = components['schemas']['TelemetryCallFeatureFlagsResponse'] export async function getFeatureFlags(API_URL: string) { - const data = await get(`${ensurePlatformSuffix(API_URL)}/telemetry/feature-flags`) - - return data as CallFeatureFlagsResponse + try { + const data = await get(`${ensurePlatformSuffix(API_URL)}/telemetry/feature-flags`) + return data as CallFeatureFlagsResponse + } catch (error: any) { + if (error.message.includes('Failed to fetch')) { + console.error('Failed to fetch PH flags: API is not available') + } + throw error + } } export async function trackFeatureFlag(API_URL: string, body: TrackFeatureFlagVariables) { @@ -57,8 +64,10 @@ export const FeatureFlagProvider = ({ getConfigCatFlags, children, }: PropsWithChildren<{ - API_URL: string - enabled?: boolean + API_URL?: string + /** Accepts either `boolean` which controls all feature flags or `{ cc: boolean, ph: boolean }` for individual providers */ + enabled?: boolean | { cc: boolean; ph: boolean } + /** Custom fetcher for ConfigCat flags if passing in custom attributes */ getConfigCatFlags?: ( userEmail?: string ) => Promise<{ settingKey: string; settingValue: boolean | number | string | null | undefined }[]> @@ -78,23 +87,29 @@ export const FeatureFlagProvider = ({ async function processFlags() { if (!enabled) return + const loadPHFlags = + (enabled === true || (typeof enabled === 'object' && enabled.ph)) && !!API_URL + const loadCCFlags = enabled === true || (typeof enabled === 'object' && enabled.cc) + let flagStore: FeatureFlagContextType = { configcat: {}, posthog: {} } // Run both async operations in parallel const [flags, flagValues] = await Promise.all([ - getFeatureFlags(API_URL), - typeof getConfigCatFlags === 'function' - ? getConfigCatFlags(user?.email) + loadPHFlags ? getFeatureFlags(API_URL) : Promise.resolve({}), + loadCCFlags + ? typeof getConfigCatFlags === 'function' + ? getConfigCatFlags(user?.email) + : getDefaultConfigCatFlags(user?.email) : Promise.resolve([]), ]) - // Process PostHog flags - if (flags) { + // Process PostHog flags if loaded + if (Object.keys(flags).length > 0) { flagStore.posthog = flags } - // Process ConfigCat flags - if (typeof getConfigCatFlags === 'function') { + // Process ConfigCat flags if loaded + if (flagValues.length > 0) { let overridesCookieValue: Record = {} try { const cookies = getCookies()