diff --git a/frontend/src/components/layout/header.tsx b/frontend/src/components/layout/header.tsx index c8ab1d1f37..ffcb00358d 100644 --- a/frontend/src/components/layout/header.tsx +++ b/frontend/src/components/layout/header.tsx @@ -44,7 +44,7 @@ function BreadcrumbHeaderRow({ useNewSidebar, breadcrumbItems }: BreadcrumbHeade {useNewSidebar ? ( <> - + ) : null} {isEmbedded() ? null : ( diff --git a/frontend/src/components/pages/quotas/quotas-list.tsx b/frontend/src/components/pages/quotas/quotas-list.tsx index b78bf6b226..28914e5e1a 100644 --- a/frontend/src/components/pages/quotas/quotas-list.tsx +++ b/frontend/src/components/pages/quotas/quotas-list.tsx @@ -32,7 +32,6 @@ import { EmptyHeader, EmptyTitle, } from 'components/redpanda-ui/components/empty'; -import { ListLayoutPagination } from 'components/redpanda-ui/components/list-layout'; import { Skeleton } from 'components/redpanda-ui/components/skeleton'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'components/redpanda-ui/components/table'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; @@ -317,9 +316,7 @@ const QuotasList = () => { - - - + ); diff --git a/frontend/src/components/pages/schemas/schema-context-selector.tsx b/frontend/src/components/pages/schemas/schema-context-selector.tsx index 656a56830d..cae25fdbab 100644 --- a/frontend/src/components/pages/schemas/schema-context-selector.tsx +++ b/frontend/src/components/pages/schemas/schema-context-selector.tsx @@ -9,6 +9,7 @@ * by the Apache License, Version 2.0 */ +import { Badge } from 'components/redpanda-ui/components/badge'; import { Button } from 'components/redpanda-ui/components/button'; import { Command, @@ -18,9 +19,10 @@ import { CommandList, } from 'components/redpanda-ui/components/command'; import { Popover, PopoverContent, PopoverTrigger } from 'components/redpanda-ui/components/popover'; +import { Separator } from 'components/redpanda-ui/components/separator'; import { Tooltip, TooltipContent, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; import { cn } from 'components/redpanda-ui/lib/utils'; -import { CheckIcon, ChevronDownIcon, InfoIcon } from 'lucide-react'; +import { CheckIcon, InfoIcon } from 'lucide-react'; import type { FC } from 'react'; import { useState } from 'react'; @@ -44,32 +46,16 @@ export const SchemaContextSelector: FC = ({ const selectedLabel = contexts.find((c) => c.id === selectedContext)?.label ?? 'All'; return ( -
-
- Context - - - - - } - /> - - Schema Registry contexts allow grouping subjects into isolated namespaces - - -
+
- {selectedLabel} - + } /> @@ -101,6 +87,18 @@ export const SchemaContextSelector: FC = ({ + + + + + } + /> + + Schema Registry contexts allow grouping subjects into isolated namespaces + +
); }; diff --git a/frontend/src/components/pages/schemas/schema-create.tsx b/frontend/src/components/pages/schemas/schema-create.tsx index 2ecae1ff1e..e73af4d70c 100644 --- a/frontend/src/components/pages/schemas/schema-create.tsx +++ b/frontend/src/components/pages/schemas/schema-create.tsx @@ -533,6 +533,8 @@ const SchemaEditor = (p: { const [contextWarning, setContextWarning] = useState(''); const [switchFormatOpen, setSwitchFormatOpen] = useState(false); const [pendingFormat, setPendingFormat] = useState(null); + // Only surface "required" errors after the user has interacted with a field, not on mount. + const [touched, setTouched] = useState<{ context: boolean; topic: boolean }>({ context: false, topic: false }); const availableContexts = useMemo(() => { if (!(srContextsEnabled && apiContexts && subjects)) return []; @@ -575,7 +577,7 @@ const SchemaEditor = (p: { )}
{srContextsEnabled && !isAddVersion && ( - + Context Select an existing context or type a new name to create one. { const contextId = contextLabelToId(value); + setTouched((t) => ({ ...t, context: true })); setContextWarning(''); p.onStateChange((prev) => ({ ...prev, @@ -605,7 +608,7 @@ const SchemaEditor = (p: { value={contextIdToLabel(state.context)} /> {contextWarning &&
{contextWarning}
} - {!state.context && Context is required} + {touched.context && !state.context && Context is required}
)} @@ -627,6 +630,7 @@ const SchemaEditor = (p: { CUSTOM: 'Custom', }} onValueChange={(e) => { + setTouched((t) => ({ ...t, topic: false })); p.onStateChange((prev) => ({ ...prev, userInput: '', strategy: e as NamingStrategy })); }} value={state.strategy} @@ -644,11 +648,12 @@ const SchemaEditor = (p: {
{showTopicNameInput && ( - + Topic name - {!state.userInput && Topic name is required} + {touched.topic && !state.userInput && Topic name is required} )} diff --git a/frontend/src/components/pages/schemas/schema-list.tsx b/frontend/src/components/pages/schemas/schema-list.tsx index 3ff7fc0f5a..fbbc92cd2f 100644 --- a/frontend/src/components/pages/schemas/schema-list.tsx +++ b/frontend/src/components/pages/schemas/schema-list.tsx @@ -12,30 +12,44 @@ import { Link } from '@tanstack/react-router'; import { type ColumnDef, + type ColumnFiltersState, + type FilterFn, flexRender, getCoreRowModel, + getFacetedRowModel, + getFacetedUniqueValues, + getFilteredRowModel, getPaginationRowModel, getSortedRowModel, type PaginationState, type Updater, useReactTable, } from '@tanstack/react-table'; -import { ArchiveIcon, EditIcon, InfoIcon, TrashIcon } from 'components/icons'; +import { ArchiveIcon, EditIcon, MoreHorizontalIcon, TrashIcon } from 'components/icons'; +import { DescriptionWithHelp } from 'components/pages/security/shared/description-with-help'; import { Alert, AlertTitle } from 'components/redpanda-ui/components/alert'; import { Badge } from 'components/redpanda-ui/components/badge'; import { Button } from 'components/redpanda-ui/components/button'; import { Checkbox } from 'components/redpanda-ui/components/checkbox'; -import { DataTableColumnHeader } from 'components/redpanda-ui/components/data-table'; -import { Drawer, DrawerContent, DrawerHeader, DrawerTitle } from 'components/redpanda-ui/components/drawer'; -import { Input } from 'components/redpanda-ui/components/input'; -import { Label } from 'components/redpanda-ui/components/label'; -import { Separator } from 'components/redpanda-ui/components/separator'; +import { + DataTableColumnHeader, + DataTableFacetedFilter, + DataTablePagination, +} from 'components/redpanda-ui/components/data-table'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from 'components/redpanda-ui/components/dropdown-menu'; +import { ListLayout, ListLayoutFilters, ListLayoutSearchInput } from 'components/redpanda-ui/components/list-layout'; import { Skeleton } from 'components/redpanda-ui/components/skeleton'; import { Spinner } from 'components/redpanda-ui/components/spinner'; +import { Stat, StatGroup } from 'components/redpanda-ui/components/stat'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from 'components/redpanda-ui/components/table'; import { Tooltip, TooltipContent, TooltipTrigger } from 'components/redpanda-ui/components/tooltip'; -import { ChevronLeftIcon, ChevronRightIcon, ChevronsLeftIcon, ChevronsRightIcon, SearchIcon } from 'lucide-react'; -import { parseAsBoolean, parseAsInteger, parseAsString, useQueryState } from 'nuqs'; +import { SearchIcon } from 'lucide-react'; +import { parseAsArrayOf, parseAsBoolean, parseAsInteger, parseAsString, useQueryState } from 'nuqs'; import type { FC } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react'; import { toast } from 'sonner'; @@ -57,21 +71,24 @@ import { useDeleteSchemaSubjectMutation, useListSchemasQuery, useSchemaCompatibilityQuery, - useSchemaDetailsQuery, + useSchemaDetailsByNameQuery, useSchemaModeQuery, useSchemaRegistryContextsQuery, useSchemaUsagesByIdQuery, } from '../../../react-query/api/schema-registry'; import { appGlobal } from '../../../state/app-global'; import { api } from '../../../state/backend-api'; -import type { SchemaRegistrySubject } from '../../../state/rest-interfaces'; +import { + SchemaRegistryCompatibilityModes, + type SchemaRegistrySubject, + type SchemaRegistrySubjectDetails, + SchemaType, +} from '../../../state/rest-interfaces'; import { useSupportedFeaturesStore } from '../../../state/supported-features'; import { uiSettings } from '../../../state/ui'; import { setPageHeader } from '../../../state/ui-state'; import { encodeURIComponentPercents } from '../../../utils/utils'; import PageContent from '../../misc/page-content'; -import Section from '../../misc/section'; -import { SmallStat } from '../../misc/small-stat'; const RequestErrors: FC<{ requestErrors?: string[] }> = ({ requestErrors }) => { if (!requestErrors || requestErrors.length === 0) { @@ -79,13 +96,13 @@ const RequestErrors: FC<{ requestErrors?: string[] }> = ({ requestErrors }) => { } return ( -
+
{requestErrors.map((errorMessage) => ( {errorMessage} ))} -
+
); }; @@ -95,9 +112,34 @@ const SCHEMA_TYPE_BADGE_VARIANT: Record = { JSON: 'warning-inverted', }; +const SCHEMA_TYPE_FILTER_OPTIONS = [ + { label: 'Avro', value: SchemaType.AVRO }, + { label: 'Protobuf', value: SchemaType.PROTOBUF }, + { label: 'JSON', value: SchemaType.JSON }, +]; + +const SCHEMA_COMPATIBILITY_FILTER_OPTIONS = Object.values(SchemaRegistryCompatibilityModes).map((mode) => ({ + label: mode, + value: mode, +})); + +// Subject rows enriched with per-subject details so type/compatibility can be filtered list-wide. +type EnrichedSubject = SchemaRegistrySubject & { + details?: SchemaRegistrySubjectDetails; + detailsLoading: boolean; +}; + +// Faceted filters set an array of selected values; match rows whose column value is one of them. +const multiSelectFilterFn: FilterFn = (row, columnId, filterValue) => { + const selected = filterValue as string[] | undefined; + if (!selected?.length) { + return true; + } + return selected.includes(String(row.getValue(columnId))); +}; + const SchemaList: FC = () => { const schemaRegistryContextsSupported = useSupportedFeaturesStore((s) => s.schemaRegistryContexts); - const [isHelpSidebarOpen, setIsHelpSidebarOpen] = useState(false); const [quickSearch, setQuickSearch] = useQueryState('q', parseAsString.withDefault('')); const [showSoftDeleted, setShowSoftDeleted] = useQueryStateWithCallback( @@ -119,6 +161,11 @@ const SchemaList: FC = () => { const deleteSchemaMutation = useDeleteSchemaSubjectMutation(); const [deleteTarget, setDeleteTarget] = useState<{ kind: 'soft' | 'permanent'; name: string } | null>(null); const [sorting, setSorting] = useQueryState('sort', sortingParser.withDefault([])); + const [typeFilter, setTypeFilter] = useQueryState('type', parseAsArrayOf(parseAsString).withDefault([])); + const [compatibilityFilter, setCompatibilityFilter] = useQueryState( + 'compatibility', + parseAsArrayOf(parseAsString).withDefault([]) + ); const [pageIndex, setPageIndex] = useQueryState('page', parseAsInteger.withDefault(0)); const [pageSize, setPageSize] = useQueryState('pageSize', parseAsInteger.withDefault(10)); @@ -133,6 +180,30 @@ const SchemaList: FC = () => { [pagination, setPageIndex, setPageSize] ); + // Faceted filter state lives in the URL, one param per column, so a filtered view + // survives a reload and can be shared — same as the search, context and page params. + const columnFilters = useMemo(() => { + const filters: ColumnFiltersState = []; + if (typeFilter.length > 0) { + filters.push({ id: 'type', value: typeFilter }); + } + if (compatibilityFilter.length > 0) { + filters.push({ id: 'compatibility', value: compatibilityFilter }); + } + return filters; + }, [typeFilter, compatibilityFilter]); + + const handleColumnFiltersChange = useCallback( + (updater: Updater) => { + const next = typeof updater === 'function' ? updater(columnFilters) : updater; + const valuesFor = (id: string) => (next.find((filter) => filter.id === id)?.value as string[] | undefined) ?? []; + setTypeFilter(valuesFor('type')); + setCompatibilityFilter(valuesFor('compatibility')); + setPageIndex(0); + }, + [columnFilters, setTypeFilter, setCompatibilityFilter, setPageIndex] + ); + // Parse schema ID from search query const schemaIdSearch = useMemo(() => { const trimmedValue = quickSearch.trim(); @@ -142,6 +213,9 @@ const SchemaList: FC = () => { const { data: schemaUsages, isLoading: isLoadingSchemaVersionMatches } = useSchemaUsagesByIdQuery(schemaIdSearch); + const subjectNames = useMemo(() => (schemaSubjects ?? []).map((s) => s.name), [schemaSubjects]); + const detailsByName = useSchemaDetailsByNameQuery(subjectNames); + const refreshData = useCallback(() => { refetchMode(); refetchCompatibility(); @@ -227,8 +301,17 @@ const SchemaList: FC = () => { return subjects; }, [schemaSubjects, quickSearch, showSoftDeleted, schemaUsages, schemaRegistryContextsSupported, selectedContext]); - // biome-ignore lint/correctness/useExhaustiveDependencies: isAllContext and selectedContext drive conditional columns - const columns = useMemo[]>( + const enrichedSubjects = useMemo( + () => + filteredSubjects.map((subject) => ({ + ...subject, + details: detailsByName[subject.name]?.data, + detailsLoading: detailsByName[subject.name]?.isLoading ?? false, + })), + [filteredSubjects, detailsByName] + ); + + const columns = useMemo[]>( () => [ { accessorKey: 'name', @@ -249,7 +332,7 @@ const SchemaList: FC = () => { to="/schema-registry/subjects/$subjectName" > {isAllContext && parsed.context !== 'default' && ( - :.{parsed.context}: + :.{parsed.context}: )} {isAllContext || isNamedContext(selectedContext) ? parsed.displayName : name} @@ -258,7 +341,7 @@ const SchemaList: FC = () => { -
- {schemaRegistryContextsSupported && ( - { - setSelectedContext(ctx); - setPageIndex(0); - }} - selectedContext={selectedContext} + testId="schema-list-compatibility-stat" + value={ +
+ {displayCompat ?? } + + + isNamedContext(selectedContext) && schemaRegistryContextsSupported + ? appGlobal.historyPush( + `/schema-registry/contexts/${encodeURIComponent(selectedContext)}/edit-compatibility` + ) + : appGlobal.historyPush('/schema-registry/edit-compatibility') + } + size="icon-xs" + variant="secondary-ghost" + > + + + } + /> + {api.userData?.canManageSchemaRegistry === false && ( + You don't have the 'canManageSchemaRegistry' permission + )} + +
+ } /> - )} -
- - - isNamedContext(selectedContext) && schemaRegistryContextsSupported - ? appGlobal.historyPush(`/schema-registry/contexts/${encodeURIComponent(selectedContext)}/create`) - : appGlobal.historyPush('/schema-registry/create') - } - variant="primary" - > - Create new schema - - } - /> - {api.userData?.canCreateSchemas === false && ( - You don't have the 'canCreateSchemas' permission - )} - -
+ - - - - - Schema Search Help - - - -
-
-

- Filtering schemas -

-

- There are two ways to filter schemas, and they work a little differently. -

-
- -
-
-

- Schema ID -

-

- If a number matches a schema ID, the results include all subjects referencing that schema. -

-
- -
-

- Subject name + {isError ? ( + + Error loading schemas + + ) : ( + +
+ +
+

+ Filtering schemas

-

- To search subject names, enter that specific name or a regex. +

+ There are two ways to filter schemas, and they work a little differently.

-
-

- - - {(() => { - if (isLoading) { - return ( -
- -
- ); - } - - if (isError) { - return ( -
- - Error loading schemas - -
- ); - } - return ( -
-
-
-
-
- - setIsHelpSidebarOpen(true)} - type="button" - > - - {isLoadingSchemaVersionMatches && } +
+
+

+ Schema ID +

+

+ If a number matches a schema ID, the results include all subjects referencing that schema. +

+
+ +
+

+ Subject name +

+

+ To search subject names, enter that specific name or a regex. +

+
-
- { - setShowSoftDeleted(checked === true); - setPageIndex(0); - }} - testId="schema-list-show-soft-deleted-checkbox" + +
+ + + isNamedContext(selectedContext) && schemaRegistryContextsSupported + ? appGlobal.historyPush( + `/schema-registry/contexts/${encodeURIComponent(selectedContext)}/create` + ) + : appGlobal.historyPush('/schema-registry/create') + } + variant="primary" + > + Create new schema + + } /> - -
+ {api.userData?.canCreateSchemas === false && ( + You don't have the 'canCreateSchemas' permission + )} + + } + > +
+
- - - {table.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - + {isLoadingSchemaVersionMatches && } + {schemaRegistryContextsSupported && ( + { + setSelectedContext(ctx); + setPageIndex(0); + }} + selectedContext={selectedContext} + /> + )} + + + + +
+ + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => { + const meta = header.column.columnDef.meta as { align?: 'right' } | undefined; + return ( + {header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext())} - ))} - - ))} - - - {table.getRowModel().rows.length ? ( - table.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - {flexRender(cell.column.columnDef.cell, cell.getContext())} - ))} - - )) - ) : ( - - - No schemas found. - - - )} - -
-
-
-
- Rows per page - -
-
- Page {table.getState().pagination.pageIndex + 1} of {table.getPageCount()} -
-
- - - - -
-
-
-
- ); - })()} + ); + })} + + ))} + + {renderBody()} + + + + )} { if (!deleteTarget) return; @@ -714,54 +792,4 @@ const SchemaList: FC = () => { ); }; -const SchemaTypeColumn: FC<{ name: string }> = ({ name }) => { - const { data: details, isLoading } = useSchemaDetailsQuery(name); - - if (isLoading || !details) { - return ; - } - - const variant = SCHEMA_TYPE_BADGE_VARIANT[details.type] ?? 'neutral-inverted'; - - return ( - - {details.type} - - ); -}; - -const SchemaCompatibilityColumn: FC<{ name: string }> = ({ name }) => { - const { data: details, isLoading } = useSchemaDetailsQuery(name); - - if (isLoading || !details) { - return ; - } - - return <>{details.compatibility}; -}; - -const SchemaModeColumn: FC<{ name: string }> = ({ name }) => { - const { data: details, isLoading } = useSchemaDetailsQuery(name); - - if (isLoading || !details) { - return ; - } - - return <>{details.mode}; -}; - -const LatestVersionColumn: FC<{ name: string }> = ({ name }) => { - const { data: details, isLoading } = useSchemaDetailsQuery(name); - - if (isLoading || !details) { - return ; - } - - if (details.latestActiveVersion < 0) { - return None; - } - - return <>{details.latestActiveVersion}; -}; - export default SchemaList; diff --git a/frontend/src/components/pages/security/shared/description-with-help.tsx b/frontend/src/components/pages/security/shared/description-with-help.tsx index 45301f73b9..d9f13f67ba 100644 --- a/frontend/src/components/pages/security/shared/description-with-help.tsx +++ b/frontend/src/components/pages/security/shared/description-with-help.tsx @@ -18,9 +18,13 @@ type Props = { short: string; title: string; children: React.ReactNode; + /** Optional test id for the info trigger button. */ + testId?: string; + /** Optional test id for the drawer title. */ + titleTestId?: string; }; -export function DescriptionWithHelp({ short, title, children }: Props) { +export function DescriptionWithHelp({ short, title, children, testId, titleTestId }: Props) { const [open, setOpen] = useState(false); return ( @@ -30,6 +34,7 @@ export function DescriptionWithHelp({ short, title, children }: Props) {