mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-07-26 23:51:14 +02:00
feat: surface validation feedback in playground UI
This commit is contained in:
parent
1ad8efb658
commit
15962d205f
4 changed files with 63 additions and 5 deletions
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -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 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"
|
||||
)}
|
||||
/>
|
||||
<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 */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue