feat: surface validation feedback in playground UI

This commit is contained in:
CREDO23 2026-07-23 18:52:50 +02:00
parent 1ad8efb658
commit 15962d205f
4 changed files with 63 additions and 5 deletions

View file

@ -12,6 +12,7 @@ import { useScraperCapabilities } from "@/hooks/use-scraper-capabilities";
import { scrapersApiService } from "@/lib/apis/scrapers-api.service";
import { AppError } from "@/lib/error";
import { findVerb } from "@/lib/playground/catalog";
import { fieldErrorsFromError } from "@/lib/playground/field-errors";
import { formatCost, formatDuration, formatPricing } from "@/lib/playground/format";
import { buildPayload, initialFormValues, parseSchemaFields } from "@/lib/playground/json-schema";
import {
@ -19,6 +20,7 @@ import {
getAmazonFieldOptions,
hasAmazonFranceValue,
} from "@/lib/playground/platform-overrides/amazon";
import { urlFieldWarnings } from "@/lib/playground/url-hints";
import { ApiReference } from "./api-reference";
import { OutputViewer } from "./output-viewer";
import { RunProgressPanel } from "./run-progress-panel";
@ -73,7 +75,12 @@ function getRunErrorMessage(error: unknown): string {
}
if (status === 422) {
return "Invalid input. Check the fields above and try again.";
if (Object.keys(fieldErrorsFromError(error)).length > 0) {
return "Invalid input. Check the highlighted fields.";
}
return error instanceof Error && error.message
? error.message
: "Invalid input. Check the fields above and try again.";
}
return error instanceof Error && error.message
@ -123,6 +130,7 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
const fields = useMemo(() => parseSchemaFields(capability?.input_schema), [capability]);
const [values, setValues] = useState<Record<string, unknown>>({});
const [fieldErrors, setFieldErrors] = useState<Record<string, string>>({});
const run = useRunStream(workspaceId);
const isRunning = run.status === "running";
const previousStatusRef = useRef(run.status);
@ -164,9 +172,15 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
const handleChange = (name: string, value: unknown) => {
setValues((prev) => ({ ...prev, [name]: value }));
setFieldErrors((prev) => {
if (!(name in prev)) return prev;
const { [name]: _cleared, ...rest } = prev;
return rest;
});
};
const handleRun = useCallback(() => {
setFieldErrors({});
const payload = buildPayload(fields, values);
void run.start(platform, verb, payload);
}, [fields, values, platform, verb, run]);
@ -178,6 +192,7 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
const endpoint = `POST /workspaces/${workspaceId}/scrapers/${platform}/${verb}`;
const isAmazonScrape = platform === "amazon" && verb === "scrape";
const hasAmazonFranceUrl = useMemo(() => hasAmazonFranceValue(values), [values]);
const fieldWarnings = useMemo(() => urlFieldWarnings(platform, values), [platform, values]);
useEffect(() => {
const previousStatus = previousStatusRef.current;
@ -194,6 +209,7 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
}
if (run.status === "error") {
setFieldErrors(fieldErrorsFromError(run.error));
const key = `${run.runId ?? "run"}:error`;
if (notifiedRunRef.current === key) return;
notifiedRunRef.current = key;
@ -268,6 +284,8 @@ export function PlaygroundRunner({ workspaceId, platform, verb }: PlaygroundRunn
onChange={handleChange}
disabled={isRunning}
getFieldOptions={isAmazonScrape ? getAmazonFieldOptions : undefined}
fieldErrors={fieldErrors}
fieldWarnings={fieldWarnings}
/>
{isAmazonScrape ? <AmazonMarketplaceHint showFranceWarning={hasAmazonFranceUrl} /> : null}

View file

@ -32,6 +32,8 @@ interface SchemaFormProps {
getFieldOptions?: FieldOptionsResolver;
/** Field names flagged by a 422 response, shown with error styling. */
fieldErrors?: Record<string, string>;
/** Client-side, non-blocking URL warnings keyed by field name. */
fieldWarnings?: Record<string, string>;
}
function FieldControl({
@ -147,6 +149,7 @@ function FieldRow({
onChange,
disabled,
error,
warning,
options,
}: {
field: FormField;
@ -154,6 +157,7 @@ function FieldRow({
onChange: (value: unknown) => void;
disabled?: boolean;
error?: string;
warning?: string;
options?: FieldOption[];
}) {
return (
@ -179,6 +183,7 @@ function FieldRow({
options={options}
/>
{error && <p className="text-xs text-destructive">{error}</p>}
{!error && warning && <p className="text-xs text-amber-600 dark:text-amber-500">{warning}</p>}
</div>
);
}
@ -190,6 +195,7 @@ export function SchemaForm({
disabled,
getFieldOptions,
fieldErrors,
fieldWarnings,
}: SchemaFormProps) {
const [showAdvanced, setShowAdvanced] = useState(false);
@ -209,6 +215,7 @@ export function SchemaForm({
onChange={(value) => onChange(field.name, value)}
disabled={disabled}
error={fieldErrors?.[field.name]}
warning={fieldWarnings?.[field.name]}
options={getFieldOptions?.(field)}
/>
))}
@ -239,6 +246,7 @@ export function SchemaForm({
onChange={(value) => onChange(field.name, value)}
disabled={disabled}
error={fieldErrors?.[field.name]}
warning={fieldWarnings?.[field.name]}
options={getFieldOptions?.(field)}
/>
))}

View file

@ -8,8 +8,18 @@ import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { isHttpUrl } from "@/lib/url";
import { cn } from "@/lib/utils";
import type { ConnectorConfigProps } from "../index";
/** Return the malformed (non-http/https) lines from a newline-separated URL list. */
function invalidUrlLines(value: string): string[] {
return value
.split("\n")
.map((line) => line.trim())
.filter((line) => line && !isHttpUrl(line));
}
export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfigChange }) => {
// Initialize with existing config values
const existingApiKey = (connector.config?.FIRECRAWL_API_KEY as string | undefined) || "";
@ -19,6 +29,8 @@ export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfig
const [initialUrls, setInitialUrls] = useState(existingUrls);
const [showApiKey, setShowApiKey] = useState(false);
const invalidUrls = invalidUrlLines(initialUrls);
const handleApiKeyChange = (value: string) => {
setApiKey(value);
if (onConfigChange) {
@ -108,11 +120,21 @@ export const WebcrawlerConfig: FC<ConnectorConfigProps> = ({ connector, onConfig
placeholder="https://example.com&#10;https://docs.example.com&#10;https://blog.example.com"
value={initialUrls}
onChange={(e) => handleUrlsChange(e.target.value)}
className="min-h-[100px] font-mono text-xs sm:text-sm bg-slate-400/5 dark:bg-white/5 border-slate-400/20 resize-none"
aria-invalid={invalidUrls.length > 0}
className={cn(
"min-h-[100px] font-mono text-xs sm:text-sm bg-slate-400/5 dark:bg-white/5 border-slate-400/20 resize-none",
invalidUrls.length > 0 && "border-destructive"
)}
/>
<p className="text-[10px] sm:text-xs text-muted-foreground">
Enter URLs to crawl (one per line). You can add more URLs later.
</p>
{invalidUrls.length > 0 ? (
<p className="text-[10px] sm:text-xs text-destructive">
Not valid http(s) URLs: {invalidUrls.join(", ")}
</p>
) : (
<p className="text-[10px] sm:text-xs text-muted-foreground">
Enter URLs to crawl (one per line). You can add more URLs later.
</p>
)}
</div>
{/* Info Alert */}

View file

@ -12,3 +12,13 @@ export function tryGetHostname(url: string): string | undefined {
return undefined;
}
}
/** True when the value parses as an http(s) URL — mirrors the backend's boundary rule. */
export function isHttpUrl(value: string): boolean {
try {
const { protocol } = new URL(value);
return protocol === "http:" || protocol === "https:";
} catch {
return false;
}
}