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 <joshenlimek@gmail.com>
This commit is contained in:
167
apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx
Normal file
167
apps/studio/components/interfaces/HomeNew/AdvisorSection.tsx
Normal file
@@ -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<Lint | null>(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 <h2>Assistant found no issues</h2>
|
||||
const issuesText = totalErrors === 1 ? 'issue' : 'issues'
|
||||
const numberDisplay = totalErrors.toString()
|
||||
return (
|
||||
<h2>
|
||||
Assistant found {numberDisplay} {issuesText}
|
||||
</h2>
|
||||
)
|
||||
}, [totalErrors])
|
||||
|
||||
const handleAskAssistant = useCallback(() => {
|
||||
snap.toggleAssistant()
|
||||
}, [snap])
|
||||
|
||||
const handleCardClick = useCallback((lint: Lint) => {
|
||||
setSelectedLint(lint)
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div>
|
||||
{isLoadingLints ? (
|
||||
<ShimmeringLoader className="w-96 mb-6" />
|
||||
) : (
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
{titleContent}
|
||||
<Button type="default" icon={<AiIconAnimation />} onClick={handleAskAssistant}>
|
||||
Ask Assistant
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{isLoadingLints ? (
|
||||
<div className="flex flex-col p-4 gap-2">
|
||||
<ShimmeringLoader />
|
||||
<ShimmeringLoader className="w-3/4" />
|
||||
<ShimmeringLoader className="w-1/2" />
|
||||
</div>
|
||||
) : errorLints.length > 0 ? (
|
||||
<>
|
||||
<Row columns={[3, 2, 1]}>
|
||||
{errorLints.map((lint) => {
|
||||
return (
|
||||
<Card
|
||||
key={lint.cache_key}
|
||||
className="h-full flex flex-col items-stretch cursor-pointer"
|
||||
onClick={() => {
|
||||
handleCardClick(lint)
|
||||
}}
|
||||
>
|
||||
<CardHeader className="border-b-0 shrink-0 flex flex-row gap-2 space-y-0 justify-between items-center">
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
{lint.categories[0] === 'SECURITY' ? (
|
||||
<Shield size={16} strokeWidth={1.5} className="text-foreground-muted" />
|
||||
) : (
|
||||
<BarChart size={16} strokeWidth={1.5} className="text-foreground-muted" />
|
||||
)}
|
||||
<CardTitle className="text-foreground-light">{lint.categories[0]}</CardTitle>
|
||||
</div>
|
||||
<ButtonTooltip
|
||||
type="text"
|
||||
className="w-7 h-7"
|
||||
icon={<AiIconAnimation size={16} />}
|
||||
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' },
|
||||
}}
|
||||
/>
|
||||
</CardHeader>
|
||||
<CardContent className="p-6 pt-16 flex flex-col justify-end flex-1 overflow-auto">
|
||||
{lint.detail ? lint.detail.substring(0, 100) : lint.title}
|
||||
{lint.detail && lint.detail.length > 100 && '...'}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</Row>
|
||||
<Sheet open={selectedLint !== null} onOpenChange={() => setSelectedLint(null)}>
|
||||
<SheetContent>
|
||||
{selectedLint && (
|
||||
<>
|
||||
<SheetHeader>
|
||||
<div className="flex items-center gap-4">
|
||||
<SheetTitle>
|
||||
{lintInfoMap.find((item) => item.name === selectedLint.name)?.title ??
|
||||
'Unknown'}
|
||||
</SheetTitle>
|
||||
<LintCategoryBadge category={selectedLint.categories[0]} />
|
||||
</div>
|
||||
</SheetHeader>
|
||||
<SheetSection>
|
||||
{selectedLint && projectRef && (
|
||||
<LintDetail
|
||||
lint={selectedLint}
|
||||
projectRef={projectRef!}
|
||||
onAskAssistant={() => setSelectedLint(null)}
|
||||
/>
|
||||
)}
|
||||
</SheetSection>
|
||||
</>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</>
|
||||
) : (
|
||||
<Card className="bg-transparent">
|
||||
<CardContent className="flex flex-col items-center justify-center gap-2 p-16">
|
||||
<Shield size={20} strokeWidth={1.5} className="text-foreground-muted" />
|
||||
<p className="text-sm text-foreground-light text-center">
|
||||
No security or performance errors found
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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) => (
|
||||
<SortableSection key={id} id={id}>
|
||||
{id}
|
||||
</SortableSection>
|
||||
))}
|
||||
{sectionOrder.map((id) => {
|
||||
if (id === 'advisor') {
|
||||
return (
|
||||
<SortableSection key={id} id={id}>
|
||||
<AdvisorSection />
|
||||
</SortableSection>
|
||||
)
|
||||
}
|
||||
})}
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</ScaffoldSection>
|
||||
|
||||
73
apps/studio/components/interfaces/Linter/LintDetail.tsx
Normal file
73
apps/studio/components/interfaces/Linter/LintDetail.tsx
Normal file
@@ -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 (
|
||||
<div>
|
||||
<h3 className="text-sm mb-2">Entity</h3>
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 bg-surface-200 border rounded-lg text-sm mb-6 w-fit">
|
||||
<EntityTypeIcon type={lint.metadata?.type} />
|
||||
<LintEntity metadata={lint.metadata} />
|
||||
</div>
|
||||
|
||||
<h3 className="text-sm mb-2">Issue</h3>
|
||||
<ReactMarkdown className="leading-6 text-sm text-foreground-light mb-6">
|
||||
{lint.detail.replace(/\\`/g, '`')}
|
||||
</ReactMarkdown>
|
||||
<h3 className="text-sm mb-2">Description</h3>
|
||||
<ReactMarkdown className="text-sm text-foreground-light mb-6">
|
||||
{lint.description.replace(/\\`/g, '`')}
|
||||
</ReactMarkdown>
|
||||
|
||||
<h3 className="text-sm mb-2">Resolve</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
icon={<AiIconAnimation className="scale-75 w-3 h-3" />}
|
||||
onClick={() => {
|
||||
onAskAssistant?.()
|
||||
snap.newChat({
|
||||
name: 'Summarize lint',
|
||||
open: true,
|
||||
initialInput: createLintSummaryPrompt(lint),
|
||||
})
|
||||
}}
|
||||
>
|
||||
Ask Assistant
|
||||
</Button>
|
||||
<LintCTA title={lint.name} projectRef={projectRef} metadata={lint.metadata} />
|
||||
<Button asChild type="text">
|
||||
<Link
|
||||
href={
|
||||
lintInfoMap.find((item) => item.name === lint.name)?.docsLink ||
|
||||
'https://supabase.com/docs/guides/database/database-linter'
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="no-underline"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
Learn more <ExternalLink size={14} />
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LintDetail
|
||||
@@ -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 } })
|
||||
}}
|
||||
|
||||
@@ -382,3 +382,23 @@ export const NoIssuesFound = ({ level }: { level: string }) => {
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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}`
|
||||
}
|
||||
|
||||
@@ -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<DataGridHandle>(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 = ({
|
||||
<>
|
||||
<ResizableHandle withHandle />
|
||||
<ResizablePanel defaultSize={30} maxSize={45} minSize={30} className="bg-studio border-t">
|
||||
<Button
|
||||
type="text"
|
||||
className="absolute top-3 right-3 px-1"
|
||||
icon={<X />}
|
||||
onClick={handleSidepanelClose}
|
||||
/>
|
||||
|
||||
<Tabs_Shadcn_
|
||||
value={view}
|
||||
className="flex flex-col h-full"
|
||||
onValueChange={(value: any) => {
|
||||
setView(value)
|
||||
}}
|
||||
>
|
||||
<TabsList_Shadcn_ className="px-5 flex gap-x-4 min-h-[46px]">
|
||||
<TabsTrigger_Shadcn_
|
||||
value="details"
|
||||
className="px-0 pb-0 h-full text-xs data-[state=active]:bg-transparent !shadow-none"
|
||||
>
|
||||
Overview
|
||||
</TabsTrigger_Shadcn_>
|
||||
</TabsList_Shadcn_>
|
||||
<TabsContent_Shadcn_
|
||||
value="details"
|
||||
className="mt-0 flex-grow min-h-0 overflow-y-auto prose"
|
||||
>
|
||||
{selectedLint && (
|
||||
<div className="py-4 px-5">
|
||||
<div className="flex items-center gap-2 py-2">
|
||||
<h3 className="text-sm m-0">
|
||||
{lintInfoMap.find((item) => item.name === selectedLint.name)?.title}
|
||||
</h3>
|
||||
<LintCategoryBadge category={selectedLint.categories[0]} />
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-sm mt-4">
|
||||
<span>Entity</span>
|
||||
<div className="flex items-center gap-1 px-2 py-0.5 bg-surface-200 border rounded-lg ">
|
||||
<EntityTypeIcon type={selectedLint.metadata?.type} />
|
||||
<LintEntity metadata={selectedLint.metadata} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid">
|
||||
<div>
|
||||
<h3 className="text-sm">Issue</h3>
|
||||
<ReactMarkdown className="leading-6 text-sm">
|
||||
{selectedLint.detail.replace(/\\`/g, '`')}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm">Description</h3>
|
||||
<ReactMarkdown className="text-sm">
|
||||
{selectedLint.description.replace(/\\`/g, '`')}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-2">
|
||||
<h3 className="text-sm">Resolve</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
icon={<AiIconAnimation className="scale-75 w-3 h-3" />}
|
||||
onClick={() => {
|
||||
snap.newChat({
|
||||
name: 'Summarize lint',
|
||||
open: true,
|
||||
initialInput: `Summarize the issue and suggest fixes: ${lintInfoMap.find((item) => item.name === selectedLint.name)?.title}
|
||||
\nEntity: ${(selectedLint.metadata && (selectedLint.metadata.entity || (selectedLint.metadata.schema && selectedLint.metadata.name && `${selectedLint.metadata.schema}.${selectedLint.metadata.name}`))) ?? ''}
|
||||
\nSchema: ${selectedLint.metadata?.schema ?? ''}
|
||||
\nIssue: ${selectedLint.detail.replace(/\\`/g, '`')}
|
||||
\nDescription: ${selectedLint.description.replace(/\\`/g, '`')}\n`,
|
||||
})
|
||||
}}
|
||||
>
|
||||
Ask Assistant
|
||||
</Button>
|
||||
<LintCTA
|
||||
title={selectedLint.name}
|
||||
projectRef={ref!}
|
||||
metadata={selectedLint.metadata}
|
||||
/>
|
||||
<Button asChild type="text">
|
||||
<Link
|
||||
href={
|
||||
lintInfoMap.find((item) => item.name === selectedLint.name)
|
||||
?.docsLink ||
|
||||
'https://supabase.com/docs/guides/database/database-linter'
|
||||
}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="no-underline"
|
||||
>
|
||||
<span className="flex items-center gap-2">
|
||||
Learn more <ExternalLink size={14} />
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</TabsContent_Shadcn_>
|
||||
</Tabs_Shadcn_>
|
||||
<div className="flex items-center justify-between w-full border-b py-3 px-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm m-0">
|
||||
{lintInfoMap.find((item) => item.name === selectedLint.name)?.title ?? 'Unknown'}
|
||||
</h3>
|
||||
<LintCategoryBadge category={selectedLint.categories[0]} />
|
||||
</div>
|
||||
<Button type="text" icon={<X />} onClick={handleSidepanelClose} />
|
||||
</div>
|
||||
<div className="p-6">
|
||||
<LintDetail lint={selectedLint} projectRef={ref!} />
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -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<LINTER_LEVELS>(
|
||||
(preset as LINTER_LEVELS) ?? LINTER_LEVELS.ERROR
|
||||
)
|
||||
const [selectedLint, setSelectedLint] = useState<Lint | null>(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}
|
||||
/>
|
||||
<LinterFilters
|
||||
filterOptions={filterOptions}
|
||||
@@ -87,7 +83,6 @@ const ProjectLints: NextPageWithLayout = () => {
|
||||
filteredLints={filteredLints}
|
||||
currentTab={currentTab}
|
||||
selectedLint={selectedLint}
|
||||
setSelectedLint={setSelectedLint}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<LinterPageFooter isLoading={isLoading} isRefetching={isRefetching} refetch={refetch} />
|
||||
|
||||
@@ -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<LINTER_LEVELS>(
|
||||
(preset as LINTER_LEVELS) ?? LINTER_LEVELS.ERROR
|
||||
)
|
||||
const [selectedLint, setSelectedLint] = useState<Lint | null>(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}
|
||||
/>
|
||||
<LinterFilters
|
||||
filterOptions={filterOptions}
|
||||
@@ -89,7 +85,6 @@ const ProjectLints: NextPageWithLayout = () => {
|
||||
filteredLints={filteredLints}
|
||||
currentTab={currentTab}
|
||||
selectedLint={selectedLint}
|
||||
setSelectedLint={setSelectedLint}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
<LinterPageFooter
|
||||
|
||||
@@ -16,3 +16,4 @@ export * from './src/ShimmeringLoader'
|
||||
export * from './src/TimestampInfo'
|
||||
export * from './src/Toc'
|
||||
export * from './src/PromoToast'
|
||||
export * from './src/Row'
|
||||
|
||||
193
packages/ui-patterns/src/Row/index.tsx
Normal file
193
packages/ui-patterns/src/Row/index.tsx
Normal file
@@ -0,0 +1,193 @@
|
||||
'use client'
|
||||
|
||||
import type React from 'react'
|
||||
import { forwardRef, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { Button, cn } from 'ui'
|
||||
import type { ReactNode } from 'react'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
|
||||
interface RowProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
// 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<HTMLDivElement, RowProps>(function Row(
|
||||
{ columns, children, className, gap = 16, showArrows = true, scrollBehavior = 'smooth', ...rest },
|
||||
ref
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(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 (
|
||||
<div ref={ref} className={cn('relative w-full', className)} {...rest}>
|
||||
{showArrows && canScrollLeft && (
|
||||
<Button
|
||||
type="default"
|
||||
onClick={scrollLeft}
|
||||
className="absolute w-8 h-8 left-0 top-1/2 -translate-y-1/2 z-10 rounded-full p-2"
|
||||
aria-label="Scroll left"
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{showArrows && canScrollRight && (
|
||||
<Button
|
||||
type="default"
|
||||
onClick={scrollRight}
|
||||
className="absolute w-8 h-8 right-0 top-1/2 -translate-y-1/2 z-10 rounded-full p-2"
|
||||
aria-label="Scroll right"
|
||||
>
|
||||
<ChevronRight className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full overflow-visible focus:outline-none"
|
||||
tabIndex={0}
|
||||
role="region"
|
||||
aria-roledescription="carousel"
|
||||
aria-label="Horizontally scrollable content"
|
||||
>
|
||||
<div
|
||||
className="flex items-stretch min-w-full transition-transform duration-300 ease-out"
|
||||
style={
|
||||
{
|
||||
gap: `${gap}px`,
|
||||
'--column-width': `calc((100% - ${(getRenderColumns() - 1) * gap}px) / ${getRenderColumns()})`,
|
||||
transform: `translateX(-${scrollPosition}px)`,
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
{childrenArray.map((child, index) => (
|
||||
<div key={index} className="flex-shrink-0" style={{ width: 'var(--column-width)' }}>
|
||||
{child}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
|
||||
Row.displayName = 'Row'
|
||||
Reference in New Issue
Block a user