fix(api): normalize null lists to [] (backend serializes empty as null)
The Go backend serializes empty slices as JSON null in several places
(e.g. {count:0, topics:null} when no rows match). The SPA assumed
arrays and crashed on .length:
TypeError: Cannot read properties of null (reading 'length')
at Hh (TopicList.tsx:64:45)
Fix in api.ts so every list-returning helper normalizes null → [] once,
centrally. Three call sites covered:
- listTopics — topics field
- listArguments — arguments field
- getAgentSummary — recent_topics field
Components stay as-is; nullable handling moves out of UI code where it
belongs in the data layer. getTopic preserves camps:null vs camps:[]
distinction (null = not yet allocated, [] would be allocated-with-zero
which the allocator contract forbids — keep them meaningfully distinct).
No backend change. Tested on prod with empty topics — list page now
renders 'no topics match this filter' instead of crashing.
This commit is contained in:
45
src/api.ts
45
src/api.ts
@@ -64,7 +64,12 @@ async function request<T>(
|
|||||||
// --------------------------------------------------------------------
|
// --------------------------------------------------------------------
|
||||||
// Topics
|
// Topics
|
||||||
|
|
||||||
export function listTopics(filter: {
|
// Note on null vs []: the Go backend serializes empty slices as JSON
|
||||||
|
// null (`json:",omitempty"`-ish behavior in places), so all list-returning
|
||||||
|
// endpoints can return e.g. {count:0, topics:null}. Normalize to [] here
|
||||||
|
// so consumers can do .length / .map without per-call guards.
|
||||||
|
|
||||||
|
export async function listTopics(filter: {
|
||||||
status?: TopicStatus;
|
status?: TopicStatus;
|
||||||
visibility?: Visibility;
|
visibility?: Visibility;
|
||||||
limit?: number;
|
limit?: number;
|
||||||
@@ -77,23 +82,35 @@ export function listTopics(filter: {
|
|||||||
if (filter.limit) qs.set('limit', String(filter.limit));
|
if (filter.limit) qs.set('limit', String(filter.limit));
|
||||||
if (filter.offset) qs.set('offset', String(filter.offset));
|
if (filter.offset) qs.set('offset', String(filter.offset));
|
||||||
const suffix = qs.toString() ? `?${qs}` : '';
|
const suffix = qs.toString() ? `?${qs}` : '';
|
||||||
return request('GET', `/topics${suffix}`, { signal: filter.signal });
|
const r = await request<{ topics: Topic[] | null; count: number }>(
|
||||||
|
'GET',
|
||||||
|
`/topics${suffix}`,
|
||||||
|
{ signal: filter.signal },
|
||||||
|
);
|
||||||
|
return { topics: r.topics ?? [], count: r.count ?? 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTopic(
|
export async function getTopic(
|
||||||
id: string,
|
id: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<TopicDetail> {
|
): Promise<TopicDetail> {
|
||||||
return request('GET', `/topics/${encodeURIComponent(id)}`, { signal });
|
const t = await request<TopicDetail>('GET', `/topics/${encodeURIComponent(id)}`, { signal });
|
||||||
|
// camps is nullable pre-allocation; keep null vs [] meaningful — null
|
||||||
|
// means "not yet allocated" (UI shows '—'), [] would mean "allocated
|
||||||
|
// but no rows" (impossible by allocator contract, but distinct).
|
||||||
|
return t;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function listArguments(
|
export async function listArguments(
|
||||||
topicId: string,
|
topicId: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<{ arguments: Argument[]; count: number }> {
|
): Promise<{ arguments: Argument[]; count: number }> {
|
||||||
return request('GET', `/topics/${encodeURIComponent(topicId)}/arguments`, {
|
const r = await request<{ arguments: Argument[] | null; count: number }>(
|
||||||
signal,
|
'GET',
|
||||||
});
|
`/topics/${encodeURIComponent(topicId)}/arguments`,
|
||||||
|
{ signal },
|
||||||
|
);
|
||||||
|
return { arguments: r.arguments ?? [], count: r.count ?? 0 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getVerdict(
|
export function getVerdict(
|
||||||
@@ -112,14 +129,16 @@ export function getVerdict(
|
|||||||
// --------------------------------------------------------------------
|
// --------------------------------------------------------------------
|
||||||
// Admin
|
// Admin
|
||||||
|
|
||||||
export function getAgentSummary(
|
export async function getAgentSummary(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
signal?: AbortSignal,
|
signal?: AbortSignal,
|
||||||
): Promise<AgentSummary> {
|
): Promise<AgentSummary> {
|
||||||
return request('GET', `/admin/agents/${encodeURIComponent(agentId)}`, {
|
const r = await request<AgentSummary & { recent_topics: AgentSummary['recent_topics'] | null }>(
|
||||||
admin: true,
|
'GET',
|
||||||
signal,
|
`/admin/agents/${encodeURIComponent(agentId)}`,
|
||||||
});
|
{ admin: true, signal },
|
||||||
|
);
|
||||||
|
return { ...r, recent_topics: r.recent_topics ?? [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
export { HttpError };
|
export { HttpError };
|
||||||
|
|||||||
Reference in New Issue
Block a user