mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 17:35:10 +03:00
Chore/add export functionality to advisors (#33030)
* Add export functionality to security and performance advisors * Add download results button to query performance advisor * Add refresh buttons to security and performance advisors * Add LoadingLine to Query performance to make UI consistent with the other advisors * Minor change
This commit is contained in:
1 parent
79e656f735
commit
794529eddf
9 files changed
+188
-216
No files matched your search
@@ -1,6 +1,10 @@
|
||||
import { useParams } from 'common'
|
||||
import { LINTER_LEVELS, LINT_TABS } from 'components/interfaces/Linter/Linter.constants'
|
||||
import { DownloadResultsButton } from 'components/ui/DownloadResultsButton'
|
||||
import { FilterPopover } from 'components/ui/FilterPopover'
|
||||
import { Lint } from 'data/lint/lint-query'
|
||||
import { RefreshCw } from 'lucide-react'
|
||||
import { Button } from 'ui'
|
||||
|
||||
interface LinterFiltersProps {
|
||||
filterOptions: {
|
||||
@@ -8,18 +12,26 @@ interface LinterFiltersProps {
|
||||
value: string
|
||||
}[]
|
||||
activeLints: Lint[]
|
||||
filteredLints: Lint[]
|
||||
currentTab: LINTER_LEVELS
|
||||
filters: { level: LINTER_LEVELS; filters: string[] }[]
|
||||
isLoading: boolean
|
||||
setFilters: (value: { level: LINTER_LEVELS; filters: string[] }[]) => void
|
||||
onClickRefresh: () => void
|
||||
}
|
||||
|
||||
const LinterFilters = ({
|
||||
filterOptions,
|
||||
activeLints,
|
||||
filteredLints,
|
||||
currentTab,
|
||||
filters,
|
||||
isLoading,
|
||||
setFilters,
|
||||
onClickRefresh,
|
||||
}: LinterFiltersProps) => {
|
||||
const { ref } = useParams()
|
||||
|
||||
const updateFilters = (level: LINTER_LEVELS, newFilters: string[]) => {
|
||||
const updatedFilters = [...filters]
|
||||
|
||||
@@ -31,11 +43,12 @@ const LinterFilters = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="bg-surface-200 p-2 px-6 py-2 border-t -mt-px">
|
||||
<div className="px-6 py-2 -mt-px flex bg-surface-200 items-center justify-between border-t">
|
||||
{LINT_TABS.map((tab) => (
|
||||
<div key={tab.id} className={tab.id === currentTab ? '' : 'hidden'}>
|
||||
<FilterPopover
|
||||
name="Filter"
|
||||
className="w-52"
|
||||
options={filterOptions}
|
||||
disabled={activeLints.filter((x) => x.level === tab.id).length === 0}
|
||||
labelKey="name"
|
||||
@@ -45,6 +58,24 @@ const LinterFilters = ({
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="flex items-center gap-x-2">
|
||||
<Button
|
||||
size="tiny"
|
||||
type="default"
|
||||
disabled={isLoading}
|
||||
onClick={onClickRefresh}
|
||||
icon={
|
||||
<RefreshCw className={`text-foreground-light ${isLoading ? 'animate-spin' : ''}`} />
|
||||
}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<DownloadResultsButton
|
||||
align="end"
|
||||
results={filteredLints}
|
||||
fileName={`Supabase Performance Security Lints (${ref})`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import { LOCAL_STORAGE_KEYS } from 'lib/constants'
|
||||
import { useDatabaseSelectorStateSnapshot } from 'state/database-selector'
|
||||
import {
|
||||
Button,
|
||||
LoadingLine,
|
||||
TabsList_Shadcn_,
|
||||
TabsTrigger_Shadcn_,
|
||||
Tabs_Shadcn_,
|
||||
@@ -44,6 +45,8 @@ export const QueryPerformance = ({
|
||||
const { project } = useProjectContext()
|
||||
const state = useDatabaseSelectorStateSnapshot()
|
||||
|
||||
const { isLoading, isRefetching } = queryPerformanceQuery
|
||||
|
||||
const [page, setPage] = useState<QUERY_PERFORMANCE_REPORT_TYPES>(
|
||||
(preset as QUERY_PERFORMANCE_REPORT_TYPES) ?? QUERY_PERFORMANCE_REPORT_TYPES.MOST_TIME_CONSUMING
|
||||
)
|
||||
@@ -190,14 +193,11 @@ export const QueryPerformance = ({
|
||||
</TabsList_Shadcn_>
|
||||
</Tabs_Shadcn_>
|
||||
|
||||
<div className="px-6 py-3 bg-surface-200 border-t -mt-px">
|
||||
<QueryPerformanceFilterBar
|
||||
queryPerformanceQuery={queryPerformanceQuery}
|
||||
onResetReportClick={() => {
|
||||
setShowResetgPgStatStatements(true)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<QueryPerformanceFilterBar
|
||||
queryPerformanceQuery={queryPerformanceQuery}
|
||||
onResetReportClick={() => setShowResetgPgStatStatements(true)}
|
||||
/>
|
||||
<LoadingLine loading={isLoading || isRefetching} />
|
||||
|
||||
<QueryPerformanceGrid queryPerformanceQuery={queryPerformanceQuery} />
|
||||
|
||||
@@ -210,9 +210,7 @@ export const QueryPerformance = ({
|
||||
className="absolute top-1.5 right-3 px-1.5"
|
||||
type="text"
|
||||
size="tiny"
|
||||
onClick={() => {
|
||||
setShowBottomSection(false)
|
||||
}}
|
||||
onClick={() => setShowBottomSection(false)}
|
||||
>
|
||||
<X size="14" />
|
||||
</Button>
|
||||
|
||||
@@ -2,7 +2,9 @@ import { ArrowDown, ArrowUp, RefreshCw } from 'lucide-react'
|
||||
import { useRouter } from 'next/router'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { DownloadResultsButton } from 'components/ui/DownloadResultsButton'
|
||||
import { FilterPopover } from 'components/ui/FilterPopover'
|
||||
import { useDatabaseRolesQuery } from 'data/database-roles/database-roles-query'
|
||||
import { DbQueryHook } from 'hooks/analytics/useDbQuery'
|
||||
@@ -27,6 +29,7 @@ export const QueryPerformanceFilterBar = ({
|
||||
onResetReportClick?: () => void
|
||||
}) => {
|
||||
const router = useRouter()
|
||||
const { ref } = useParams()
|
||||
const { project } = useProjectContext()
|
||||
const [showBottomSection] = useLocalStorageQuery(
|
||||
LOCAL_STORAGE_KEYS.QUERY_PERF_SHOW_BOTTOM_SECTION,
|
||||
@@ -86,7 +89,7 @@ export const QueryPerformanceFilterBar = ({
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="px-6 py-2 bg-surface-200 border-t -mt-px flex justify-between items-center">
|
||||
<div className="flex items-center gap-x-4">
|
||||
<div className="flex items-center gap-x-2">
|
||||
<p className="text-xs prose">Filter by</p>
|
||||
@@ -126,29 +129,27 @@ export const QueryPerformanceFilterBar = ({
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
{!showBottomSection && onResetReportClick && (
|
||||
<Button
|
||||
onClick={() => {
|
||||
onResetReportClick()
|
||||
}}
|
||||
type="default"
|
||||
>
|
||||
<Button type="default" onClick={() => onResetReportClick()}>
|
||||
Reset report
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="default"
|
||||
size="tiny"
|
||||
type="default"
|
||||
onClick={() => queryPerformanceQuery.runQuery()}
|
||||
disabled={isLoading || isRefetching}
|
||||
icon={
|
||||
<RefreshCw
|
||||
size={12}
|
||||
className={`text-foreground-light ${isLoading || isRefetching ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
<DownloadResultsButton
|
||||
results={queryPerformanceQuery.data ?? []}
|
||||
fileName={`Supabase Query Performance (${ref})`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
import { compact, isObject, isString, map } from 'lodash'
|
||||
import { ChevronDownIcon, Clipboard, Download } from 'lucide-react'
|
||||
import { markdownTable } from 'markdown-table'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { CSVLink } from 'react-csv'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { TelemetryActions } from 'common/telemetry-constants'
|
||||
import { useProjectContext } from 'components/layouts/ProjectLayout/ProjectContext'
|
||||
import { useSendEventMutation } from 'data/telemetry/send-event-mutation'
|
||||
import { useSelectedOrganization } from 'hooks/misc/useSelectedOrganization'
|
||||
import { copyToClipboard } from 'lib/helpers'
|
||||
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
|
||||
export type ResultsDropdownProps = {
|
||||
id: string
|
||||
}
|
||||
|
||||
const ResultsDropdown = ({ id }: ResultsDropdownProps) => {
|
||||
const { project } = useProjectContext()
|
||||
const snapV2 = useSqlEditorV2StateSnapshot()
|
||||
|
||||
const result = snapV2.results?.[id]?.[0] ?? undefined
|
||||
const csvRef = useRef<CSVLink & HTMLAnchorElement & { link: HTMLAnchorElement }>(null)
|
||||
|
||||
const { ref } = useParams()
|
||||
const org = useSelectedOrganization()
|
||||
const { mutate: sendEvent } = useSendEventMutation()
|
||||
|
||||
const csvData = useMemo(() => {
|
||||
if (result?.rows) {
|
||||
const rows = Array.from(result.rows || []).map((row) => {
|
||||
return map(row, (v, k) => {
|
||||
if (isString(v)) {
|
||||
// replace all newlines with the character \n
|
||||
// escape all quotation marks
|
||||
return v.replaceAll(/\n/g, '\\n').replaceAll(/"/g, '""')
|
||||
}
|
||||
if (isObject(v)) {
|
||||
// replace all quotation marks with two quotation marks to escape them.
|
||||
return JSON.stringify(v).replaceAll(/\"/g, '""')
|
||||
}
|
||||
return v
|
||||
})
|
||||
})
|
||||
|
||||
return compact(rows)
|
||||
}
|
||||
return ''
|
||||
}, [result])
|
||||
|
||||
const headers = useMemo(() => {
|
||||
if (result?.rows) {
|
||||
const firstRow = Array.from(result.rows || [])[0]
|
||||
if (firstRow) {
|
||||
return Object.keys(firstRow)
|
||||
}
|
||||
}
|
||||
// if undefined is returned no headers will be set. In this case, no headers would be better
|
||||
// than malformed headers.
|
||||
return undefined
|
||||
}, [result])
|
||||
|
||||
function onDownloadCSV() {
|
||||
csvRef.current?.link.click()
|
||||
sendEvent({
|
||||
action: TelemetryActions.SQL_EDITOR_RESULT_DOWNLOAD_CSV_CLICKED,
|
||||
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
|
||||
})
|
||||
}
|
||||
|
||||
function onCopyAsMarkdown() {
|
||||
if (navigator) {
|
||||
if (!result || !result.rows) return 'results is empty'
|
||||
if (result.rows.constructor !== Array && !!result.error) return result.error
|
||||
if (result.rows.length == 0) return 'results is empty'
|
||||
|
||||
const columns = Object.keys(result.rows[0])
|
||||
const rows = result.rows.map((x) => {
|
||||
let temp: any[] = []
|
||||
columns.forEach((col) => temp.push(x[col]))
|
||||
return temp
|
||||
})
|
||||
const table = [columns].concat(rows)
|
||||
const markdownData = markdownTable(table)
|
||||
|
||||
copyToClipboard(markdownData, () => {
|
||||
toast.success('Copied results to clipboard')
|
||||
sendEvent({
|
||||
action: TelemetryActions.SQL_EDITOR_RESULT_COPY_MARKDOWN_CLICKED,
|
||||
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
function onCopyAsJSON() {
|
||||
if (navigator) {
|
||||
if (!result || !result.rows) return 'results is empty'
|
||||
if (result.rows.constructor !== Array && !!result.error) return result.error
|
||||
if (result.rows.length == 0) return 'results is empty'
|
||||
|
||||
copyToClipboard(JSON.stringify(result.rows, null, 2), () => {
|
||||
toast.success('Copied results to clipboard')
|
||||
sendEvent({
|
||||
action: TelemetryActions.SQL_EDITOR_RESULT_COPY_JSON_CLICKED,
|
||||
groups: { project: ref ?? 'Unknown', organization: org?.slug ?? 'Unknown' },
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type="text" iconRight={<ChevronDownIcon size={14} />}>
|
||||
Export
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
<CSVLink
|
||||
ref={csvRef}
|
||||
className="hidden"
|
||||
headers={headers}
|
||||
data={csvData}
|
||||
filename={`supabase_${project?.ref}_${snapV2.snippets[id]?.snippet.name}.csv`}
|
||||
/>
|
||||
|
||||
<DropdownMenuContent side="bottom" align="start">
|
||||
<DropdownMenuItem onClick={onDownloadCSV} className="space-x-2">
|
||||
<Download size={14} />
|
||||
<p>Download CSV</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onCopyAsMarkdown} className="space-x-2">
|
||||
<Clipboard size={14} />
|
||||
<p>Copy as markdown</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={onCopyAsJSON} className="space-x-2">
|
||||
<Clipboard size={14} />
|
||||
<p>Copy as JSON</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
|
||||
export default ResultsDropdown
|
||||
@@ -1,12 +1,12 @@
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { useParams } from 'common'
|
||||
import { DownloadResultsButton } from 'components/ui/DownloadResultsButton'
|
||||
import { useContentUpsertMutation } from 'data/content/content-upsert-mutation'
|
||||
import { Snippet } from 'data/content/sql-folders-query'
|
||||
import { useSqlEditorV2StateSnapshot } from 'state/sql-editor-v2'
|
||||
import { TabsContent_Shadcn_, TabsList_Shadcn_, TabsTrigger_Shadcn_, Tabs_Shadcn_ } from 'ui'
|
||||
import { ChartConfig } from './ChartConfig'
|
||||
import ResultsDropdown from './ResultsDropdown'
|
||||
import UtilityActions from './UtilityActions'
|
||||
import UtilityTabResults from './UtilityTabResults'
|
||||
|
||||
@@ -114,7 +114,13 @@ const UtilityPanel = ({
|
||||
<TabsTrigger_Shadcn_ className="py-3 text-xs" value="chart">
|
||||
<span className="translate-y-[1px]">Chart</span>
|
||||
</TabsTrigger_Shadcn_>
|
||||
{result?.rows && <ResultsDropdown id={id} />}
|
||||
{result?.rows && (
|
||||
<DownloadResultsButton
|
||||
type="text"
|
||||
results={result.rows as any[]}
|
||||
fileName={`Supabase Snippet ${snippet.name}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<UtilityActions
|
||||
id={id}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { ChevronDown, Clipboard, Download } from 'lucide-react'
|
||||
import { markdownTable } from 'markdown-table'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import { CSVLink } from 'react-csv'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
import { copyToClipboard } from 'lib/helpers'
|
||||
import {
|
||||
Button,
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from 'ui'
|
||||
|
||||
interface DownloadResultsButtonProps {
|
||||
type?: 'text' | 'default'
|
||||
align?: 'start' | 'center' | 'end'
|
||||
results: any[]
|
||||
fileName: string
|
||||
onCopyAsMarkdown?: () => void
|
||||
onCopyAsJSON?: () => void
|
||||
}
|
||||
|
||||
export const DownloadResultsButton = ({
|
||||
type = 'default',
|
||||
align = 'start',
|
||||
results,
|
||||
fileName,
|
||||
onCopyAsMarkdown,
|
||||
onCopyAsJSON,
|
||||
}: DownloadResultsButtonProps) => {
|
||||
const csvRef = useRef<CSVLink & HTMLAnchorElement & { link: HTMLAnchorElement }>(null)
|
||||
|
||||
const headers = useMemo(() => {
|
||||
if (results) {
|
||||
const firstRow = Array.from(results)[0]
|
||||
if (firstRow) return Object.keys(firstRow)
|
||||
}
|
||||
return undefined
|
||||
}, [results])
|
||||
|
||||
const copyAsMarkdown = () => {
|
||||
if (navigator) {
|
||||
if (results.length == 0) toast('Results are empty')
|
||||
|
||||
const columns = Object.keys(results[0])
|
||||
const rows = results.map((x) => {
|
||||
let temp: any[] = []
|
||||
columns.forEach((col) => temp.push(x[col]))
|
||||
return temp
|
||||
})
|
||||
const table = [columns].concat(rows)
|
||||
const markdownData = markdownTable(table)
|
||||
|
||||
copyToClipboard(markdownData, () => {
|
||||
toast.success('Copied results to clipboard')
|
||||
onCopyAsMarkdown?.()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const copyAsJSON = () => {
|
||||
if (navigator) {
|
||||
if (results.length === 0) return toast('Results are empty')
|
||||
copyToClipboard(JSON.stringify(results, null, 2), () => {
|
||||
toast.success('Copied results to clipboard')
|
||||
onCopyAsJSON?.()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button type={type} iconRight={<ChevronDown />} disabled={results.length === 0}>
|
||||
Export
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align={align} className="w-44">
|
||||
<DropdownMenuItem className="gap-x-2" onClick={() => csvRef.current?.link.click()}>
|
||||
<Download size={14} />
|
||||
<p>Download CSV</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={copyAsMarkdown} className="gap-x-2">
|
||||
<Clipboard size={14} />
|
||||
<p>Copy as markdown</p>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={copyAsJSON} className="gap-x-2">
|
||||
<Clipboard size={14} />
|
||||
<p>Copy as JSON</p>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<CSVLink
|
||||
ref={csvRef}
|
||||
className="hidden"
|
||||
headers={headers}
|
||||
data={results}
|
||||
filename={`${fileName}.csv`}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ interface FilterPopoverProps<T> {
|
||||
labelClass?: string
|
||||
maxHeightClass?: string
|
||||
clearButtonText?: string
|
||||
className?: string
|
||||
onSaveFilters: (options: string[]) => void
|
||||
}
|
||||
|
||||
@@ -39,6 +40,7 @@ export const FilterPopover = <T extends Record<string, any>>({
|
||||
buttonType,
|
||||
disabled,
|
||||
labelClass,
|
||||
className,
|
||||
maxHeightClass = 'h-[205px]',
|
||||
clearButtonText = 'Clear',
|
||||
onSaveFilters,
|
||||
@@ -81,7 +83,7 @@ export const FilterPopover = <T extends Record<string, any>>({
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger_Shadcn_>
|
||||
<PopoverContent_Shadcn_ className="p-0 w-44" align="start">
|
||||
<PopoverContent_Shadcn_ className={cn('p-0 w-44', className)} align="start">
|
||||
<div className="border-b border-overlay bg-surface-200 rounded-t pb-1 px-3">
|
||||
<span className="text-xs text-foreground-light">
|
||||
{title ?? `Select ${name.toLowerCase()}`}
|
||||
@@ -108,7 +110,7 @@ export const FilterPopover = <T extends Record<string, any>>({
|
||||
/>
|
||||
<Label_Shadcn_
|
||||
htmlFor={option[valueKey]}
|
||||
className={cn('flex items-center gap-x-2', labelClass)}
|
||||
className={cn('flex items-center gap-x-2 text-xs', labelClass)}
|
||||
>
|
||||
{icon && (
|
||||
<img
|
||||
|
||||
@@ -16,7 +16,7 @@ import { LoadingLine } from 'ui'
|
||||
|
||||
const ProjectLints: NextPageWithLayout = () => {
|
||||
const project = useSelectedProject()
|
||||
const { preset, id } = useParams()
|
||||
const { ref, preset, id } = useParams()
|
||||
|
||||
// need to maintain a list of filters for each tab
|
||||
const [filters, setFilters] = useState<{ level: LINTER_LEVELS; filters: string[] }[]>([
|
||||
@@ -24,7 +24,6 @@ const ProjectLints: NextPageWithLayout = () => {
|
||||
{ level: LINTER_LEVELS.WARN, filters: [] },
|
||||
{ level: LINTER_LEVELS.INFO, filters: [] },
|
||||
])
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<LINTER_LEVELS>(
|
||||
(preset as LINTER_LEVELS) ?? LINTER_LEVELS.ERROR
|
||||
)
|
||||
@@ -34,25 +33,15 @@ const ProjectLints: NextPageWithLayout = () => {
|
||||
projectRef: project?.ref,
|
||||
})
|
||||
|
||||
let clientLints: Lint[] = []
|
||||
|
||||
const activeLints = useMemo(() => {
|
||||
return [...(data ?? []), ...clientLints]?.filter((x) => x.categories.includes('PERFORMANCE'))
|
||||
return [...(data ?? [])]?.filter((x) => x.categories.includes('PERFORMANCE'))
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [data])
|
||||
|
||||
useEffect(() => {
|
||||
// check the URL for an ID and set the selected lint
|
||||
if (id) setSelectedLint(activeLints.find((lint) => lint.cache_key === id) ?? null)
|
||||
}, [id, activeLints])
|
||||
|
||||
const currentTabFilters = (filters.find((filter) => filter.level === currentTab)?.filters ||
|
||||
[]) as string[]
|
||||
|
||||
const filteredLints = activeLints
|
||||
.filter((x) => x.level === currentTab)
|
||||
.filter((x) => (currentTabFilters.length > 0 ? currentTabFilters.includes(x.name) : x))
|
||||
|
||||
const filterOptions = lintInfoMap
|
||||
// only show filters for lint types which are present in the results and not ignored
|
||||
.filter((item) =>
|
||||
@@ -63,6 +52,11 @@ 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)
|
||||
}, [id, activeLints])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<FormHeader
|
||||
@@ -80,9 +74,12 @@ const ProjectLints: NextPageWithLayout = () => {
|
||||
<LinterFilters
|
||||
filterOptions={filterOptions}
|
||||
activeLints={activeLints}
|
||||
filteredLints={filteredLints}
|
||||
currentTab={currentTab}
|
||||
filters={filters}
|
||||
isLoading={isLoading || isRefetching}
|
||||
setFilters={setFilters}
|
||||
onClickRefresh={refetch}
|
||||
/>
|
||||
<LoadingLine loading={isRefetching} />
|
||||
<LinterDataGrid
|
||||
|
||||
@@ -16,7 +16,7 @@ import { LoadingLine } from 'ui'
|
||||
|
||||
const ProjectLints: NextPageWithLayout = () => {
|
||||
const project = useSelectedProject()
|
||||
const { preset, id } = useParams()
|
||||
const { ref, preset, id } = useParams()
|
||||
|
||||
// need to maintain a list of filters for each tab
|
||||
const [filters, setFilters] = useState<{ level: LINTER_LEVELS; filters: string[] }[]>([
|
||||
@@ -24,42 +24,21 @@ const ProjectLints: NextPageWithLayout = () => {
|
||||
{ level: LINTER_LEVELS.WARN, filters: [] },
|
||||
{ level: LINTER_LEVELS.INFO, filters: [] },
|
||||
])
|
||||
|
||||
const [currentTab, setCurrentTab] = useState<LINTER_LEVELS>(
|
||||
(preset as LINTER_LEVELS) ?? LINTER_LEVELS.ERROR
|
||||
)
|
||||
|
||||
const [selectedLint, setSelectedLint] = useState<Lint | null>(null)
|
||||
|
||||
const {
|
||||
data,
|
||||
isLoading: areLintsLoading,
|
||||
isRefetching,
|
||||
refetch: refetchLintsQuery,
|
||||
} = useProjectLintsQuery({
|
||||
const { data, isLoading, isRefetching, refetch } = useProjectLintsQuery({
|
||||
projectRef: project?.ref,
|
||||
})
|
||||
|
||||
const isLoading = areLintsLoading
|
||||
|
||||
const refetch = () => {
|
||||
refetchLintsQuery()
|
||||
}
|
||||
|
||||
const activeLints = (data ?? []).filter((lint) => lint.categories.includes('SECURITY'))
|
||||
|
||||
useEffect(() => {
|
||||
// check the URL for an ID and set the selected lint
|
||||
if (id) setSelectedLint(activeLints.find((lint) => lint.cache_key === id) ?? null)
|
||||
}, [id, activeLints])
|
||||
|
||||
const currentTabFilters = (filters.find((filter) => filter.level === currentTab)?.filters ||
|
||||
[]) as string[]
|
||||
|
||||
const filteredLints = activeLints
|
||||
.filter((x) => x.level === currentTab)
|
||||
.filter((x) => (currentTabFilters.length > 0 ? currentTabFilters.includes(x.name) : x))
|
||||
|
||||
const filterOptions = lintInfoMap
|
||||
// only show filters for lint types which are present in the results and not ignored
|
||||
.filter((item) =>
|
||||
@@ -70,6 +49,11 @@ 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)
|
||||
}, [id, activeLints])
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<FormHeader
|
||||
@@ -86,10 +70,13 @@ const ProjectLints: NextPageWithLayout = () => {
|
||||
/>
|
||||
<LinterFilters
|
||||
filterOptions={filterOptions}
|
||||
filteredLints={filteredLints}
|
||||
activeLints={activeLints}
|
||||
currentTab={currentTab}
|
||||
filters={filters}
|
||||
isLoading={isLoading || isRefetching}
|
||||
setFilters={setFilters}
|
||||
onClickRefresh={refetch}
|
||||
/>
|
||||
<LoadingLine loading={isRefetching} />
|
||||
<LinterDataGrid
|
||||
|
||||
Reference in new issue
Block a user