Merge remote-tracking branch 'origin/v2/main' into feat/mid-session-auth
This commit is contained in:
commit
479e757315
9 changed files with 2897 additions and 222 deletions
|
|
@ -10,6 +10,8 @@ import {
|
|||
import {
|
||||
EMPTY_CLIENT_SETTINGS,
|
||||
ISSUER_URL_ERROR,
|
||||
ISSUER_REQUIRED_ERROR,
|
||||
CLIENT_ID_REQUIRED_ERROR,
|
||||
type ClientSettingsFormValues,
|
||||
} from "./clientSettingsValues";
|
||||
|
||||
|
|
@ -139,7 +141,7 @@ describe("ClientSettingsForm EMA IdP session", () => {
|
|||
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reveals the issuer error without blur when revealIssuerError is set", () => {
|
||||
it("reveals the issuer error without blur when revealErrors is set", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={{
|
||||
|
|
@ -151,7 +153,7 @@ describe("ClientSettingsForm EMA IdP session", () => {
|
|||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
emaIdpLoginState="none"
|
||||
revealIssuerError
|
||||
revealErrors
|
||||
/>,
|
||||
);
|
||||
|
||||
|
|
@ -160,23 +162,63 @@ describe("ClientSettingsForm EMA IdP session", () => {
|
|||
expect(screen.getByText(ISSUER_URL_ERROR)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no error for a valid issuer even when revealIssuerError is set", () => {
|
||||
it("reveals required errors for blank issuer and client ID when revealErrors is set", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
|
||||
expandedSections={["ema"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
emaIdpLoginState="none"
|
||||
revealErrors
|
||||
/>,
|
||||
);
|
||||
|
||||
// Both required IdP fields are blank; a close/save attempt surfaces them
|
||||
// instead of silently dropping the config.
|
||||
expect(screen.getByText(ISSUER_REQUIRED_ERROR)).toBeInTheDocument();
|
||||
expect(screen.getByText(CLIENT_ID_REQUIRED_ERROR)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("does not show required errors for blank fields before revealErrors", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={{ ...EMPTY_CLIENT_SETTINGS, emaEnabled: true }}
|
||||
expandedSections={["ema"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
emaIdpLoginState="none"
|
||||
/>,
|
||||
);
|
||||
|
||||
// Inline validation must not nag about not-yet-filled required fields.
|
||||
expect(screen.queryByText(ISSUER_REQUIRED_ERROR)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(CLIENT_ID_REQUIRED_ERROR),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no error for a valid issuer even when revealErrors is set", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={{
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.test",
|
||||
clientId: "client-1",
|
||||
}}
|
||||
expandedSections={["ema"]}
|
||||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
emaIdpLoginState="none"
|
||||
revealIssuerError
|
||||
revealErrors
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText(CLIENT_ID_REQUIRED_ERROR),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows no issuer error for a valid URL", () => {
|
||||
|
|
@ -251,7 +293,7 @@ describe("ClientSettingsForm EMA IdP session", () => {
|
|||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reveals the CIMD URL error without blur when revealClientMetadataUrlError is set", () => {
|
||||
it("reveals the CIMD URL error without blur when revealErrors is set", () => {
|
||||
renderWithMantine(
|
||||
<ClientSettingsForm
|
||||
settings={{
|
||||
|
|
@ -263,7 +305,7 @@ describe("ClientSettingsForm EMA IdP session", () => {
|
|||
onExpandedSectionsChange={vi.fn()}
|
||||
onSettingsChange={vi.fn()}
|
||||
emaIdpLoginState="none"
|
||||
revealClientMetadataUrlError
|
||||
revealErrors
|
||||
/>,
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -30,16 +30,12 @@ export interface ClientSettingsFormProps {
|
|||
emaIdpLoginState?: EmaIdpLoginState;
|
||||
onEmaIdpLogout?: () => void;
|
||||
/**
|
||||
* Force the issuer error to show even before the field is blurred. The parent
|
||||
* sets this when a save/close is attempted with an invalid issuer, so the user
|
||||
* isn't left with a silently-dropped value and no explanation.
|
||||
* Force all field errors to show, including required-but-blank fields (EMA's
|
||||
* issuer/clientId, CIMD's metadata URL). The parent sets this when a
|
||||
* save/close is attempted with an incomplete or invalid config, so nothing is
|
||||
* silently dropped without explanation.
|
||||
*/
|
||||
revealIssuerError?: boolean;
|
||||
/**
|
||||
* Force the CIMD metadata URL error to show even before the field is blurred.
|
||||
* Same contract as {@link revealIssuerError} for the CIMD URL field.
|
||||
*/
|
||||
revealClientMetadataUrlError?: boolean;
|
||||
revealErrors?: boolean;
|
||||
}
|
||||
|
||||
const HintText = Text.withProps({
|
||||
|
|
@ -54,8 +50,7 @@ export function ClientSettingsForm({
|
|||
onSettingsChange,
|
||||
emaIdpLoginState = "none",
|
||||
onEmaIdpLogout,
|
||||
revealIssuerError = false,
|
||||
revealClientMetadataUrlError = false,
|
||||
revealErrors = false,
|
||||
}: ClientSettingsFormProps) {
|
||||
function patch(partial: Partial<ClientSettingsFormValues>) {
|
||||
onSettingsChange((prev) => ({ ...prev, ...partial }));
|
||||
|
|
@ -64,23 +59,31 @@ export function ClientSettingsForm({
|
|||
// Defer the issuer error until the field has been blurred so it doesn't nag
|
||||
// mid-typing (e.g. while "https:/…" is still incomplete). Once touched it
|
||||
// updates live, so the error clears as soon as a valid URL is entered. The
|
||||
// parent also forces it on via `revealIssuerError` when a close/save is
|
||||
// attempted with an invalid value, so the value is never silently dropped
|
||||
// without explanation. The persist gate (canPersistClientSettingsDraft)
|
||||
// validates independently — an invalid issuer is never written regardless.
|
||||
// parent forces all errors on via `revealErrors` when a close/save is
|
||||
// attempted with an incomplete or invalid config — including the
|
||||
// required-but-blank fields, which inline validation otherwise leaves silent.
|
||||
// The persist gate (canPersistClientSettingsDraft) validates independently, so
|
||||
// an incomplete/invalid config is never written regardless of these flags.
|
||||
const [issuerTouched, setIssuerTouched] = useState(false);
|
||||
const [clientMetadataUrlTouched, setClientMetadataUrlTouched] =
|
||||
useState(false);
|
||||
|
||||
const errors = validateClientSettings(settings);
|
||||
const showIssuerError =
|
||||
(issuerTouched || revealIssuerError) && errors.issuer
|
||||
? errors.issuer
|
||||
// Inline errors flag only a filled-in-wrong field (issuer URL); the
|
||||
// require-complete set adds the blank-required fields surfaced on reveal.
|
||||
const inlineErrors = validateClientSettings(settings);
|
||||
const revealedErrors = validateClientSettings(settings, {
|
||||
requireComplete: true,
|
||||
});
|
||||
const showIssuerError = revealErrors
|
||||
? revealedErrors.issuer
|
||||
: issuerTouched
|
||||
? inlineErrors.issuer
|
||||
: undefined;
|
||||
const showClientMetadataUrlError =
|
||||
(clientMetadataUrlTouched || revealClientMetadataUrlError) &&
|
||||
errors.clientMetadataUrl
|
||||
? errors.clientMetadataUrl
|
||||
const showClientIdError = revealErrors ? revealedErrors.clientId : undefined;
|
||||
const showClientMetadataUrlError = revealErrors
|
||||
? revealedErrors.clientMetadataUrl
|
||||
: clientMetadataUrlTouched
|
||||
? inlineErrors.clientMetadataUrl
|
||||
: undefined;
|
||||
|
||||
const showIdpSession =
|
||||
|
|
@ -134,6 +137,7 @@ export function ClientSettingsForm({
|
|||
description="Client id registered with your enterprise IdP."
|
||||
value={settings.clientId}
|
||||
onChange={(e) => patch({ clientId: e.currentTarget.value })}
|
||||
error={showClientIdError}
|
||||
rightSectionPointerEvents="auto"
|
||||
rightSection={
|
||||
settings.clientId ? (
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ import {
|
|||
EMPTY_CLIENT_SETTINGS,
|
||||
formValuesToClientConfig,
|
||||
ISSUER_URL_ERROR,
|
||||
ISSUER_REQUIRED_ERROR,
|
||||
CLIENT_ID_REQUIRED_ERROR,
|
||||
CLIENT_METADATA_URL_REQUIRED_ERROR,
|
||||
validateClientSettings,
|
||||
} from "./clientSettingsValues";
|
||||
|
||||
|
|
@ -457,4 +460,92 @@ describe("clientSettingsValues", () => {
|
|||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("validateClientSettings does not flag blank required fields by default", () => {
|
||||
expect(
|
||||
validateClientSettings({
|
||||
emaEnabled: true,
|
||||
issuer: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
...emptyCimd,
|
||||
}),
|
||||
).toEqual({});
|
||||
});
|
||||
|
||||
it("validateClientSettings with requireComplete flags blank issuer and clientId", () => {
|
||||
expect(
|
||||
validateClientSettings(
|
||||
{
|
||||
emaEnabled: true,
|
||||
issuer: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
...emptyCimd,
|
||||
},
|
||||
{ requireComplete: true },
|
||||
),
|
||||
).toEqual({
|
||||
issuer: ISSUER_REQUIRED_ERROR,
|
||||
clientId: CLIENT_ID_REQUIRED_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
it("validateClientSettings with requireComplete keeps the URL error over the required error", () => {
|
||||
expect(
|
||||
validateClientSettings(
|
||||
{
|
||||
emaEnabled: true,
|
||||
issuer: "not-a-url",
|
||||
clientId: "cid",
|
||||
clientSecret: "",
|
||||
...emptyCimd,
|
||||
},
|
||||
{ requireComplete: true },
|
||||
),
|
||||
).toEqual({ issuer: ISSUER_URL_ERROR });
|
||||
});
|
||||
|
||||
it("validateClientSettings with requireComplete flags only the blank clientId when the issuer is valid", () => {
|
||||
expect(
|
||||
validateClientSettings(
|
||||
{
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.test",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
...emptyCimd,
|
||||
},
|
||||
{ requireComplete: true },
|
||||
),
|
||||
).toEqual({ clientId: CLIENT_ID_REQUIRED_ERROR });
|
||||
});
|
||||
|
||||
it("validateClientSettings with requireComplete flags a blank CIMD URL", () => {
|
||||
expect(
|
||||
validateClientSettings(
|
||||
{
|
||||
emaEnabled: false,
|
||||
issuer: "",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
cimdEnabled: true,
|
||||
clientMetadataUrl: "",
|
||||
},
|
||||
{ requireComplete: true },
|
||||
),
|
||||
).toEqual({ clientMetadataUrl: CLIENT_METADATA_URL_REQUIRED_ERROR });
|
||||
});
|
||||
|
||||
it("canPersistClientSettingsDraft blocks a blank clientId with a valid issuer", () => {
|
||||
expect(
|
||||
canPersistClientSettingsDraft({
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.test",
|
||||
clientId: "",
|
||||
clientSecret: "",
|
||||
...emptyCimd,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -7,35 +7,72 @@ import {
|
|||
/** Field-level error message for an issuer that is not an http(s) URL. */
|
||||
export const ISSUER_URL_ERROR =
|
||||
"Must be an http(s) URL, like https://idp.example.com";
|
||||
/** Field-level errors for a required field left blank while its feature is enabled. */
|
||||
export const ISSUER_REQUIRED_ERROR =
|
||||
"Issuer is required when enterprise IdP configuration is enabled";
|
||||
export const CLIENT_ID_REQUIRED_ERROR =
|
||||
"Client ID is required when enterprise IdP configuration is enabled";
|
||||
export const CLIENT_METADATA_URL_REQUIRED_ERROR =
|
||||
"Metadata document URL is required when CIMD is enabled";
|
||||
|
||||
/** Field-level validation errors for the client settings form. */
|
||||
export interface ClientSettingsErrors {
|
||||
issuer?: string;
|
||||
clientId?: string;
|
||||
clientMetadataUrl?: string;
|
||||
}
|
||||
|
||||
export interface ValidateClientSettingsOptions {
|
||||
/**
|
||||
* Also flag required fields left blank. Off by default so inline (as-you-type)
|
||||
* validation only flags a field the user has actually filled in wrong; turned
|
||||
* on when a save/close is attempted so an incomplete config is surfaced rather
|
||||
* than silently dropped.
|
||||
*/
|
||||
requireComplete?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline validation for the client settings form. Only flags fields that the
|
||||
* user has actually filled in — empty required fields are gated by
|
||||
* {@link canPersistClientSettingsDraft} rather than surfaced as errors.
|
||||
* Validation for the client settings form. By default only flags fields the
|
||||
* user has filled in wrong (a non-empty, non-http(s) issuer; a malformed CIMD
|
||||
* metadata URL). With `requireComplete`, also flags the required fields of an
|
||||
* enabled feature (EMA's issuer/clientId, CIMD's metadata URL) when they are
|
||||
* blank — used at save/close time so an enabled-but-incomplete config never
|
||||
* persists silently.
|
||||
*/
|
||||
export function validateClientSettings(
|
||||
values: ClientSettingsFormValues,
|
||||
options: ValidateClientSettingsOptions = {},
|
||||
): ClientSettingsErrors {
|
||||
const errors: ClientSettingsErrors = {};
|
||||
if (
|
||||
values.emaEnabled &&
|
||||
values.issuer.trim() !== "" &&
|
||||
!isAbsoluteHttpUrl(values.issuer)
|
||||
) {
|
||||
errors.issuer = ISSUER_URL_ERROR;
|
||||
}
|
||||
if (values.cimdEnabled && values.clientMetadataUrl.trim() !== "") {
|
||||
const cimdError = getCimdClientMetadataUrlError(values.clientMetadataUrl);
|
||||
if (cimdError) {
|
||||
errors.clientMetadataUrl = cimdError;
|
||||
|
||||
if (values.emaEnabled) {
|
||||
const issuer = values.issuer.trim();
|
||||
if (issuer === "") {
|
||||
if (options.requireComplete) errors.issuer = ISSUER_REQUIRED_ERROR;
|
||||
} else if (!isAbsoluteHttpUrl(values.issuer)) {
|
||||
errors.issuer = ISSUER_URL_ERROR;
|
||||
}
|
||||
|
||||
if (options.requireComplete && values.clientId.trim() === "") {
|
||||
errors.clientId = CLIENT_ID_REQUIRED_ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
if (values.cimdEnabled) {
|
||||
const url = values.clientMetadataUrl.trim();
|
||||
if (url === "") {
|
||||
if (options.requireComplete) {
|
||||
errors.clientMetadataUrl = CLIENT_METADATA_URL_REQUIRED_ERROR;
|
||||
}
|
||||
} else {
|
||||
const cimdError = getCimdClientMetadataUrlError(values.clientMetadataUrl);
|
||||
if (cimdError) {
|
||||
errors.clientMetadataUrl = cimdError;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
|
||||
|
|
@ -108,18 +145,18 @@ export function formValuesToClientConfig(
|
|||
return result;
|
||||
}
|
||||
|
||||
/** Skip debounced persist while required fields are blank for enabled features. */
|
||||
/**
|
||||
* Skip the debounced persist while an enabled feature's required fields are
|
||||
* blank or invalid (EMA's issuer/clientId, CIMD's metadata URL). Defers to
|
||||
* {@link validateClientSettings} in `requireComplete` mode so the persist gate
|
||||
* and the close-time field errors can never drift: the same condition that
|
||||
* blocks the write also surfaces the field-level errors that explain why.
|
||||
*/
|
||||
export function canPersistClientSettingsDraft(
|
||||
values: ClientSettingsFormValues,
|
||||
): boolean {
|
||||
if (values.emaEnabled) {
|
||||
if (values.issuer.trim() === "" || values.clientId.trim() === "")
|
||||
return false;
|
||||
}
|
||||
if (values.cimdEnabled) {
|
||||
if (!values.clientMetadataUrl.trim()) return false;
|
||||
}
|
||||
// Defer to validateClientSettings so the persist gate and inline field errors
|
||||
// can never drift — invalid values are never sent to the backend.
|
||||
return Object.keys(validateClientSettings(values)).length === 0;
|
||||
return (
|
||||
Object.keys(validateClientSettings(values, { requireComplete: true }))
|
||||
.length === 0
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ import { ClientSettingsModal } from "./ClientSettingsModal";
|
|||
import {
|
||||
EMPTY_CLIENT_SETTINGS,
|
||||
ISSUER_URL_ERROR,
|
||||
CLIENT_ID_REQUIRED_ERROR,
|
||||
CLIENT_METADATA_URL_REQUIRED_ERROR,
|
||||
type ClientSettingsFormValues,
|
||||
} from "../ClientSettingsForm/clientSettingsValues";
|
||||
import { CIMD_METADATA_URL_INVALID_ERROR } from "@inspector/core/client/config-parse.js";
|
||||
|
|
@ -150,6 +152,9 @@ describe("ClientSettingsModal", () => {
|
|||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const clickClose = (user: ReturnType<typeof userEvent.setup>) =>
|
||||
user.click(document.querySelector("button.mantine-CloseButton-root")!);
|
||||
|
||||
it("blocks close and reveals the issuer error when the issuer is invalid", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
|
|
@ -160,6 +165,7 @@ describe("ClientSettingsModal", () => {
|
|||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
issuer: "not-a-url",
|
||||
clientId: "client-1",
|
||||
}}
|
||||
onClose={onClose}
|
||||
onSettingsChange={vi.fn()}
|
||||
|
|
@ -169,13 +175,35 @@ describe("ClientSettingsModal", () => {
|
|||
// Error is hidden initially (form gates it on blur)...
|
||||
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
|
||||
// ...but a close attempt reveals it and does NOT close (no silent drop).
|
||||
await user.click(
|
||||
document.querySelector("button.mantine-CloseButton-root")!,
|
||||
);
|
||||
await clickClose(user);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(ISSUER_URL_ERROR)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("blocks close and reveals the required error when the client ID is blank", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={{
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
issuer: "https://idp.test", // valid issuer, but clientId is blank
|
||||
}}
|
||||
onClose={onClose}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText(CLIENT_ID_REQUIRED_ERROR),
|
||||
).not.toBeInTheDocument();
|
||||
await clickClose(user);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByText(CLIENT_ID_REQUIRED_ERROR)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows close once the invalid issuer is corrected", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
|
|
@ -184,6 +212,7 @@ describe("ClientSettingsModal", () => {
|
|||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
issuer: "not-a-url",
|
||||
clientId: "client-1", // complete except for the issuer
|
||||
});
|
||||
return (
|
||||
<ClientSettingsModal
|
||||
|
|
@ -196,10 +225,7 @@ describe("ClientSettingsModal", () => {
|
|||
}
|
||||
renderWithMantine(<Host />);
|
||||
|
||||
const close = () =>
|
||||
user.click(document.querySelector("button.mantine-CloseButton-root")!);
|
||||
|
||||
await close(); // blocked, error revealed
|
||||
await clickClose(user); // blocked, error revealed
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
const issuer = screen.getByLabelText("Issuer");
|
||||
|
|
@ -207,18 +233,18 @@ describe("ClientSettingsModal", () => {
|
|||
await user.type(issuer, "https://idp.test");
|
||||
expect(screen.queryByText(ISSUER_URL_ERROR)).not.toBeInTheDocument();
|
||||
|
||||
await close(); // now valid -> closes
|
||||
await clickClose(user); // now complete + valid -> closes
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows close when an invalid issuer is cleared (empty is valid to leave)", async () => {
|
||||
it("allows close once a blank client ID is filled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
function Host() {
|
||||
const [settings, setSettings] = useState({
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true,
|
||||
issuer: "not-a-url",
|
||||
issuer: "https://idp.test", // complete except for the clientId
|
||||
});
|
||||
return (
|
||||
<ClientSettingsModal
|
||||
|
|
@ -231,10 +257,43 @@ describe("ClientSettingsModal", () => {
|
|||
}
|
||||
renderWithMantine(<Host />);
|
||||
|
||||
await user.clear(screen.getByLabelText("Issuer"));
|
||||
await clickClose(user); // blocked on blank clientId
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
await user.type(screen.getByLabelText("Client ID"), "client-1");
|
||||
await clickClose(user); // now complete -> closes
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows close by disabling enterprise IdP when the config is incomplete", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
function Host() {
|
||||
const [settings, setSettings] = useState({
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
emaEnabled: true, // enabled but issuer + clientId blank
|
||||
});
|
||||
return (
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={settings}
|
||||
onClose={onClose}
|
||||
onSettingsChange={setSettings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
renderWithMantine(<Host />);
|
||||
|
||||
await clickClose(user); // blocked: required fields blank
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
// Disabling enterprise IdP is the escape hatch — nothing required to save.
|
||||
await user.click(
|
||||
document.querySelector("button.mantine-CloseButton-root")!,
|
||||
screen.getByRole("checkbox", {
|
||||
name: "Enable enterprise IdP configuration",
|
||||
}),
|
||||
);
|
||||
await clickClose(user);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
|
|
@ -304,14 +363,40 @@ describe("ClientSettingsModal", () => {
|
|||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows close when an invalid CIMD URL is cleared (empty is valid to leave)", async () => {
|
||||
it("blocks close and reveals the required error when the CIMD URL is blank", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
renderWithMantine(
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={{
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
cimdEnabled: true, // enabled but metadata URL blank
|
||||
}}
|
||||
onClose={onClose}
|
||||
onSettingsChange={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByText(CLIENT_METADATA_URL_REQUIRED_ERROR),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(
|
||||
document.querySelector("button.mantine-CloseButton-root")!,
|
||||
);
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(
|
||||
screen.getByText(CLIENT_METADATA_URL_REQUIRED_ERROR),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("allows close once a blank CIMD URL is filled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
function Host() {
|
||||
const [settings, setSettings] = useState({
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
cimdEnabled: true,
|
||||
clientMetadataUrl: "not-a-url",
|
||||
cimdEnabled: true, // enabled but metadata URL blank
|
||||
});
|
||||
return (
|
||||
<ClientSettingsModal
|
||||
|
|
@ -324,8 +409,51 @@ describe("ClientSettingsModal", () => {
|
|||
}
|
||||
renderWithMantine(<Host />);
|
||||
|
||||
await user.click(
|
||||
document.querySelector("button.mantine-CloseButton-root")!,
|
||||
); // blocked on blank URL
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
await expandCimdSection(user);
|
||||
await user.clear(screen.getByLabelText("Client ID metadata document URL"));
|
||||
await user.type(
|
||||
screen.getByLabelText("Client ID metadata document URL"),
|
||||
"https://example.com/cimd.json",
|
||||
);
|
||||
await user.click(
|
||||
document.querySelector("button.mantine-CloseButton-root")!,
|
||||
);
|
||||
expect(onClose).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("allows close by disabling CIMD when the URL is blank", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
function Host() {
|
||||
const [settings, setSettings] = useState({
|
||||
...EMPTY_CLIENT_SETTINGS,
|
||||
cimdEnabled: true, // enabled but metadata URL blank
|
||||
});
|
||||
return (
|
||||
<ClientSettingsModal
|
||||
opened
|
||||
settings={settings}
|
||||
onClose={onClose}
|
||||
onSettingsChange={setSettings}
|
||||
/>
|
||||
);
|
||||
}
|
||||
renderWithMantine(<Host />);
|
||||
|
||||
await user.click(
|
||||
document.querySelector("button.mantine-CloseButton-root")!,
|
||||
); // blocked: URL required
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
|
||||
// Disabling CIMD is the escape hatch — nothing required to save.
|
||||
await expandCimdSection(user);
|
||||
await user.click(
|
||||
screen.getByRole("checkbox", { name: "Use Client ID Metadata Document" }),
|
||||
);
|
||||
await user.click(
|
||||
document.querySelector("button.mantine-CloseButton-root")!,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -37,9 +37,7 @@ export function ClientSettingsModal({
|
|||
const [expandedSections, setExpandedSections] = useState<
|
||||
ClientSettingsSection[]
|
||||
>(["ema"]);
|
||||
const [revealIssuerError, setRevealIssuerError] = useState(false);
|
||||
const [revealClientMetadataUrlError, setRevealClientMetadataUrlError] =
|
||||
useState(false);
|
||||
const [revealErrors, setRevealErrors] = useState(false);
|
||||
|
||||
const allExpanded = expandedSections.length === ALL_SECTIONS.length;
|
||||
|
||||
|
|
@ -47,21 +45,16 @@ export function ClientSettingsModal({
|
|||
setExpandedSections(allExpanded ? [] : ALL_SECTIONS);
|
||||
}
|
||||
|
||||
// Closing (X / Esc / overlay) is the implicit "save" for this auto-saving
|
||||
// modal — the parent flushes the debounced persist on close. If a validated
|
||||
// URL field is invalid the persist gate would silently drop it, so instead of
|
||||
// closing we reveal the field error (overriding the form's on-blur gating).
|
||||
// The user can fix the URL or clear the field — an empty value is valid to
|
||||
// leave — and then close. Resets via the parent's open/close remount key.
|
||||
// modal — the parent flushes the debounced persist on close. A client config
|
||||
// that's incomplete (blank required field) or invalid (bad issuer / CIMD URL)
|
||||
// would be silently dropped by the persist gate, so instead of closing we
|
||||
// reveal the field errors (overriding the form's on-blur gating) and keep the
|
||||
// modal open. The user fixes the fields, or disables the feature, then closes.
|
||||
// Resets via the parent's open/close remount key.
|
||||
function handleClose() {
|
||||
const errors = validateClientSettings(settings);
|
||||
if (errors.issuer) {
|
||||
setRevealIssuerError(true);
|
||||
}
|
||||
if (errors.clientMetadataUrl) {
|
||||
setRevealClientMetadataUrlError(true);
|
||||
}
|
||||
if (errors.issuer || errors.clientMetadataUrl) {
|
||||
const errors = validateClientSettings(settings, { requireComplete: true });
|
||||
if (Object.keys(errors).length > 0) {
|
||||
setRevealErrors(true);
|
||||
return;
|
||||
}
|
||||
onClose();
|
||||
|
|
@ -94,8 +87,7 @@ export function ClientSettingsModal({
|
|||
onSettingsChange={onSettingsChange}
|
||||
emaIdpLoginState={emaIdpLoginState}
|
||||
onEmaIdpLogout={onEmaIdpLogout}
|
||||
revealIssuerError={revealIssuerError}
|
||||
revealClientMetadataUrlError={revealClientMetadataUrlError}
|
||||
revealErrors={revealErrors}
|
||||
/>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import { expect, fn, userEvent, within } from "storybook/test";
|
||||
import { ResourcePreviewPanel } from "./ResourcePreviewPanel";
|
||||
|
||||
const meta: Meta<typeof ResourcePreviewPanel> = {
|
||||
|
|
@ -108,6 +108,44 @@ export const SubscriptionsUnsupported: Story = {
|
|||
},
|
||||
};
|
||||
|
||||
// Markdown, CSV, and HTML resources expose a "View Source" link (left of
|
||||
// Refresh) that swaps the rendered preview for the raw resource text. The play
|
||||
// function drives the toggle: rendered heading → raw source → back.
|
||||
export const MarkdownWithViewSource: Story = {
|
||||
args: {
|
||||
resource: {
|
||||
name: "README.md",
|
||||
uri: "file:///README.md",
|
||||
},
|
||||
contents: [
|
||||
{
|
||||
uri: "file:///README.md",
|
||||
mimeType: "text/markdown",
|
||||
text: "# Project\n\nA **bold** intro with a [link](https://example.com).",
|
||||
},
|
||||
],
|
||||
isSubscribed: false,
|
||||
lastUpdated: new Date("2026-03-17T10:30:00Z"),
|
||||
},
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
// Rendered by default.
|
||||
await expect(
|
||||
canvas.getByRole("heading", { level: 1, name: "Project" }),
|
||||
).toBeInTheDocument();
|
||||
// Switch to raw source.
|
||||
await userEvent.click(canvas.getByRole("button", { name: "View Source" }));
|
||||
await expect(canvas.getByText(/# Project/)).toBeInTheDocument();
|
||||
// Switch back to rendered.
|
||||
await userEvent.click(
|
||||
canvas.getByRole("button", { name: "View Rendered" }),
|
||||
);
|
||||
await expect(
|
||||
canvas.getByRole("heading", { level: 1, name: "Project" }),
|
||||
).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
export const WithAnnotations: Story = {
|
||||
args: {
|
||||
resource: {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { describe, it, expect, vi } from "vitest";
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import type {
|
||||
BlobResourceContents,
|
||||
|
|
@ -8,6 +8,16 @@ import type {
|
|||
import { renderWithMantine, screen } from "../../../test/renderWithMantine";
|
||||
import { ResourcePreviewPanel } from "./ResourcePreviewPanel";
|
||||
|
||||
// Stub the lazy highlighter so JSON/XML/CSS branches render synchronously
|
||||
// (its dynamic-import behavior is covered in CodeHighlight.test.tsx).
|
||||
vi.mock("../../elements/CodeHighlight/CodeHighlight", () => ({
|
||||
CodeHighlight: ({ language, code }: { language: string; code: string }) => (
|
||||
<pre data-testid="code-highlight" data-language={language}>
|
||||
{code}
|
||||
</pre>
|
||||
),
|
||||
}));
|
||||
|
||||
const textResource: Resource = {
|
||||
name: "config.json",
|
||||
uri: "file:///config.json",
|
||||
|
|
@ -295,4 +305,264 @@ describe("ResourcePreviewPanel", () => {
|
|||
expect(screen.queryByRole("heading", { level: 1 })).not.toBeInTheDocument();
|
||||
expect(screen.getByText("# not a heading")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
describe("View Source toggle", () => {
|
||||
const markdownProps = {
|
||||
...baseProps,
|
||||
resource: { name: "readme", uri: "file:///readme.md" },
|
||||
contents: [
|
||||
{
|
||||
uri: "file:///readme.md",
|
||||
mimeType: "text/markdown",
|
||||
text: "# Hello",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("toggles a markdown preview between rendered and raw source", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ResourcePreviewPanel {...markdownProps} />);
|
||||
|
||||
// Rendered by default: heading shown, no raw "# Hello" text.
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Hello" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "View Source" }));
|
||||
|
||||
// Source mode: raw markdown text shown, heading gone, label flipped.
|
||||
expect(screen.getByText("# Hello")).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByRole("heading", { level: 1 }),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole("button", { name: "View Rendered" }));
|
||||
|
||||
// Back to rendered.
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Hello" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("button", { name: "View Source" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reflects toggle state via aria-pressed", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(<ResourcePreviewPanel {...markdownProps} />);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "View Source" }),
|
||||
).toHaveAttribute("aria-pressed", "false");
|
||||
await user.click(screen.getByRole("button", { name: "View Source" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "View Rendered" }),
|
||||
).toHaveAttribute("aria-pressed", "true");
|
||||
});
|
||||
|
||||
it("only switches source-toggleable items in a mixed multi-part resource", async () => {
|
||||
const user = userEvent.setup();
|
||||
renderWithMantine(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "mix", uri: "file:///mix.md" }}
|
||||
contents={[
|
||||
{ uri: "file:///mix.md", mimeType: "text/markdown", text: "# Hi" },
|
||||
{ uri: "file:///mix.png", mimeType: "image/png", blob: "abc" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
// Toggle gated on the first (markdown) item.
|
||||
await user.click(screen.getByRole("button", { name: "View Source" }));
|
||||
// Markdown switches to raw text...
|
||||
expect(screen.getByText("# Hi")).toBeInTheDocument();
|
||||
// ...but the image is not forced through the text decoder — it still
|
||||
// renders as an image rather than garbled bytes or a binary notice.
|
||||
const img = screen
|
||||
.getAllByRole("img")
|
||||
.find((el) => el.getAttribute("src")?.startsWith("data:image/png"));
|
||||
expect(img).toBeDefined();
|
||||
});
|
||||
|
||||
it("offers the toggle for CSV resources", () => {
|
||||
renderWithMantine(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "data", uri: "file:///data.csv" }}
|
||||
contents={[{ uri: "file:///data.csv", text: "a,b\n1,2" }]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "View Source" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers the toggle for HTML resources", () => {
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:url");
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
renderWithMantine(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "page", uri: "file:///page.html" }}
|
||||
contents={[{ uri: "file:///page.html", text: "<p>hi</p>" }]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "View Source" }),
|
||||
).toBeInTheDocument();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("hides the toggle for non-toggleable kinds (JSON, plain text, image)", () => {
|
||||
const { rerender } = renderWithMantine(
|
||||
<ResourcePreviewPanel {...baseProps} />,
|
||||
);
|
||||
// JSON
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "View Source" }),
|
||||
).not.toBeInTheDocument();
|
||||
// Plain text
|
||||
rerender(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "notes", uri: "file:///notes.txt" }}
|
||||
contents={[
|
||||
{ uri: "file:///notes.txt", mimeType: "text/plain", text: "hi" },
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.queryByRole("button", { name: "View Source" }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("resets to the rendered view when the resource changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { rerender } = renderWithMantine(
|
||||
<ResourcePreviewPanel {...markdownProps} />,
|
||||
);
|
||||
await user.click(screen.getByRole("button", { name: "View Source" }));
|
||||
expect(
|
||||
screen.getByRole("button", { name: "View Rendered" }),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Switch to a different markdown resource — the toggle should reset.
|
||||
rerender(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "other", uri: "file:///other.md" }}
|
||||
contents={[
|
||||
{
|
||||
uri: "file:///other.md",
|
||||
mimeType: "text/markdown",
|
||||
text: "# Two",
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("button", { name: "View Source" }),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Two" }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("URI-suffix MIME inference", () => {
|
||||
it("infers text/csv from a .csv URI and renders a table", () => {
|
||||
renderWithMantine(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "data", uri: "file:///data.csv" }}
|
||||
contents={[{ uri: "file:///data.csv", text: "a,b\n1,2" }]}
|
||||
/>,
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("columnheader", { name: "a" }),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText("text/csv")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("infers application/json from a .json URI and highlights", () => {
|
||||
renderWithMantine(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "cfg", uri: "file:///cfg.json" }}
|
||||
contents={[{ uri: "file:///cfg.json", text: '{"a":1}' }]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("code-highlight")).toHaveAttribute(
|
||||
"data-language",
|
||||
"json",
|
||||
);
|
||||
});
|
||||
|
||||
it("infers application/xml from a .xml URI and highlights", () => {
|
||||
renderWithMantine(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "feed", uri: "file:///feed.xml" }}
|
||||
contents={[{ uri: "file:///feed.xml", text: "<a><b/></a>" }]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("code-highlight")).toHaveAttribute(
|
||||
"data-language",
|
||||
"xml",
|
||||
);
|
||||
});
|
||||
|
||||
it("infers text/css from a .css URI and highlights", () => {
|
||||
renderWithMantine(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "style", uri: "file:///style.css" }}
|
||||
contents={[{ uri: "file:///style.css", text: ".a{}" }]}
|
||||
/>,
|
||||
);
|
||||
expect(screen.getByTestId("code-highlight")).toHaveAttribute(
|
||||
"data-language",
|
||||
"css",
|
||||
);
|
||||
});
|
||||
|
||||
describe("blob-URL renderers", () => {
|
||||
beforeEach(() => {
|
||||
vi.spyOn(URL, "createObjectURL").mockReturnValue("blob:url");
|
||||
vi.spyOn(URL, "revokeObjectURL").mockImplementation(() => {});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("infers text/html from a .html URI and sandboxes it", () => {
|
||||
const { container } = renderWithMantine(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "report", uri: "file:///report.html" }}
|
||||
contents={[{ uri: "file:///report.html", text: "<p>hi</p>" }]}
|
||||
/>,
|
||||
);
|
||||
const iframe = container.querySelector("iframe");
|
||||
expect(iframe).toHaveAttribute("sandbox", "");
|
||||
});
|
||||
|
||||
it("infers application/pdf from a .pdf URI and renders a viewer", () => {
|
||||
const { container } = renderWithMantine(
|
||||
<ResourcePreviewPanel
|
||||
{...baseProps}
|
||||
resource={{ name: "doc", uri: "file:///doc.pdf" }}
|
||||
contents={[
|
||||
{
|
||||
uri: "file:///doc.pdf",
|
||||
blob: Buffer.from("%PDF", "utf-8").toString("base64"),
|
||||
},
|
||||
]}
|
||||
/>,
|
||||
);
|
||||
const iframe = container.querySelector("iframe");
|
||||
expect(iframe).toHaveAttribute("src", "blob:url#view=FitH");
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -8,14 +8,15 @@ import {
|
|||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import { useState } from "react";
|
||||
import type {
|
||||
BlobResourceContents,
|
||||
ContentBlock,
|
||||
Resource,
|
||||
TextResourceContents,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { AnnotationBadge } from "../../elements/AnnotationBadge/AnnotationBadge";
|
||||
import { ContentViewer } from "../../elements/ContentViewer/ContentViewer";
|
||||
import { getMimeKind } from "../../elements/ContentViewer/contentViewerUtils";
|
||||
import { CopyButton } from "../../elements/CopyButton/CopyButton";
|
||||
import { SubscribeButton } from "../../elements/SubscribeButton/SubscribeButton";
|
||||
|
||||
|
|
@ -41,29 +42,23 @@ export interface ResourcePreviewPanelProps {
|
|||
onClose?: () => void;
|
||||
}
|
||||
|
||||
function toContentBlock(
|
||||
item: TextResourceContents | BlobResourceContents,
|
||||
): ContentBlock {
|
||||
if ("text" in item) {
|
||||
return { type: "text", text: item.text };
|
||||
}
|
||||
const mimeType = item.mimeType ?? "application/octet-stream";
|
||||
if (mimeType.startsWith("image/")) {
|
||||
return { type: "image", data: item.blob, mimeType };
|
||||
}
|
||||
if (mimeType.startsWith("audio/")) {
|
||||
return { type: "audio", data: item.blob, mimeType };
|
||||
}
|
||||
return {
|
||||
type: "text",
|
||||
text: `[Binary content (${mimeType}) — preview not supported]`,
|
||||
};
|
||||
}
|
||||
|
||||
function formatLastUpdated(date: Date): string {
|
||||
return `Last updated: ${date.toLocaleString()}`;
|
||||
}
|
||||
|
||||
// MIME kinds whose rendered preview (react-markdown, the CSV table, the
|
||||
// sandboxed HTML iframe) hides the underlying text. For these the panel offers
|
||||
// a "View Source" toggle that swaps the renderer for the raw resource text.
|
||||
const SOURCE_TOGGLEABLE_KINDS = new Set(["markdown", "csv", "html"]);
|
||||
|
||||
// MIME forced on ContentViewer in source mode so it routes through the plain
|
||||
// preformatted-text renderer regardless of the resource's real type.
|
||||
const SOURCE_MIME = "text/plain";
|
||||
|
||||
function isSourceToggleable(mimeType: string): boolean {
|
||||
return SOURCE_TOGGLEABLE_KINDS.has(getMimeKind(mimeType));
|
||||
}
|
||||
|
||||
const HeaderRow = Group.withProps({
|
||||
justify: "space-between",
|
||||
wrap: "nowrap",
|
||||
|
|
@ -115,6 +110,14 @@ const ActionGroup = Group.withProps({
|
|||
gap: "xs",
|
||||
});
|
||||
|
||||
// Subtle footer action button. Shared by Refresh and the View Source toggle so
|
||||
// the two stay visually identical (the toggle is deliberately styled to match
|
||||
// Refresh, which sits immediately to its right).
|
||||
const FooterButton = Button.withProps({
|
||||
variant: "subtle",
|
||||
size: "sm",
|
||||
});
|
||||
|
||||
const Spacer = Flex.withProps({});
|
||||
|
||||
// The panel sizes to its content: when the resource body is short the
|
||||
|
|
@ -145,14 +148,31 @@ const ContentStack = Stack.withProps({
|
|||
gap: "md",
|
||||
});
|
||||
|
||||
// Infer a markdown MIME from the URI when the server didn't supply one.
|
||||
// MCP servers often return `text/plain` (or omit mimeType entirely) for
|
||||
// `.md` resources; the file extension is the most reliable fallback signal.
|
||||
// Map a file extension to the MIME type that drives ContentViewer's per-MIME
|
||||
// renderer dispatch. MCP servers commonly omit `mimeType` (or return a generic
|
||||
// `text/plain` / `application/octet-stream`), so the URI suffix is the most
|
||||
// reliable signal for engaging the markdown / PDF / CSV / XML / HTML / CSS
|
||||
// renderers. Order doesn't matter — suffixes are unique.
|
||||
const URI_SUFFIX_MIME: ReadonlyArray<readonly [string, string]> = [
|
||||
[".md", "text/markdown"],
|
||||
[".markdown", "text/markdown"],
|
||||
[".csv", "text/csv"],
|
||||
[".json", "application/json"],
|
||||
[".xml", "application/xml"],
|
||||
[".html", "text/html"],
|
||||
[".htm", "text/html"],
|
||||
[".css", "text/css"],
|
||||
[".pdf", "application/pdf"],
|
||||
];
|
||||
|
||||
// Infer a MIME type from the URI's file extension when the server didn't supply
|
||||
// one. Returns undefined for unrecognized suffixes so callers fall through to
|
||||
// the octet-stream default.
|
||||
function inferMimeFromUri(uri: string): string | undefined {
|
||||
const path = uri.split("?")[0].split("#")[0];
|
||||
const lower = path.toLowerCase();
|
||||
if (lower.endsWith(".md") || lower.endsWith(".markdown")) {
|
||||
return "text/markdown";
|
||||
for (const [suffix, mime] of URI_SUFFIX_MIME) {
|
||||
if (lower.endsWith(suffix)) return mime;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
|
@ -183,6 +203,17 @@ export function ResourcePreviewPanel({
|
|||
const { uri, annotations } = resource;
|
||||
const mimeType = effectiveMime(contents[0]?.mimeType, resource);
|
||||
|
||||
const [showSource, setShowSource] = useState(false);
|
||||
// Reset to the rendered view when the previewed resource changes (the panel
|
||||
// is reused, not remounted, across resources). React's documented
|
||||
// "adjust state during render" pattern — no effect, so no cascading render.
|
||||
const [prevUri, setPrevUri] = useState(uri);
|
||||
if (uri !== prevUri) {
|
||||
setPrevUri(uri);
|
||||
setShowSource(false);
|
||||
}
|
||||
const sourceToggleable = isSourceToggleable(mimeType);
|
||||
|
||||
return (
|
||||
<PanelStack>
|
||||
<HeaderRow>
|
||||
|
|
@ -199,14 +230,26 @@ export function ResourcePreviewPanel({
|
|||
</HeaderRow>
|
||||
<ContentScroll>
|
||||
<ContentStack>
|
||||
{contents.map((item, index) => (
|
||||
<ContentViewer
|
||||
key={index}
|
||||
block={toContentBlock(item)}
|
||||
mimeType={effectiveMime(item.mimeType, resource)}
|
||||
copyable
|
||||
/>
|
||||
))}
|
||||
{contents.map((item, index) => {
|
||||
const itemMime = effectiveMime(item.mimeType, resource);
|
||||
// The toggle is gated on the first content item but applies per
|
||||
// item: in source mode only the source-toggleable items (the ones
|
||||
// whose rendered view hides their text) switch to plain text, so a
|
||||
// mixed multi-part resource doesn't force an image/PDF blob through
|
||||
// the text decoder.
|
||||
const renderMime =
|
||||
showSource && isSourceToggleable(itemMime)
|
||||
? SOURCE_MIME
|
||||
: itemMime;
|
||||
return (
|
||||
<ContentViewer
|
||||
key={index}
|
||||
contents={item}
|
||||
mimeType={renderMime}
|
||||
copyable
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ContentStack>
|
||||
</ContentScroll>
|
||||
<MetaRow>
|
||||
|
|
@ -227,9 +270,15 @@ export function ResourcePreviewPanel({
|
|||
)}
|
||||
</AnnotationGroup>
|
||||
<ActionGroup>
|
||||
<Button variant="subtle" size="sm" onClick={onRefresh}>
|
||||
Refresh
|
||||
</Button>
|
||||
{sourceToggleable && (
|
||||
<FooterButton
|
||||
aria-pressed={showSource}
|
||||
onClick={() => setShowSource((shown) => !shown)}
|
||||
>
|
||||
{showSource ? "View Rendered" : "View Source"}
|
||||
</FooterButton>
|
||||
)}
|
||||
<FooterButton onClick={onRefresh}>Refresh</FooterButton>
|
||||
{subscriptionsSupported && (
|
||||
<SubscribeButton
|
||||
subscribed={isSubscribed}
|
||||
|
|
|
|||
Loading…
Reference in a new issue