From 15962d205f8bbd09d92014850b99223fe113d27d Mon Sep 17 00:00:00 2001 From: CREDO23 Date: Thu, 23 Jul 2026 18:52:50 +0200 Subject: [PATCH] feat: surface validation feedback in playground UI --- .../components/playground-runner.tsx | 20 ++++++++++++- .../playground/components/schema-form.tsx | 8 +++++ .../components/webcrawler-config.tsx | 30 ++++++++++++++++--- surfsense_web/lib/url.ts | 10 +++++++ 4 files changed, 63 insertions(+), 5 deletions(-) diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx index 32624ca55..6fbf7b98b 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/playground-runner.tsx @@ -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>({}); + const [fieldErrors, setFieldErrors] = useState>({}); 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 ? : null} diff --git a/surfsense_web/app/dashboard/[workspace_id]/playground/components/schema-form.tsx b/surfsense_web/app/dashboard/[workspace_id]/playground/components/schema-form.tsx index cdd425925..c86dd9839 100644 --- a/surfsense_web/app/dashboard/[workspace_id]/playground/components/schema-form.tsx +++ b/surfsense_web/app/dashboard/[workspace_id]/playground/components/schema-form.tsx @@ -32,6 +32,8 @@ interface SchemaFormProps { getFieldOptions?: FieldOptionsResolver; /** Field names flagged by a 422 response, shown with error styling. */ fieldErrors?: Record; + /** Client-side, non-blocking URL warnings keyed by field name. */ + fieldWarnings?: Record; } 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 &&

{error}

} + {!error && warning &&

{warning}

} ); } @@ -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)} /> ))} diff --git a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/webcrawler-config.tsx b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/webcrawler-config.tsx index b7fbae91c..5a4540bcf 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/webcrawler-config.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/connector-configs/components/webcrawler-config.tsx @@ -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 = ({ 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 = ({ 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 = ({ connector, onConfig placeholder="https://example.com https://docs.example.com 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" + )} /> -

- Enter URLs to crawl (one per line). You can add more URLs later. -

+ {invalidUrls.length > 0 ? ( +

+ Not valid http(s) URLs: {invalidUrls.join(", ")} +

+ ) : ( +

+ Enter URLs to crawl (one per line). You can add more URLs later. +

+ )} {/* Info Alert */} diff --git a/surfsense_web/lib/url.ts b/surfsense_web/lib/url.ts index 7bb0488d0..c351d1b18 100644 --- a/surfsense_web/lib/url.ts +++ b/surfsense_web/lib/url.ts @@ -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; + } +}