fix(ui): show registry provider logos and capitalize registry labels (#12985)

This commit is contained in:
Alejandro Bailo authored and GitHub committed 2026-10-08 18:16:08 +02:00
1 parent f44ea4b6fa
commit d8bdc8553f
20 files changed
+416 -50

No files matched your search

@@ -10,6 +10,7 @@ import { MutedFindingsConfigButton } from "@/components/providers/muted-findings
import { NoProvidersAdded } from "@/components/providers/no-providers-added";
import { ProvidersAccountsTable } from "@/components/providers/providers-accounts-table";
import { ProvidersFilters } from "@/components/providers/providers-filters";
import { RegistryLogosContext } from "@/components/providers/table/registry-row-logo";
import { ProviderWizardModal } from "@/components/providers/wizard";
import type {
OrgWizardInitialData,
@@ -81,6 +82,8 @@ interface ProvidersAccountsViewProps {
scanConfigStatus?: ScanConfigurationListStatus;
isScanLimitReached?: boolean;
hierarchyStatus?: HierarchyStatus;
/** Registry logo per provider type; rows render before it resolves. */
registryLogos?: Promise<Record<string, string>>;
}
export function ProvidersAccountsView({
@@ -95,6 +98,7 @@ export function ProvidersAccountsView({
scanConfigStatus,
isScanLimitReached,
hierarchyStatus = HIERARCHY_STATUS.AVAILABLE,
registryLogos,
}: ProvidersAccountsViewProps) {
const pathname = usePathname();
const searchParams = useSearchParams();
@@ -219,18 +223,20 @@ export function ProvidersAccountsView({
</>
}
/>
<ProvidersAccountsTable
isCloud={isCloud}
metadata={metadata}
rows={rows}
scanScheduleCapability={scanScheduleCapability}
scanConfigs={scanConfigs}
scanConfigStatus={scanConfigStatus}
onOpenProviderWizard={(initialData) =>
openProviderWizard(WIZARD_OPEN_SOURCE.ROW_ACTION, initialData)
}
onOpenOrganizationWizard={openOrganizationWizard}
/>
<RegistryLogosContext value={registryLogos ?? null}>
<ProvidersAccountsTable
isCloud={isCloud}
metadata={metadata}
rows={rows}
scanScheduleCapability={scanScheduleCapability}
scanConfigs={scanConfigs}
scanConfigStatus={scanConfigStatus}
onOpenProviderWizard={(initialData) =>
openProviderWizard(WIZARD_OPEN_SOURCE.ROW_ACTION, initialData)
}
onOpenOrganizationWizard={openOrganizationWizard}
/>
</RegistryLogosContext>
</div>
)}
<ProviderWizardModal
@@ -0,0 +1,59 @@
import { useForm } from "react-hook-form";
import { describe, expect, it } from "vitest";
import { handleRegistryLogo } from "@/__tests__/msw/handlers/registry";
import { worker } from "@/__tests__/msw/worker";
import { render } from "@/__tests__/render-browser";
import type { AddProviderFormValues } from "@/types/formSchemas";
import { RadioGroupProvider } from "./radio-group-provider";
const LOGO_URL = "https://media.registry.example.com/providers/vcf/logo.png";
function Selector() {
const form = useForm<AddProviderFormValues>();
return (
<RadioGroupProvider
control={form.control}
isInvalid={false}
registryAvailable
registryOptions={[
{
type: "vcf",
label: "Vcf",
logoUrl: LOGO_URL,
},
]}
/>
);
}
// The logo is the first element of the option's label row.
function logoBox(option: HTMLElement) {
const logo = option.lastElementChild?.firstElementChild;
if (!logo) throw new Error("Provider option without a logo");
const { width, height } = logo.getBoundingClientRect();
return { width, height };
}
describe("provider selector logos", () => {
it("draws a Registry provider's logo at the size of the built-in logos", async () => {
// Given
worker.use(handleRegistryLogo(LOGO_URL));
const screen = await render(<Selector />);
// When
const builtIn = screen
.getByRole("option", { name: "Amazon Web Services" })
.element() as HTMLElement;
const registry = screen
.getByRole("option", { name: "Vcf Registry" })
.element() as HTMLElement;
await expect
.poll(() => registry.querySelector("img")?.getAttribute("src"))
.toBe(LOGO_URL);
// Then
expect(logoBox(registry)).toEqual(logoBox(builtIn));
});
});
@@ -8,11 +8,6 @@ import {
PROVIDER_TYPE_DATA,
} from "@/components/icons/providers-badge/provider-type-icon";
import { Badge, SearchInput } from "@/components/shadcn";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/shadcn/avatar";
import { FormMessage } from "@/components/shadcn/form";
import {
Tabs,
@@ -24,6 +19,8 @@ import type { RegistryProviderOption } from "@/lib/registry/provider-options";
import { cn } from "@/lib/utils";
import type { AddProviderFormValues } from "@/types/formSchemas";
import { RegistryProviderLogo } from "./registry-provider-logo";
const PROVIDERS = Object.entries(PROVIDER_TYPE_DATA).map(
([value, { label }]) => ({ value, label }),
);
@@ -135,19 +132,11 @@ export const RadioGroupProvider: FC<RadioGroupProviderProps> = ({
<div className="flex min-w-0 flex-1 items-center gap-1.5">
{provider.registry ? (
<Avatar>
<AvatarImage
src={
provider.logoUrl?.startsWith("https://")
? provider.logoUrl
: undefined
}
alt=""
/>
<AvatarFallback>
<ProviderTypeIcon type={provider.value} size={26} />
</AvatarFallback>
</Avatar>
<RegistryProviderLogo
type={provider.value}
logoUrl={provider.logoUrl}
size={26}
/>
) : (
<ProviderTypeIcon type={provider.value} size={26} />
)}
@@ -0,0 +1,33 @@
import { ProviderTypeIcon } from "@/components/icons/providers-badge/provider-type-icon";
import {
Avatar,
AvatarFallback,
AvatarImage,
} from "@/components/shadcn/avatar";
import type { ProviderType } from "@/types";
interface RegistryProviderLogoProps {
type: ProviderType;
logoUrl?: string;
size: number;
}
/** The registry's logo, or the generic glyph while it loads or once its signed URL expires. */
export function RegistryProviderLogo({
type,
logoUrl,
size,
}: RegistryProviderLogoProps) {
// Same box as a built-in provider badge of this size.
return (
<Avatar style={{ width: size, height: size }}>
<AvatarImage
src={logoUrl?.startsWith("https://") ? logoUrl : undefined}
alt=""
/>
<AvatarFallback>
<ProviderTypeIcon type={type} size={size} />
</AvatarFallback>
</Avatar>
);
}
@@ -41,6 +41,7 @@ import type {
import { LinkToScans } from "../link-to-scans";
import { DataTableRowActions } from "./data-table-row-actions";
import { RegistryRowLogo } from "./registry-row-logo";
interface GroupNameChipsProps {
groupNames?: string[];
@@ -247,6 +248,11 @@ export function getColumnProviders(
>
<EntityInfo
cloudProvider={provider.attributes.provider}
icon={
provider.attributes.is_dynamic ? (
<RegistryRowLogo type={provider.attributes.provider} />
) : undefined
}
entityAlias={provider.attributes.alias}
entityId={provider.attributes.uid}
nameAction={
@@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { handleRegistryLogo } from "@/__tests__/msw/handlers/registry";
import { worker } from "@/__tests__/msw/worker";
import { render } from "@/__tests__/render-browser";
import { RegistryLogosContext, RegistryRowLogo } from "./registry-row-logo";
const LOGO_URL = "https://media.registry.example.com/providers/vcf/logo.png";
describe("registry row logo", () => {
it("shows the generic glyph until the streamed logos arrive, then the registry logo", async () => {
// Given
worker.use(handleRegistryLogo(LOGO_URL));
let resolveLogos!: (logos: Record<string, string>) => void;
const logos = new Promise<Record<string, string>>((resolve) => {
resolveLogos = resolve;
});
const screen = await render(
<RegistryLogosContext value={logos}>
<RegistryRowLogo type="vcf" />
</RegistryLogosContext>,
);
const logoImage = () => screen.container.querySelector("img");
// Then: the row renders while the registry has not answered.
expect(screen.container.querySelector("svg")).not.toBeNull();
expect(logoImage()).toBeNull();
// When
resolveLogos({ vcf: LOGO_URL });
// Then
await expect.poll(() => logoImage()?.getAttribute("src")).toBe(LOGO_URL);
});
});
@@ -0,0 +1,39 @@
"use client";
import { createContext, Suspense, use } from "react";
import { RegistryProviderLogo } from "@/components/providers/registry-provider-logo";
import type { ProviderType } from "@/types";
const ROW_LOGO_SIZE = 35;
/** Registry logo per provider type, resolved after the rows render. */
export const RegistryLogosContext = createContext<Promise<
Record<string, string>
> | null>(null);
function ResolvedRegistryRowLogo({
type,
logos,
}: {
type: ProviderType;
logos: Promise<Record<string, string>>;
}) {
const byType = use(logos);
const logoUrl = Object.hasOwn(byType, type) ? byType[type] : undefined;
return (
<RegistryProviderLogo type={type} logoUrl={logoUrl} size={ROW_LOGO_SIZE} />
);
}
/** A registry row's logo; the generic glyph stands in until the logos arrive. */
export function RegistryRowLogo({ type }: { type: ProviderType }) {
const logos = use(RegistryLogosContext);
const placeholder = <RegistryProviderLogo type={type} size={ROW_LOGO_SIZE} />;
if (!logos) return placeholder;
return (
<Suspense fallback={placeholder}>
<ResolvedRegistryRowLogo type={type} logos={logos} />
</Suspense>
);
}
@@ -157,12 +157,16 @@ describe("dynamic credentials in the provider wizard", () => {
expect(timeout).toHaveAttribute("min", "1");
expect(timeout).toHaveAttribute("max", "300");
expect(timeout).toHaveAttribute("step", "1");
expect(
screen.getByRole("combobox", { name: "Authentication Scheme" }),
).toHaveTextContent("bearer");
const authScheme = screen.getByRole("combobox", {
name: "Authentication Scheme",
});
expect(authScheme).toHaveTextContent("Bearer");
expect(apiUrl).toHaveValue("");
// When: false must remain a boolean and numeric input must become a number.
// When: options read capitalized but submit their raw schema value.
await user.click(authScheme);
await user.click(screen.getByRole("option", { name: "Basic" }));
// False must remain a boolean and numeric input must become a number.
await user.type(apiUrl, "https://api.example.test");
await user.type(apiKey, "fixture-key-not-a-secret");
await user.click(verifyTls);
@@ -180,7 +184,7 @@ describe("dynamic credentials in the provider wizard", () => {
api_key: "fixture-key-not-a-secret",
verify_tls: false,
timeout_seconds: 60,
auth_scheme: "bearer",
auth_scheme: "basic",
},
});
});
@@ -347,7 +351,7 @@ describe("dynamic credentials in the provider wizard", () => {
await user.click(
screen.getByRole("combobox", { name: "Authentication method" }),
);
await user.click(screen.getByRole("option", { name: "personal token" }));
await user.click(screen.getByRole("option", { name: "Personal token" }));
expect(screen.getByLabelText(/API token/)).toHaveValue("");
expect(JSON.stringify(sessionStorage)).not.toContain(
"previous-method-secret",
@@ -27,6 +27,7 @@ import {
getCredentialDefaults,
validateCredentialValues,
} from "@/lib/provider-credentials/provider-credential-values";
import { formatRegistryLabel } from "@/lib/registry/labels";
import { useProviderWizardStore } from "@/store/provider-wizard/store";
import type {
ProviderSchemasResult,
@@ -58,7 +59,7 @@ function listCredentialMethods(
secretTypes: ProviderSecretTypes,
): CredentialMethod[] {
return Object.entries(secretTypes).flatMap(([secretType, value]) => {
const label = secretType.replaceAll("_", " ");
const label = formatRegistryLabel(secretType);
const variants = parseRegistryCredentialVariants(value);
if (!variants || variants.length === 1) {
return [
@@ -17,6 +17,7 @@ import type {
RegistryCredentialSchema,
RegistryCredentialValue,
} from "@/lib/provider-credentials/provider-credential-schema";
import { formatRegistryLabel } from "@/lib/registry/labels";
interface RegistryCredentialFieldsProps {
readonly errors: Readonly<Record<string, string | undefined>>;
@@ -107,7 +108,7 @@ export function RegistryCredentialFields({
<SelectContent>
{field.options?.map((option) => (
<SelectItem key={option} value={option}>
{option}
{formatRegistryLabel(option)}
</SelectItem>
))}
</SelectContent>