fix(web): unify processor creation and entity detail loading states

This commit is contained in:
Hyu
2026-09-15 13:51:39 +08:00
parent d497defbf7
commit 5c98329170
32 changed files with 729 additions and 154 deletions
+11 -6
View File
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@@ -43,6 +44,8 @@ export default function AgentDetailContent({ id }: { id: string }) {
const { refreshPipelines, pipelines, setDetailEntityName } = useSidebarData();
const [agent, setAgent] = useState<Agent | null>(null);
const [platformTools, setPlatformTools] = useState<AgentPlatformTool[]>([]);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [loading, setLoading] = useState(!isCreateMode);
const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false);
@@ -77,6 +80,7 @@ export default function AgentDetailContent({ id }: { id: string }) {
if (isCreateMode) return;
let cancelled = false;
setLoading(true);
setLoadFailed(false);
Promise.all([
httpClient.getAgent(id),
httpClient.getAdapters().catch(() => ({ adapters: [] })),
@@ -97,13 +101,16 @@ export default function AgentDetailContent({ id }: { id: string }) {
);
setAgent(resp.agent);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [id, isCreateMode]);
}, [id, isCreateMode, loadAttempt]);
if (isCreateMode) {
return (
@@ -116,13 +123,11 @@ export default function AgentDetailContent({ id }: { id: string }) {
);
}
if (loading || !agent) {
if (loadFailed)
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
{t('common.loading')}
</div>
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
}
if (loading || !agent) return <EntityLoadState />;
if (agent.kind === 'pipeline') {
return <PipelineDetailContent id={id} routeBase="/home/agents" />;
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Link, useSearchParams } from 'react-router-dom';
@@ -79,6 +80,7 @@ export default function PluginProcessorDetailContent({
const [events, setEvents] = useState<ProcessorRunEvent[]>([]);
const [eventCursor, setEventCursor] = useState<number | null>(null);
const [loading, setLoading] = useState(true);
const [initialLoadComplete, setInitialLoadComplete] = useState(false);
const [saving, setSaving] = useState(false);
const [pagingRuns, setPagingRuns] = useState(false);
const [pagingEvents, setPagingEvents] = useState(false);
@@ -89,6 +91,7 @@ export default function PluginProcessorDetailContent({
const available = Boolean(component);
const load = useCallback(async () => {
setLoading(true);
setFailed(false);
try {
const [metadata, page] = await Promise.all([
@@ -99,6 +102,7 @@ export default function PluginProcessorDetailContent({
setPlatformTools(metadata.platform_tools ?? []);
setRuns(page.items);
setCursor(page.has_more ? page.next_cursor : null);
setInitialLoadComplete(true);
} catch {
setFailed(true);
} finally {
@@ -385,6 +389,9 @@ export default function PluginProcessorDetailContent({
</div>
);
if (!initialLoadComplete)
return <EntityLoadState error={failed} onRetry={() => void load()} />;
return (
<ProcessorDetailWorkbench
title={`${agent.emoji || '🧩'} ${agent.name}`}
@@ -121,11 +121,7 @@ export default function AgentCreateContent({
form="agent-create-form"
disabled={form.formState.isSubmitting}
>
{t(
kind === 'event_processor'
? 'agents.eventProcessor.create'
: 'common.submit',
)}
{t('common.submit')}
</Button>
</div>
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import {
forwardRef,
type ForwardedRef,
@@ -145,6 +146,8 @@ function AgentFormComponent(
useState<ApiRespPluginSystemStatus | null>(null);
const [pluginStatusLoading, setPluginStatusLoading] = useState(true);
const [pluginStatusError, setPluginStatusError] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [initialDataLoaded, setInitialDataLoaded] = useState(false);
const [runnerInstallRecovering, setRunnerInstallRecovering] = useState(false);
const [activeSection, setActiveSection] =
@@ -208,6 +211,8 @@ function AgentFormComponent(
useEffect(() => {
let cancelled = false;
setInitialDataLoaded(false);
setLoadFailed(false);
Promise.all([httpClient.getAgentMetadata(), httpClient.getAgent(agentId)])
.then(([metadata, resp]) => {
if (cancelled) return;
@@ -274,12 +279,14 @@ function AgentFormComponent(
setInitialDataLoaded(true);
})
.catch((err) => {
if (cancelled) return;
setLoadFailed(true);
toast.error(t('agents.loadError') + err.msg);
});
return () => {
cancelled = true;
};
}, [agentId, form, t]);
}, [agentId, form, t, loadAttempt]);
useEffect(() => {
if (!initialDataLoaded || !readPendingRunnerInstall(runnerInstallScope)) {
@@ -390,7 +397,8 @@ function AgentFormComponent(
];
const runnerStatus = useMemo<RunnerStatus>(() => {
if (pluginStatusLoading) {
if (loadFailed) return { label: t('common.loadFailed'), tone: 'error' };
if (!initialDataLoaded || pluginStatusLoading) {
return {
label: t('agents.runnerStatusLoading'),
tone: 'neutral',
@@ -459,6 +467,8 @@ function AgentFormComponent(
tone: 'success',
};
}, [
initialDataLoaded,
loadFailed,
currentRunner,
pluginStatusError,
pluginStatusLoading,
@@ -635,6 +645,7 @@ function AgentFormComponent(
}
},
async save() {
if (!initialDataLoaded || loadFailed) return false;
if (!hasUnsavedChangesRef.current) return true;
if (isSavingRef.current) return false;
const valid = await form.trigger();
@@ -642,9 +653,15 @@ function AgentFormComponent(
return (await saveValues(form.getValues())) ?? false;
},
}),
[form, saveValues],
[form, initialDataLoaded, loadFailed, saveValues],
);
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!initialDataLoaded) return <EntityLoadState />;
return (
<div className="h-full p-0 flex flex-col">
<Form {...form}>
+1 -1
View File
@@ -8,7 +8,7 @@ export default function AgentsPage() {
const detailId = searchParams.get('id');
if (detailId) {
return <AgentDetailContent id={detailId} />;
return <AgentDetailContent key={detailId} id={detailId} />;
}
return (
+19 -6
View File
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useState, useEffect, useRef, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
@@ -59,6 +60,8 @@ export default function BotDetailContent({ id }: { id: string }) {
const [adapterLabel, setAdapterLabel] = useState('');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [bot, setBot] = useState<Bot | null>(null);
const [isRefreshingSessions, setIsRefreshingSessions] = useState(false);
const sessionMonitorRef = useRef<BotSessionMonitorHandle>(null);
@@ -74,13 +77,17 @@ export default function BotDetailContent({ id }: { id: string }) {
// Fetch bot enable state
useEffect(() => {
if (!isCreateMode) {
httpClient.getBot(id).then((res) => {
setBot(res.bot);
setBotEnabled(res.bot.enable ?? true);
setEnableLoaded(true);
});
setLoadFailed(false);
httpClient
.getBot(id)
.then((res) => {
setBot(res.bot);
setBotEnabled(res.bot.enable ?? true);
setEnableLoaded(true);
})
.catch(() => setLoadFailed(true));
}
}, [id, isCreateMode]);
}, [id, isCreateMode, loadAttempt]);
const handleEnableToggle = useCallback(
async (checked: boolean) => {
@@ -178,6 +185,12 @@ export default function BotDetailContent({ id }: { id: string }) {
);
}
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!enableLoaded) return <EntityLoadState />;
// ==================== Edit Mode ====================
return (
<>
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { showBotError } from '../../bot-error';
import React, {
forwardRef,
@@ -137,6 +138,9 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
// Track whether initial data loading is complete.
// setValue calls during init should NOT mark the form as dirty.
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [initialDataLoaded, setInitialDataLoaded] = useState(false);
const isInitializing = useRef(true);
const [adapterNameToDynamicConfigMap, setAdapterNameToDynamicConfigMap] =
@@ -225,49 +229,55 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
useEffect(() => {
setBotFormValues();
}, []);
}, [initBotId, loadAttempt]);
function setBotFormValues() {
setInitialDataLoaded(false);
setLoadFailed(false);
isInitializing.current = true;
initBotFormComponent().then(() => {
if (initBotId) {
getBotConfig(initBotId)
.then((val) => {
// Use form.reset() to set values AND update the dirty baseline,
// so isDirty stays false after initial load.
form.reset({
name: val.name,
description: val.description,
adapter: val.adapter,
adapter_config: val.adapter_config,
enable: val.enable,
event_bindings: val.event_bindings || [],
plugin_processors: val.plugin_processors || [],
});
handleAdapterSelect(val.adapter);
initBotFormComponent()
.then(() => {
if (initBotId) {
return getBotConfig(initBotId)
.then((val) => {
// Use form.reset() to set values AND update the dirty baseline,
// so isDirty stays false after initial load.
form.reset({
name: val.name,
description: val.description,
adapter: val.adapter,
adapter_config: val.adapter_config,
enable: val.enable,
event_bindings: val.event_bindings || [],
plugin_processors: val.plugin_processors || [],
});
handleAdapterSelect(val.adapter);
if (val.webhook_full_url) {
setWebhookUrl(val.webhook_full_url);
} else {
setWebhookUrl('');
}
setExtraWebhookUrl(val.extra_webhook_full_url || '');
})
.catch((err) => {
toast.error(
t('bots.getBotConfigError') + (err as CustomApiError).msg,
);
})
.finally(() => {
isInitializing.current = false;
});
} else {
form.reset();
setWebhookUrl('');
setExtraWebhookUrl('');
isInitializing.current = false;
}
});
if (val.webhook_full_url) {
setWebhookUrl(val.webhook_full_url);
} else {
setWebhookUrl('');
}
setExtraWebhookUrl(val.extra_webhook_full_url || '');
})
.catch((err) => {
setLoadFailed(true);
toast.error(
t('bots.getBotConfigError') + (err as CustomApiError).msg,
);
})
.finally(() => {
isInitializing.current = false;
});
} else {
form.reset();
setWebhookUrl('');
setExtraWebhookUrl('');
isInitializing.current = false;
}
})
.catch(() => setLoadFailed(true))
.finally(() => setInitialDataLoaded(true));
}
async function initBotFormComponent() {
@@ -460,6 +470,12 @@ const BotForm = forwardRef<BotFormHandle, BotFormProps>(function BotForm(
}
}
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!initialDataLoaded) return <EntityLoadState />;
return (
<Form {...form}>
<form
+1 -1
View File
@@ -8,7 +8,7 @@ export default function BotConfigPage() {
const detailId = searchParams.get('id');
if (detailId) {
return <BotDetailContent id={detailId} />;
return <BotDetailContent key={detailId} id={detailId} />;
}
return (
+12 -1
View File
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
@@ -57,16 +58,20 @@ export default function KBDetailContent({ id }: { id: string }) {
const [activeTab, setActiveTab] = useState('metadata');
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showBasicInfoDialog, setShowBasicInfoDialog] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [kbInfo, setKbInfo] = useState<KnowledgeBase | null>(null);
const [formDirty, setFormDirty] = useState(false);
const [formVersion, setFormVersion] = useState(0);
const loadKbInfo = useCallback(
async (kbId: string) => {
setLoadFailed(false);
try {
const resp = await httpClient.getKnowledgeBase(kbId);
setKbInfo(resp.base);
} catch (e) {
setLoadFailed(true);
console.error('Failed to load KB info:', e);
toast.error(
t('knowledge.loadKnowledgeBaseFailed') + (e as CustomApiError).msg,
@@ -81,7 +86,7 @@ export default function KBDetailContent({ id }: { id: string }) {
if (!isCreateMode) {
loadKbInfo(id);
}
}, [id, isCreateMode, loadKbInfo]);
}, [id, isCreateMode, loadKbInfo, loadAttempt]);
const hasDocumentCapability = (): boolean => {
if (!kbInfo || !kbInfo.knowledge_engine) return false;
@@ -179,6 +184,12 @@ export default function KBDetailContent({ id }: { id: string }) {
);
}
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!kbInfo) return <EntityLoadState />;
// ==================== Edit Mode ====================
return (
<>
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
@@ -94,6 +95,9 @@ export default function KBForm({
Record<string, unknown>
>({});
const [isEditing, setIsEditing] = useState(Boolean(initKbId));
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [initialDataLoaded, setInitialDataLoaded] = useState(false);
const [loading, setLoading] = useState(true);
// Dirty tracking: snapshot of saved state for comparison
@@ -144,12 +148,15 @@ export default function KBForm({
};
useEffect(() => {
loadRagEngines().then(() => {
if (initKbId) {
loadKbConfig(initKbId);
}
});
}, []);
setInitialDataLoaded(false);
setLoadFailed(false);
loadRagEngines()
.then(() => {
if (initKbId) return loadKbConfig(initKbId);
})
.catch(() => setLoadFailed(true))
.finally(() => setInitialDataLoaded(true));
}, [initKbId, loadAttempt]);
// Auto-select first engine when engines are loaded and no selection
useEffect(() => {
@@ -178,7 +185,7 @@ export default function KBForm({
const resp = await httpClient.getKnowledgeEngines();
setRagEngines(resp.engines);
} catch (err) {
console.error('Failed to load Knowledge Engines:', err);
throw err;
} finally {
setLoading(false);
}
@@ -211,8 +218,8 @@ export default function KBForm({
isInitializing.current = false;
}, 500);
} catch (err) {
console.error('Failed to load KB config:', err);
isInitializing.current = false;
throw err;
}
};
@@ -321,6 +328,12 @@ export default function KBForm({
[selectedEngine?.retrieval_schema],
);
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!initialDataLoaded) return <EntityLoadState />;
return (
<Form {...form}>
<form
+1 -1
View File
@@ -48,7 +48,7 @@ export default function KnowledgePage() {
externalKbCount={migrationExternalCount}
onMigrationComplete={handleMigrationComplete}
/>
<KBDetailContent id={detailId} />
<KBDetailContent key={detailId} id={detailId} />
</>
);
}
+20 -7
View File
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate } from 'react-router-dom';
import { Button } from '@/components/ui/button';
@@ -74,6 +75,8 @@ export default function MCPDetailContent({ id }: { id: string }) {
// Enable state managed here so the header switch works
const [serverEnabled, setServerEnabled] = useState(true);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [enableLoaded, setEnableLoaded] = useState(false);
const [detailRuntimeStatus, setDetailRuntimeStatus] =
useState<MCPRuntimeState | null>(null);
@@ -120,14 +123,18 @@ export default function MCPDetailContent({ id }: { id: string }) {
useEffect(() => {
if (!isCreateMode) {
setDetailRuntimeStatus(null);
httpClient.getMCPServer(id).then((res) => {
const server = res.server ?? res;
setServerEnabled(server.enable ?? true);
setDetailRuntimeStatus(server.runtime_info?.status ?? null);
setEnableLoaded(true);
});
setLoadFailed(false);
httpClient
.getMCPServer(id)
.then((res) => {
const server = res.server ?? res;
setServerEnabled(server.enable ?? true);
setDetailRuntimeStatus(server.runtime_info?.status ?? null);
setEnableLoaded(true);
})
.catch(() => setLoadFailed(true));
}
}, [id, isCreateMode]);
}, [id, isCreateMode, loadAttempt]);
const handleEnableToggle = useCallback(
async (checked: boolean) => {
@@ -325,6 +332,12 @@ export default function MCPDetailContent({ id }: { id: string }) {
);
// ==================== Edit Mode ====================
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!enableLoaded) return <EntityLoadState />;
return (
<>
<div className="flex h-full flex-col">
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import React, {
type ReactNode,
useState,
@@ -563,6 +564,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
const pollingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const watchMode = form.watch('mode');
const {
loading: boxLoading,
available: boxAvailable,
hint: boxHint,
reason: boxReason,
@@ -577,6 +579,9 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
watchMode === 'stdio' && mcpStdioEnabled && !boxAvailable;
const stdioBlocked = stdioBlockedByPolicy || stdioBlockedByBox;
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [initialDataLoaded, setInitialDataLoaded] = useState(!isEditMode);
const { isDirty } = form.formState;
useEffect(() => {
onDirtyChange?.(isDirty);
@@ -606,10 +611,13 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
);
useEffect(() => {
setLoadFailed(false);
setInitialDataLoaded(!isEditMode);
isInitializing.current = true;
if (isEditMode && initServerName) {
loadServerForEdit(initServerName).finally(() => {
isInitializing.current = false;
setInitialDataLoaded(true);
});
} else {
form.reset({
@@ -636,7 +644,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
pollingIntervalRef.current = null;
}
};
}, [initServerName]);
}, [initServerName, loadAttempt]);
useEffect(() => {
if (!onDraftChange || isEditMode) return;
@@ -756,6 +764,7 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
setRuntimeInfo(server.runtime_info ?? null);
setReadme(server.readme ?? '');
} catch (error) {
setLoadFailed(true);
console.error('Failed to load server:', error);
toast.error(t('mcp.loadFailed'));
}
@@ -1337,6 +1346,12 @@ const MCPForm = forwardRef<MCPFormHandle, MCPFormProps>(function MCPForm(
runtimePanel
);
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!initialDataLoaded || boxLoading) return <EntityLoadState />;
if (layout === 'split') {
return (
<Form {...form}>
+1 -1
View File
@@ -8,7 +8,7 @@ export default function MCPPage() {
const detailId = searchParams.get('id');
if (detailId) {
return <MCPDetailContent id={detailId} />;
return <MCPDetailContent key={detailId} id={detailId} />;
}
return (
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { toast } from 'sonner';
@@ -52,6 +53,8 @@ export default function PipelineDetailContent({
const [formDirty, setFormDirty] = useState(false);
const [formSaving, setFormSaving] = useState(false);
const [basicInfoOpen, setBasicInfoOpen] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [pipelineDetails, setPipelineDetails] = useState<Pipeline | null>(null);
const pipelineFormRef = useRef<PipelineFormHandle>(null);
const sidebarPipeline = pipelines.find((item) => item.id === id);
@@ -59,13 +62,17 @@ export default function PipelineDetailContent({
useEffect(() => {
if (isCreateMode) return;
let cancelled = false;
httpClient.getPipeline(id).then((response) => {
if (!cancelled) setPipelineDetails(response.pipeline);
});
setLoadFailed(false);
httpClient
.getPipeline(id)
.then((response) => {
if (!cancelled) setPipelineDetails(response.pipeline);
})
.catch(() => setLoadFailed(true));
return () => {
cancelled = true;
};
}, [id, isCreateMode]);
}, [id, isCreateMode, loadAttempt]);
function handleFinish() {
refreshPipelines();
@@ -137,6 +144,12 @@ export default function PipelineDetailContent({
navigate(routeBase);
}
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!pipelineDetails) return <EntityLoadState />;
// ==================== Edit Mode ====================
const pipelineName =
pipelineDetails?.name ||
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import {
forwardRef,
useCallback,
@@ -211,6 +212,8 @@ const PipelineFormComponent = forwardRef<
useState<PipelineConfigTab>();
const [outputConfigTabSchema, setOutputConfigTabSchema] =
useState<PipelineConfigTab>();
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [metadataLoaded, setMetadataLoaded] = useState(false);
const [pipelineLoaded, setPipelineLoaded] = useState(!isEditMode);
@@ -257,28 +260,37 @@ const PipelineFormComponent = forwardRef<
}, [hasUnsavedChanges, onDirtyChange]);
useEffect(() => {
let cancelled = false;
setLoadFailed(false);
setMetadataLoaded(false);
setPipelineLoaded(!isEditMode);
// get config schema from metadata
httpClient.getGeneralPipelineMetadata().then((resp) => {
for (const config of resp.configs) {
if (config.name === 'ai') {
setAIConfigTabSchema(config);
} else if (config.name === 'trigger') {
setTriggerConfigTabSchema(config);
} else if (config.name === 'safety') {
setSafetyConfigTabSchema(config);
} else if (config.name === 'output') {
setOutputConfigTabSchema(config);
httpClient
.getGeneralPipelineMetadata()
.then((resp) => {
if (cancelled) return;
for (const config of resp.configs) {
if (config.name === 'ai') {
setAIConfigTabSchema(config);
} else if (config.name === 'trigger') {
setTriggerConfigTabSchema(config);
} else if (config.name === 'safety') {
setSafetyConfigTabSchema(config);
} else if (config.name === 'output') {
setOutputConfigTabSchema(config);
}
}
}
setMetadataLoaded(true);
});
setMetadataLoaded(true);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
});
if (isEditMode) {
httpClient
.getPipeline(pipelineId || '')
.then((resp: GetPipelineResponseData) => {
if (cancelled) return;
setIsDefaultPipeline(resp.pipeline.is_default ?? false);
const loadedValues = {
@@ -296,9 +308,15 @@ const PipelineFormComponent = forwardRef<
savedSnapshotRef.current = JSON.stringify(loadedValues);
initializedStagesRef.current.clear();
setPipelineLoaded(true);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
});
}
}, [form, isEditMode, pipelineId]);
return () => {
cancelled = true;
};
}, [form, isEditMode, pipelineId, loadAttempt]);
useEffect(() => {
if (
@@ -693,6 +711,12 @@ const PipelineFormComponent = forwardRef<
}
};
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!metadataLoaded || !pipelineLoaded) return <EntityLoadState />;
return (
<>
<div className="h-full p-0 flex flex-col">
+1 -1
View File
@@ -8,7 +8,7 @@ export default function PipelineConfigPage() {
const detailId = searchParams.get('id');
if (detailId) {
return <PipelineDetailContent id={detailId} />;
return <PipelineDetailContent key={detailId} id={detailId} />;
}
return (
+3 -8
View File
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useSearchParams } from 'react-router-dom';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useEffect, useRef, useState, useCallback } from 'react';
@@ -78,11 +79,7 @@ export default function PluginPagesPage() {
</div>
);
}
return (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
</div>
);
return <EntityLoadState />;
}
const assetPath = page.path;
@@ -209,9 +206,7 @@ function PluginPageIframe({
{t('plugins.loadFailed')}
</div>
) : loading || !assetUrl ? (
<div className="flex items-center justify-center h-full text-muted-foreground">
Loading...
</div>
<EntityLoadState />
) : null}
{!assetError && assetUrl && (
<iframe
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import PluginForm from '@/app/home/plugins/components/plugin-installed/plugin-form/PluginForm';
@@ -40,6 +41,8 @@ export default function PluginDetailContent({ id }: { id: string }) {
const { t } = useTranslation();
const navigate = useNavigate();
const { plugins, setDetailEntityName, refreshPlugins } = useSidebarData();
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [pluginInfo, setPluginInfo] = useState<Plugin | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteData, setDeleteData] = useState(false);
@@ -76,15 +79,19 @@ export default function PluginDetailContent({ id }: { id: string }) {
useEffect(() => {
let cancelled = false;
httpClient.getPlugin(pluginAuthor, pluginName).then((res) => {
if (!cancelled) {
setPluginInfo(res.plugin);
}
});
setLoadFailed(false);
httpClient
.getPlugin(pluginAuthor, pluginName)
.then((res) => {
if (!cancelled) {
setPluginInfo(res.plugin);
}
})
.catch(() => setLoadFailed(true));
return () => {
cancelled = true;
};
}, [pluginAuthor, pluginName]);
}, [pluginAuthor, pluginName, loadAttempt]);
function handleFormSubmit(timeout?: number) {
if (timeout) {
@@ -189,6 +196,12 @@ export default function PluginDetailContent({ id }: { id: string }) {
</Card>
);
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!pluginInfo) return <EntityLoadState />;
return (
<>
<div className="flex h-full flex-col">
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useState, useEffect, useRef } from 'react';
import { ApiRespPluginConfig } from '@/app/infra/entities/api';
import { Plugin } from '@/app/infra/entities/plugin';
@@ -25,6 +26,8 @@ export default function PluginForm({
onFormSubmit: (timeout?: number) => void;
}) {
const { t } = useTranslation();
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [pluginInfo, setPluginInfo] = useState<Plugin>();
const [pluginConfig, setPluginConfig] = useState<ApiRespPluginConfig>();
const [isSaving, setIsLoading] = useState(false);
@@ -33,36 +36,55 @@ export default function PluginForm({
const initialFileKeys = useRef<Set<string>>(new Set());
useEffect(() => {
let cancelled = false;
setLoadFailed(false);
setPluginInfo(undefined);
setPluginConfig(undefined);
// 获取插件信息
httpClient.getPlugin(pluginAuthor, pluginName).then((res) => {
setPluginInfo(res.plugin);
});
httpClient
.getPlugin(pluginAuthor, pluginName)
.then((res) => {
if (cancelled) return;
setPluginInfo(res.plugin);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
});
// 获取插件配置
httpClient.getPluginConfig(pluginAuthor, pluginName).then((res) => {
setPluginConfig(res);
httpClient
.getPluginConfig(pluginAuthor, pluginName)
.then((res) => {
if (cancelled) return;
setPluginConfig(res);
// 提取初始配置中的所有文件 key
const extractFileKeys = (obj: any): string[] => {
const keys: string[] = [];
if (obj && typeof obj === 'object') {
if ('file_key' in obj && typeof obj.file_key === 'string') {
keys.push(obj.file_key);
}
for (const value of Object.values(obj)) {
if (Array.isArray(value)) {
value.forEach((item) => keys.push(...extractFileKeys(item)));
} else if (typeof value === 'object' && value !== null) {
keys.push(...extractFileKeys(value));
// 提取初始配置中的所有文件 key
const extractFileKeys = (obj: any): string[] => {
const keys: string[] = [];
if (obj && typeof obj === 'object') {
if ('file_key' in obj && typeof obj.file_key === 'string') {
keys.push(obj.file_key);
}
for (const value of Object.values(obj)) {
if (Array.isArray(value)) {
value.forEach((item) => keys.push(...extractFileKeys(item)));
} else if (typeof value === 'object' && value !== null) {
keys.push(...extractFileKeys(value));
}
}
}
}
return keys;
};
return keys;
};
const fileKeys = extractFileKeys(res.config);
initialFileKeys.current = new Set(fileKeys);
});
}, [pluginAuthor, pluginName]);
const fileKeys = extractFileKeys(res.config);
initialFileKeys.current = new Set(fileKeys);
})
.catch(() => {
if (!cancelled) setLoadFailed(true);
});
return () => {
cancelled = true;
};
}, [pluginAuthor, pluginName, loadAttempt]);
const handleSubmit = async () => {
setIsLoading(true);
@@ -132,13 +154,11 @@ export default function PluginForm({
}
};
if (!pluginInfo || !pluginConfig) {
if (loadFailed)
return (
<div className="flex items-center justify-center h-full mb-[2rem]">
{t('plugins.loading')}
</div>
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
}
if (!pluginInfo || !pluginConfig) return <EntityLoadState />;
return (
<div className="min-w-0 max-w-full space-y-4">
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useState, useEffect } from 'react';
import { httpClient } from '@/app/infra/http/HttpClient';
import { useTranslation } from 'react-i18next';
@@ -88,12 +89,15 @@ export default function PluginReadme({
}) {
const { t } = useTranslation();
const [readme, setReadme] = useState<string>('');
const [isLoadingReadme, setIsLoadingReadme] = useState(false);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [isLoadingReadme, setIsLoadingReadme] = useState(true);
const language = getAPILanguageCode();
useEffect(() => {
// Fetch plugin README
setLoadFailed(false);
setIsLoadingReadme(true);
httpClient
.getPluginReadme(pluginAuthor, pluginName, language)
@@ -101,19 +105,22 @@ export default function PluginReadme({
setReadme(res.readme);
})
.catch(() => {
setLoadFailed(true);
setReadme('');
})
.finally(() => {
setIsLoadingReadme(false);
});
}, [pluginAuthor, pluginName]);
}, [pluginAuthor, pluginName, language, loadAttempt]);
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
return (
<div className="w-full h-full overflow-auto overscroll-none">
{isLoadingReadme ? (
<div className="p-6 text-sm text-gray-500 dark:text-gray-400">
{t('plugins.loadingReadme')}
</div>
<EntityLoadState />
) : readme ? (
<div className="markdown-body p-6 max-w-none pt-0">
<ReactMarkdown
+1 -1
View File
@@ -36,7 +36,7 @@ export default function PluginConfigPage() {
const detailId = searchParams.get('id');
if (detailId) {
return <PluginDetailContent id={detailId} />;
return <PluginDetailContent key={detailId} id={detailId} />;
}
return <PluginListView />;
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
@@ -37,6 +38,7 @@ export default function SkillDetailContent({ id }: { id: string }) {
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const skill = skills.find((item) => item.id === id);
const {
loading: boxLoading,
available: boxAvailable,
hint: boxHint,
reason: boxReason,
@@ -77,6 +79,8 @@ export default function SkillDetailContent({ id }: { id: string }) {
}
}
if (boxLoading) return <EntityLoadState />;
if (isCreateMode) {
return (
<div className="flex h-full flex-col">
@@ -1,3 +1,4 @@
import EntityLoadState from '@/components/EntityLoadState';
import {
type FormEvent,
type ReactNode,
@@ -280,7 +281,8 @@ const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree(
const [dirContents, setDirContents] = useState<Map<string, FileEntry[]>>(
new Map(),
);
const [loading, setLoading] = useState(false);
const [loading, setLoading] = useState(true);
const [filesFailed, setFilesFailed] = useState(false);
const [selectedPath, setSelectedPath] = useState<string | null>(null);
useEffect(() => {
@@ -288,12 +290,14 @@ const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree(
}, [selectedFile]);
const loadRootFiles = useCallback(async () => {
setFilesFailed(false);
setLoading(true);
onLoadingChange?.(true);
try {
const result = await httpClient.listSkillFiles(skillName, '.');
setRootEntries(result.entries);
} catch (error) {
setFilesFailed(true);
console.error('Failed to load skill files:', error);
toast.error(t('skills.loadFilesError') + String(error));
} finally {
@@ -416,6 +420,14 @@ const FileTree = forwardRef<FileTreeHandle, FileTreeProps>(function FileTree(
);
};
if (loading || filesFailed)
return (
<EntityLoadState
error={filesFailed}
onRetry={() => void loadRootFiles()}
/>
);
return (
<div className="space-y-2">
<div className="max-h-[min(46vh,32rem)] space-y-1 overflow-y-auto overscroll-contain pr-1">
@@ -592,18 +604,26 @@ export default function SkillForm({
const [fileContent, setFileContent] = useState<string>('');
const fileTreeRef = useRef<FileTreeHandle>(null);
const directoryInputRef = useRef<HTMLInputElement>(null);
const [loadFailed, setLoadFailed] = useState(false);
const [loadAttempt, setLoadAttempt] = useState(0);
const [initialDataLoaded, setInitialDataLoaded] = useState(!initSkillName);
const [fileTreeLoading, setFileTreeLoading] = useState(false);
const loadSkill = useCallback(
async (skillName: string) => {
setInitialDataLoaded(false);
setLoadFailed(false);
try {
const resp = await httpClient.getSkill(skillName);
setSkill(resp.skill);
setSelectedFile('SKILL.md');
setFileContent(resp.skill.instructions || '');
} catch (error) {
setLoadFailed(true);
console.error('Failed to load skill:', error);
toast.error(t('skills.getSkillListError') + String(error));
} finally {
setInitialDataLoaded(true);
}
},
[t],
@@ -627,7 +647,7 @@ export default function SkillForm({
setDirectorySourceName('');
setDirectoryTree([]);
setDirectoryFileMap(new Map());
}, [initSkillName, loadSkill]);
}, [initSkillName, loadSkill, loadAttempt]);
useEffect(() => {
if (initSkillName) return;
@@ -959,6 +979,12 @@ export default function SkillForm({
</div>
);
if (loadFailed)
return (
<EntityLoadState error onRetry={() => setLoadAttempt((n) => n + 1)} />
);
if (!initialDataLoaded) return <EntityLoadState />;
if (layout === 'split') {
return (
<form
+1 -1
View File
@@ -34,7 +34,7 @@ export default function SkillsPage() {
}, [detailId, isCreateView, navigate]);
if (detailId) {
return <SkillDetailContent id={detailId} />;
return <SkillDetailContent key={detailId} id={detailId} />;
}
function handleCreatedSkill(skillName: string) {
+36
View File
@@ -0,0 +1,36 @@
import { Loader2 } from 'lucide-react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
export default function EntityLoadState({
error = false,
onRetry,
}: {
error?: boolean;
onRetry?: () => void;
}) {
const { t } = useTranslation();
return (
<div
role={error ? 'alert' : 'status'}
aria-busy={!error}
className="flex min-h-40 flex-1 flex-col items-center justify-center gap-3 p-6 text-sm text-muted-foreground"
>
{error ? (
<>
<p>{t('common.loadFailed')}</p>
{onRetry && (
<Button type="button" variant="outline" onClick={onRetry}>
{t('common.retry')}
</Button>
)}
</>
) : (
<>
<Loader2 aria-hidden="true" className="size-5 animate-spin" />
<p>{t('common.loading')}</p>
</>
)}
</div>
);
}
+1
View File
@@ -14,6 +14,7 @@ const enUS = {
editionCloud: 'Cloud',
},
common: {
loadFailed: 'Failed to load. Please try again.',
login: 'Login',
logout: 'Logout',
accountOptions: 'Settings',
+1
View File
@@ -14,6 +14,7 @@ const jaJP = {
editionCloud: 'Cloud',
},
common: {
loadFailed: '読み込みに失敗しました。再試行してください。',
login: 'ログイン',
logout: 'ログアウト',
accountOptions: 'システム設定',
+1
View File
@@ -14,6 +14,7 @@ const zhHans = {
editionCloud: 'Cloud',
},
common: {
loadFailed: '加载失败,请重试。',
login: '登录',
logout: '退出登录',
accountOptions: '系统设置',
+2
View File
@@ -1012,6 +1012,7 @@ test.describe('agent runner resource selectors', () => {
install_count: 12,
latest_version: '1.0.0',
components: { Runner: 1 },
runner_usages: ['agent'],
status: 'live',
type: 'plugin',
created_at: '2026-01-01T00:00:00Z',
@@ -1128,6 +1129,7 @@ test.describe('agent runner resource selectors', () => {
install_count: 9,
latest_version: '1.0.0',
components: { Runner: 1 },
runner_usages: ['agent'],
status: 'live',
type: 'plugin',
created_at: '2026-01-01T00:00:00Z',
+328
View File
@@ -0,0 +1,328 @@
import { expect, test, type Page } from '@playwright/test';
import { installLangBotApiMocks } from './fixtures/langbot-api';
async function setup(page: Page) {
await page.routeWebSocket('**/api/v1/pipelines/**/ws/connect**', (ws) => {
ws.onMessage((raw) => {
if (JSON.parse(String(raw)).type === 'authenticate')
ws.send(
JSON.stringify({
type: 'connected',
connection_id: 'loading-test',
session_type: 'person',
}),
);
});
});
await installLangBotApiMocks(page, {
authenticated: true,
withAdapterEvents: true,
withRunnerToolSelector: true,
});
await page.route('**/api/v1/plugins/qa/loading**', async (route) => {
const path = new URL(route.request().url()).pathname;
const data = path.endsWith('/config')
? { config: {} }
: path.endsWith('/readme')
? { readme: '# Loaded documentation' }
: {
plugin: {
manifest: {
manifest: {
metadata: {
author: 'qa',
name: 'loading',
label: { en_US: 'Loading test plugin' },
description: { en_US: 'Test' },
},
spec: { config: [] },
},
},
components: [],
},
};
await route.fulfill({ json: { code: 0, data } });
});
await page.route('**/api/v1/agents/processor-loading', (route) =>
route.fulfill({
json: {
code: 0,
data: {
agent: {
uuid: 'processor-loading',
kind: 'event_processor',
name: 'Loading test processor',
config: {},
supported_event_patterns: [],
},
},
},
}),
);
await page.route('**/api/v1/agents/processor-loading/runs**', (route) =>
route.fulfill({
json: {
code: 0,
data: { items: [], has_more: false, next_cursor: null },
},
}),
);
}
const cases = [
{
name: 'plugin documentation',
url: '/home/extensions?id=qa/loading',
endpoint: '/plugins/qa/loading/readme?**',
form: '[data-slot="card-title"]',
readyText: 'Loaded documentation',
},
{
name: 'bot adapters',
url: '/home/bots?id=bot-loading',
endpoint: '/platform/adapters',
form: '#bot-form',
},
{
name: 'processor run history',
url: '/home/agents?id=processor-loading',
endpoint: '/agents/processor-loading/runs',
form: '#event-processor-form',
},
{
name: 'skill file list',
url: '/home/skills?id=skill-loading',
endpoint: '/skills/skill-loading/files?**',
form: '#skill-form',
readyText: 'SKILL.md',
},
{
name: 'bot details',
url: '/home/bots?id=bot-loading',
endpoint: '/platform/bots/bot-loading',
form: '#bot-form',
},
{
name: 'agent details',
url: '/home/agents?id=agent-loading',
endpoint: '/agents/agent-loading',
form: '#agent-form',
},
{
name: 'pipeline details',
url: '/home/agents?id=pipeline-loading',
endpoint: '/pipelines/pipeline-loading',
form: '#pipeline-form',
},
{
name: 'legacy pipeline route',
url: '/home/pipelines?id=pipeline-loading',
endpoint: '/pipelines/pipeline-loading',
form: '#pipeline-form',
},
{
name: 'plugin processor details',
url: '/home/agents?id=processor-loading',
endpoint: '/agents/processor-loading',
form: '#event-processor-form',
},
{
name: 'knowledge base details',
url: '/home/knowledge?id=kb-loading',
endpoint: '/knowledge/bases/kb-loading',
form: '#kb-form',
},
{
name: 'MCP details',
url: '/home/mcp?id=mcp-loading',
endpoint: '/mcp/servers/mcp-loading',
form: '#mcp-form',
},
{
name: 'skill details',
url: '/home/skills?id=skill-loading',
endpoint: '/skills/skill-loading',
form: '#skill-form',
},
{
name: 'plugin details',
url: '/home/extensions?id=qa/loading',
endpoint: '/plugins/qa/loading',
form: '[data-slot="card-title"]',
},
{
name: 'agent metadata after runtime health',
url: '/home/agents?id=agent-loading',
endpoint: '/agents/_/metadata',
form: '#agent-form',
},
{
name: 'pipeline metadata',
url: '/home/agents?id=pipeline-loading',
endpoint: '/pipelines/_/metadata',
form: '#pipeline-form',
},
{
name: 'processor metadata',
url: '/home/agents?id=processor-loading',
endpoint: '/agents/_/metadata',
form: '#event-processor-form',
},
{
name: 'knowledge engines',
url: '/home/knowledge?id=kb-loading',
endpoint: '/knowledge/engines',
form: '#kb-form',
},
{
name: 'plugin configuration',
url: '/home/extensions?id=qa/loading',
endpoint: '/plugins/qa/loading/config',
form: '[data-slot="card-title"]',
readyText: 'Plugin Configuration',
},
];
for (const scenario of cases) {
test(`${scenario.name}: loading until response arrives`, async ({ page }) => {
await setup(page);
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
let requested = false;
await page.route(`**/api/v1${scenario.endpoint}`, async (route) => {
requested = true;
await pending;
await route.fallback();
});
try {
await page.goto(scenario.url);
await expect.poll(() => requested).toBe(true);
await expect(
page.getByRole('status').filter({ hasText: 'Loading...' }).first(),
).toBeVisible();
await expect(
page.getByText('No runners are available', { exact: true }),
).toHaveCount(0);
if (scenario.name === 'agent metadata after runtime health') {
await page.screenshot({
path: '../../.codex-run/entity-loading-agent.png',
});
}
if (!scenario.readyText)
await expect(page.locator(scenario.form)).toHaveCount(0);
} finally {
release();
}
await expect(page.locator(scenario.form).first()).toBeAttached();
await expect(
page.getByRole('status').filter({ hasText: 'Loading...' }),
).toHaveCount(0);
});
test(`${scenario.name}: failed request can be retried`, async ({ page }) => {
await setup(page);
let fail = true;
await page.route(`**/api/v1${scenario.endpoint}`, async (route) => {
if (fail)
await route.fulfill({
status: 503,
json: {
code: -1,
msg: 'Temporarily unavailable',
message: 'Temporarily unavailable',
},
});
else await route.fallback();
});
await page.goto(scenario.url);
const error = page
.getByRole('alert')
.filter({ hasText: 'Failed to load. Please try again.' });
await expect(error).toBeVisible();
fail = false;
await error.getByRole('button', { name: 'Retry', exact: true }).click();
await expect(error).toHaveCount(0);
await expect(page.locator(scenario.form).first()).toBeAttached();
await expect(
page.getByRole('status').filter({ hasText: 'Loading...' }),
).toHaveCount(0);
});
}
test('completed empty metadata displays the genuine empty state', async ({
page,
}) => {
await setup(page);
await page.route('**/api/v1/agents/_/metadata', (route) =>
route.fulfill({
json: {
code: 0,
data: { runner_config: null, platform_tools: [], host_tools: [] },
},
}),
);
await page.goto('/home/agents?id=agent-empty');
await expect(
page.getByText('No runners are available', { exact: true }),
).toBeVisible();
await expect(
page.getByRole('status').filter({ hasText: 'Loading...' }),
).toHaveCount(0);
});
test('switching bots resets the form and ignores an old response', async ({
page,
}) => {
await setup(page);
await page.route('**/api/v1/platform/bots', (route) =>
route.fulfill({
json: {
code: 0,
data: {
bots: ['bot-first', 'bot-second'].map((uuid) => ({
uuid,
name: uuid,
adapter: 'aiocqhttp',
enable: true,
})),
},
},
}),
);
let release!: () => void;
const pending = new Promise<void>((resolve) => {
release = resolve;
});
let requested = false;
let responded = false;
await page.route('**/api/v1/platform/bots/bot-second', async (route) => {
requested = true;
await pending;
await route.fallback();
responded = true;
});
await page.goto('/home/bots?id=bot-first');
await expect(page.locator('#bot-form')).toBeVisible();
try {
await page.locator('a[href="/home/bots?id=bot-second"]').click();
await expect.poll(() => requested).toBe(true);
await expect(page.locator('#bot-form')).toHaveCount(0);
await expect(
page.getByRole('status').filter({ hasText: 'Loading...' }),
).toBeVisible();
await page.locator('a[href="/home/bots?id=bot-first"]').click();
await expect(page.locator('#bot-form')).toBeVisible();
} finally {
release();
}
await expect.poll(() => responded).toBe(true);
await expect(
page.getByRole('heading', { name: 'bot-first', exact: true }),
).toBeVisible();
await expect(
page.getByRole('heading', { name: 'bot-second', exact: true }),
).toHaveCount(0);
});
+1 -3
View File
@@ -222,9 +222,7 @@ test('create first, select a plugin in the header, debug beside scrollable logs'
await page
.getByRole('textbox', { name: 'Name', exact: false })
.fill('Welcome processor');
await page
.getByRole('button', { name: 'Create plugin processor', exact: true })
.click();
await page.getByRole('button', { name: 'Submit', exact: true }).click();
await expect(page).toHaveURL(/id=processor-qa/);
expect(creations).toHaveLength(1);
expect(creations[0]).toMatchObject({