Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions client/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -341,7 +341,10 @@ const App = () => {
request: {
id: nextRequestId.current,
message: request.params.message,
mode: request.params.mode,
requestedSchema: request.params.requestedSchema,
url: request.params.url,
elicitationId: request.params.elicitationId,
},
originatingTab: currentTab,
resolve,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,22 @@ import { JsonSchemaType, JsonValue } from "@/utils/jsonUtils";
import { generateDefaultValue } from "@/utils/schemaUtils";
import {
PendingElicitationRequest,
FormElicitationRequestData,
ElicitationResponse,
} from "./ElicitationTab";
import Ajv from "ajv";

export type ElicitationRequestProps = {
request: PendingElicitationRequest;
export type ElicitationFormRequestProps = {
request: PendingElicitationRequest & {
request: FormElicitationRequestData;
};
onResolve: (id: number, response: ElicitationResponse) => void;
};

const ElicitationRequest = ({
const ElicitationFormRequest = ({
request,
onResolve,
}: ElicitationRequestProps) => {
}: ElicitationFormRequestProps) => {
const [formData, setFormData] = useState<JsonValue>({});
const [validationError, setValidationError] = useState<string | null>(null);

Expand Down Expand Up @@ -170,4 +173,4 @@ const ElicitationRequest = ({
);
};

export default ElicitationRequest;
export default ElicitationFormRequest;
91 changes: 81 additions & 10 deletions client/src/components/ElicitationTab.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,28 @@
import { Alert, AlertDescription } from "@/components/ui/alert";
import { TabsContent } from "@/components/ui/tabs";
import { Button } from "@/components/ui/button";
import { JsonSchemaType } from "@/utils/jsonUtils";
import ElicitationRequest from "./ElicitationRequest";
import ElicitationFormRequest from "@/components/ElicitationFormRequest.tsx";
import ElicitationUrlRequest from "@/components/ElicitationUrlRequest.tsx";

export interface ElicitationRequestData {
export type FormElicitationRequestData = {
mode?: "form";
id: number;
message: string;
requestedSchema: JsonSchemaType;
}
};

export type UrlElicitationRequestData = {
mode: "url";
id: number;
message: string;
url: string;
elicitationId: string;
};

export type ElicitationRequestData =
| FormElicitationRequestData
| UrlElicitationRequestData;

export interface ElicitationResponse {
action: "accept" | "decline" | "cancel";
Expand All @@ -25,6 +40,23 @@ export type Props = {
onResolve: (id: number, response: ElicitationResponse) => void;
};

const isFormRequest = (
req: PendingElicitationRequest,
): req is PendingElicitationRequest & {
request: FormElicitationRequestData;
} => {
const mode = req.request.mode;
return mode === undefined || mode === null || mode === "form";
};

const isUrlElicitationRequest = (
req: PendingElicitationRequest,
): req is PendingElicitationRequest & {
request: UrlElicitationRequestData;
} => {
return req.request.mode === "url";
};

const ElicitationTab = ({ pendingRequests, onResolve }: Props) => {
return (
<TabsContent value="elicitations">
Expand All @@ -37,13 +69,52 @@ const ElicitationTab = ({ pendingRequests, onResolve }: Props) => {
</Alert>
<div className="mt-4 space-y-4">
<h3 className="text-lg font-semibold">Recent Requests</h3>
{pendingRequests.map((request) => (
<ElicitationRequest
key={request.id}
request={request}
onResolve={onResolve}
/>
))}
{pendingRequests.map((request) => {
if (isFormRequest(request)) {
return (
<ElicitationFormRequest
key={request.id}
request={request}
onResolve={onResolve}
/>
);
} else if (isUrlElicitationRequest(request)) {
return (
<ElicitationUrlRequest
key={request.id}
request={request}
onResolve={onResolve}
/>
);
}
return (
<div
key={request.id}
className="flex flex-col gap-3 p-4 border rounded-lg"
>
<p className="text-sm">
Unsupported elicitation mode. You can decline or cancel this
request.
</p>
<div className="flex space-x-2">
<Button
type="button"
variant="outline"
onClick={() => onResolve(request.id, { action: "decline" })}
>
Decline
</Button>
<Button
type="button"
variant="outline"
onClick={() => onResolve(request.id, { action: "cancel" })}
>
Cancel
</Button>
</div>
</div>
);
})}
{pendingRequests.length === 0 && (
<p className="text-gray-500">No pending requests</p>
)}
Expand Down
165 changes: 165 additions & 0 deletions client/src/components/ElicitationUrlRequest.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import {
ElicitationResponse,
PendingElicitationRequest,
UrlElicitationRequestData,
} from "@/components/ElicitationTab.tsx";
import JsonView from "@/components/JsonView.tsx";
import { Button } from "@/components/ui/button.tsx";
import { CheckCheck, Copy } from "lucide-react";
import useCopy from "@/lib/hooks/useCopy.ts";
import { toast } from "@/lib/hooks/useToast.ts";

export type ElicitationUrlRequestProps = {
request: PendingElicitationRequest & {
request: UrlElicitationRequestData;
};
onResolve: (id: number, response: ElicitationResponse) => void;
};

const ElicitationUrlRequest = ({
request,
onResolve,
}: ElicitationUrlRequestProps) => {
const { copied, setCopied } = useCopy();

const parsedUrl = (() => {
try {
return new URL(request.request.url);
} catch {
return null;
}
})();

const handleAcceptAndOpen = () => {
if (!parsedUrl) {
return;
}

window.open(parsedUrl.href, "_blank", "noopener,noreferrer");

onResolve(request.id, {
action: "accept",
});
};

const handleAccept = () => {
onResolve(request.id, {
action: "accept",
});
};

const handleDecline = () => {
onResolve(request.id, { action: "decline" });
};

const handleCancel = () => {
onResolve(request.id, { action: "cancel" });
};

const warnings = (() => {
if (!parsedUrl) {
return [];
}

const warnings: string[] = [];

if (parsedUrl.protocol !== "https:") {
warnings.push("Not HTTPS protocol");
}

if (parsedUrl.hostname.includes("xn--")) {
warnings.push("This URL contains internationalized (non-ASCII) characters");
}
return warnings;
})();

const domain = (() => {
if (parsedUrl) {
return parsedUrl.hostname;
}
console.error("Invalid URL in elicitation request.");
return "Invalid URL";
})();

return (
<div
data-testid="elicitation-request"
className="flex gap-4 p-4 border rounded-lg space-y-4"
>
<div className="flex-1 bg-gray-50 dark:bg-gray-800 dark:text-gray-100 p-2 rounded">
<div className="space-y-2">
<div className="mt-2">
<h5 className="text-xs font-medium mb-1">Request Schema:</h5>
<JsonView
data={JSON.stringify(
request.request,
["message", "url", "elicitationId"],
2,
)}
/>
</div>
</div>
</div>

<div className="flex-1 space-y-6">
<div className="space-y-3">
{warnings.length > 0 &&
warnings.map((msg, index) => (
<div
key={index}
className="bg-yellow-100 border-l-4 border-yellow-500 p-2 text-xs text-yellow-700 dark:bg-yellow-900 dark:text-yellow-200"
>
{msg}
</div>
))}
<p className="text-sm">{request.request.message}</p>
<p className="text-sm font-semibold">Domain: {domain}</p>
<p className="text-xs text-gray-600">
Full URL: {request.request.url}
</p>
</div>
<div className="flex space-x-2">
<Button
type="button"
onClick={handleAcceptAndOpen}
disabled={!parsedUrl}
>
Accept and open
</Button>
<Button type="button" onClick={handleAccept}>
Accept
</Button>
<Button type="button" variant="outline" onClick={handleDecline}>
Decline
</Button>
<Button type="button" variant="outline" onClick={handleCancel}>
Cancel
</Button>
<Button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(request.request.url);
setCopied(true);
} catch (error) {
toast({
title: "Error",
description: `There was an error copying url to the clipboard: ${error instanceof Error ? error.message : String(error)}`,
});
}
}}
>
{copied ? (
<CheckCheck className="h-4 w-4 mr-2 dark:text-green-700 text-green-600" />
) : (
<Copy className="h-4 w-4 mr-2" />
)}
Copy URL
</Button>
</div>
</div>
</div>
);
};

export default ElicitationUrlRequest;
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { render, screen, fireEvent, act } from "@testing-library/react";
import "@testing-library/jest-dom";
import { describe, it, jest, beforeEach, afterEach } from "@jest/globals";
import ElicitationRequest from "../ElicitationRequest";
import { PendingElicitationRequest } from "../ElicitationTab";
import ElicitationFormRequest from "../ElicitationFormRequest";
import {
FormElicitationRequestData,
PendingElicitationRequest,
} from "../ElicitationTab";

jest.mock("../DynamicJsonForm", () => {
return function MockDynamicJsonForm({
Expand Down Expand Up @@ -38,6 +41,10 @@ jest.mock("../DynamicJsonForm", () => {
describe("ElicitationRequest", () => {
const mockOnResolve = jest.fn();

type FormPendingElicitationRequest = PendingElicitationRequest & {
request: FormElicitationRequestData;
};

beforeEach(() => {
jest.clearAllMocks();
});
Expand All @@ -47,8 +54,8 @@ describe("ElicitationRequest", () => {
});

const createMockRequest = (
overrides: Partial<PendingElicitationRequest> = {},
): PendingElicitationRequest => ({
overrides: Partial<FormPendingElicitationRequest> = {},
): FormPendingElicitationRequest => ({
id: 1,
request: {
id: 1,
Expand All @@ -66,10 +73,10 @@ describe("ElicitationRequest", () => {
});

const renderElicitationRequest = (
request: PendingElicitationRequest = createMockRequest(),
request: FormPendingElicitationRequest = createMockRequest(),
) => {
return render(
<ElicitationRequest request={request} onResolve={mockOnResolve} />,
<ElicitationFormRequest request={request} onResolve={mockOnResolve} />,
);
};

Expand Down
Loading