Skip to content

Commit 5b3a9f2

Browse files
committed
Handle error via tanstack UseQueryResult, move hook, rename err obj
Signed-off-by: Michael Edgar <medgar@redhat.com>
1 parent 4762e65 commit 5b3a9f2

7 files changed

Lines changed: 117 additions & 90 deletions

File tree

api/src/main/webui/eslint.config.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,12 @@ import tseslint from 'typescript-eslint';
55
export default defineConfig(
66
{
77
ignores: ['dist/**'],
8+
rules: {
9+
'@typescript-eslint/no-unused-vars': ['error', {
10+
argsIgnorePattern: '^_',
11+
ignoreRestSiblings: true, // ← Allows destructuring pattern
12+
}],
13+
},
814
},
915
js.configs.recommended,
1016
tseslint.configs.recommended,

api/src/main/webui/src/api/client.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,13 @@
55
* No authentication initially - will be added later.
66
*/
77

8+
import { ErrorObject } from "./types";
9+
810
export class ApiError extends Error {
911
constructor(
1012
public status: number,
1113
public statusText: string,
12-
public errors?: Array<{ status: string; title: string; detail?: string }>
14+
public errors?: ErrorObject[],
1315
) {
1416
super(`API Error: ${status} ${statusText}`);
1517
this.name = 'ApiError';
@@ -68,7 +70,17 @@ class ApiClient {
6870
return {} as T;
6971
}
7072

71-
return await response.json();
73+
const data = await response.json();
74+
75+
if (!response.ok) {
76+
throw new ApiError(
77+
response.status,
78+
response.statusText,
79+
data.errors
80+
);
81+
}
82+
83+
return data;
7284
}
7385

7486
/**

api/src/main/webui/src/api/hooks/useMessages.ts

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44

55
import { useQuery, UseQueryOptions } from '@tanstack/react-query';
66
import { apiClient } from '../client';
7-
import { KafkaRecord, KafkaRecordsResponse, ApiError } from '../types';
7+
import { KafkaRecord, KafkaRecordsResponse, ErrorObject } from '../types';
88

99
interface GetMessagesParams {
1010
kafkaId: string;
@@ -110,9 +110,9 @@ export async function getMessage(
110110
*/
111111
export function useMessages(
112112
params: GetMessagesParams,
113-
options?: Omit<UseQueryOptions<KafkaRecord[], ApiError>, 'queryKey' | 'queryFn'>
113+
options?: Omit<UseQueryOptions<KafkaRecord[], ErrorObject>, 'queryKey' | 'queryFn'>
114114
) {
115-
return useQuery<KafkaRecord[], ApiError>({
115+
return useQuery({
116116
queryKey: [
117117
'messages',
118118
params.kafkaId,
@@ -138,9 +138,9 @@ export function useMessage(
138138
topicId: string,
139139
partition: number | undefined,
140140
offset: number | undefined,
141-
options?: Omit<UseQueryOptions<KafkaRecord | undefined, ApiError>, 'queryKey' | 'queryFn'>
141+
options?: Omit<UseQueryOptions<KafkaRecord | undefined, ErrorObject>, 'queryKey' | 'queryFn'>
142142
) {
143-
return useQuery<KafkaRecord | undefined, ApiError>({
143+
return useQuery({
144144
queryKey: ['message', kafkaId, topicId, partition, offset],
145145
queryFn: () =>
146146
partition !== undefined && offset !== undefined

api/src/main/webui/src/api/types.ts

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,12 @@
66
*/
77

88
// Common types
9+
10+
// All `meta` properties are generic records with can be extended with type-specific properties.
11+
export type AbstractMeta = Record<string, unknown>;
12+
913
export interface ListResponse<T extends Resource> {
10-
meta?: {
14+
meta?: AbstractMeta & {
1115
page: {
1216
total: number;
1317
pageNumber: number;
@@ -21,13 +25,13 @@ export interface ListResponse<T extends Resource> {
2125
next?: string;
2226
};
2327
data?: T[];
24-
errors?: ApiError[];
28+
errors?: ErrorObject[];
2529
}
2630

2731
export interface ApiResponse<T> {
2832
data?: T;
29-
errors?: ApiError[];
30-
meta?: Record<string, unknown>;
33+
errors?: ErrorObject[];
34+
meta?: AbstractMeta;
3135
links?: {
3236
first?: string;
3337
last?: string;
@@ -36,10 +40,17 @@ export interface ApiResponse<T> {
3640
};
3741
}
3842

39-
export interface ApiError {
43+
export interface ErrorObject {
44+
id: string;
4045
status: string;
46+
code?: string;
4147
title: string;
4248
detail?: string;
49+
source?: {
50+
pointer?: string;
51+
parameter?: string;
52+
header?: string;
53+
};
4354
}
4455

4556
export interface ResourceIdentifier {
@@ -56,7 +67,7 @@ export interface Resource {
5667
id: string;
5768
attributes?: Record<string, unknown>;
5869
relationships?: Record<string, { data: ResourceIdentifier | ResourceIdentifier[] }>;
59-
meta?: object;
70+
meta?: AbstractMeta;
6071
}
6172

6273
// Kafka Cluster types
@@ -87,7 +98,7 @@ export interface KafkaCluster extends Resource {
8798
listeners?: KafkaClusterListener[];
8899
conditions?: KafkaClusterCondition[];
89100
};
90-
meta?: MetaWithPrivileges & {
101+
meta?: AbstractMeta & MetaWithPrivileges & {
91102
authentication?: {
92103
method: string;
93104
};
@@ -385,7 +396,7 @@ export interface RelatedSchema {
385396
meta?: {
386397
artifactType?: string;
387398
name?: string;
388-
errors?: ApiError[];
399+
errors?: ErrorObject[];
389400
} | null;
390401
links?: {
391402
content: string;

api/src/main/webui/src/components/common/ResourceListDataView.tsx

Lines changed: 66 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { useCallback, useEffect, useMemo, useState } from 'react';
1+
import React, { useCallback, useEffect, useMemo, useState } from 'react';
22
import { useSearchParams } from 'react-router-dom';
33
import { useTranslation } from 'react-i18next';
44
import {
@@ -25,6 +25,8 @@ import { ISortBy, Tbody, Td, Tr } from '@patternfly/react-table';
2525
import { ListResponse, Resource } from '@/api/types';
2626
import { DataViewFilters } from '@patternfly/react-data-view/dist/dynamic/DataViewFilters';
2727
import { ResourceListParams } from '@/api/hooks/useResourceList';
28+
import { UseQueryResult } from '@tanstack/react-query';
29+
import { ApiError } from '@/api/client';
2830

2931
const perPageOptions = [
3032
{ title: '5', value: 5 },
@@ -36,6 +38,44 @@ const perPageOptions = [
3638

3739
const DEFAULT_PAGE_SIZE = 10;
3840

41+
// Custom hook for text filter handlers
42+
const useTextFilterHandlers = (
43+
filterId: string,
44+
pendingFilters: Record<string, string>,
45+
setPendingFilters: React.Dispatch<React.SetStateAction<Record<string, string>>>,
46+
onSetFilters: (newFilters: Partial<Record<string, string | string[]>>) => void,
47+
) => {
48+
const handleChange = useCallback((_event: React.FormEvent<HTMLInputElement> | undefined, value: string) => {
49+
if (value) {
50+
setPendingFilters(prev => ({ ...prev, [filterId]: value }));
51+
} else {
52+
onSetFilters({ [filterId]: '' });
53+
setPendingFilters(prev => {
54+
const { [filterId]: _, ...rest } = prev;
55+
return rest;
56+
});
57+
}
58+
}, [filterId]);
59+
60+
const handleClear = useCallback(() => {
61+
onSetFilters({ [filterId]: '' });
62+
setPendingFilters(prev => {
63+
const { [filterId]: _, ...rest } = prev;
64+
return rest;
65+
});
66+
}, [filterId]);
67+
68+
const handleSearch = useCallback((_event: React.SyntheticEvent<HTMLButtonElement>, value: string) => {
69+
onSetFilters({ [filterId]: pendingFilters[filterId] ?? value });
70+
setPendingFilters(prev => {
71+
const { [filterId]: _, ...rest } = prev;
72+
return rest;
73+
});
74+
}, [filterId, pendingFilters[filterId]]);
75+
76+
return { handleChange, handleClear, handleSearch };
77+
};
78+
3979
export interface ResourceListDataViewColumnMapper {
4080
(
4181
sortBy?: string,
@@ -53,8 +93,7 @@ export interface ResourceListDataViewRowMapper<T extends Resource> {
5393
}
5494

5595
export interface ResourceListDataViewProps<T extends Resource> {
56-
listResponse: ListResponse<T> | undefined;
57-
isLoading: boolean;
96+
resourceResult: UseQueryResult<ListResponse<T>, Error>;
5897
columnProvider: {
5998
dependencies: unknown[];
6099
callback: ResourceListDataViewColumnMapper;
@@ -75,8 +114,7 @@ export interface ResourceListDataViewProps<T extends Resource> {
75114
}
76115

77116
export function ResourceListDataView<T extends Resource>({
78-
listResponse,
79-
isLoading = false,
117+
resourceResult,
80118
columnProvider,
81119
rowProvider,
82120
dataFilters,
@@ -158,6 +196,7 @@ export function ResourceListDataView<T extends Resource>({
158196
searchParams: sortSearchParams,
159197
});
160198

199+
const listResponse = resourceResult.data;
161200
const totalCount = listResponse?.meta?.page?.total ?? 0;
162201

163202
// Track current page number locally to avoid flashing during navigation
@@ -237,42 +276,6 @@ export function ResourceListDataView<T extends Resource>({
237276
onSetFilters(newValues as Record<string, string | string[]>);
238277
}, [onSetFilters]);
239278

240-
// Custom hook for text filter handlers
241-
const useTextFilterHandlers = (filterId: string) => {
242-
const handleChange = useCallback((_event: React.FormEvent<HTMLInputElement> | undefined, value: string) => {
243-
if (value) {
244-
setPendingFilters(prev => ({ ...prev, [filterId]: value }));
245-
} else {
246-
onSetFilters({ [filterId]: '' });
247-
setPendingFilters(prev => {
248-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
249-
const { [filterId]: _, ...rest } = prev;
250-
return rest;
251-
});
252-
}
253-
}, [filterId]);
254-
255-
const handleClear = useCallback(() => {
256-
onSetFilters({ [filterId]: '' });
257-
setPendingFilters(prev => {
258-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
259-
const { [filterId]: _, ...rest } = prev;
260-
return rest;
261-
});
262-
}, [filterId]);
263-
264-
const handleSearch = useCallback((_event: React.SyntheticEvent<HTMLButtonElement>, value: string) => {
265-
onSetFilters({ [filterId]: pendingFilters[filterId] ?? value });
266-
setPendingFilters(prev => {
267-
// eslint-disable-next-line @typescript-eslint/no-unused-vars
268-
const { [filterId]: _, ...rest } = prev;
269-
return rest;
270-
});
271-
}, [filterId, pendingFilters[filterId]]);
272-
273-
return { handleChange, handleClear, handleSearch };
274-
};
275-
276279
// Manually sync to URL in a single effect
277280
useEffect(() => {
278281
setSearchParams(params => {
@@ -338,12 +341,23 @@ export function ResourceListDataView<T extends Resource>({
338341

339342
// Determine the active state, errors, and table rows for DataView
340343
const [ activeState, errors, rows ] = useMemo(() => {
341-
if (isLoading) {
344+
if (resourceResult.isLoading) {
342345
return [ DataViewState.loading, undefined, [] ];
343346
}
344347

345-
if (listResponse?.errors) {
346-
return [ DataViewState.error, listResponse.errors, [] ];
348+
if (resourceResult?.error) {
349+
const e = resourceResult.error;
350+
351+
if (e instanceof ApiError) {
352+
return [ DataViewState.error, e.errors, [] ];
353+
}
354+
355+
const errObjects = [{
356+
title: e.message,
357+
detail: e.toString(),
358+
}];
359+
360+
return [ DataViewState.error, errObjects, [] ];
347361
}
348362

349363
if (listResponse?.data && listResponse?.data.length === 0) {
@@ -355,7 +369,7 @@ export function ResourceListDataView<T extends Resource>({
355369
[],
356370
listResponse?.data?.map(entry => rowProvider.callback(entry)) ?? []
357371
];
358-
}, [ isLoading, listResponse, ...rowProvider.dependencies ]);
372+
}, [ resourceResult.isLoading, listResponse, ...rowProvider.dependencies ]);
359373

360374
useEffect(() => {
361375
const pageSize = searchParams.get('page[size]');
@@ -433,7 +447,7 @@ export function ResourceListDataView<T extends Resource>({
433447
);
434448

435449
const bodyLoading = useMemo(
436-
() => <SkeletonTableBody rowsCount={DEFAULT_PAGE_SIZE} columnsCount={columns.length} />,
450+
() => <SkeletonTableBody rowsCount={perPage ?? DEFAULT_PAGE_SIZE} columnsCount={columns.length} />,
437451
[columns.length]
438452
);
439453

@@ -452,7 +466,12 @@ export function ResourceListDataView<T extends Resource>({
452466
if (filter.type === 'checkbox') {
453467
return <></>; // TODO: Add checkbox filter component
454468
} else {
455-
const { handleChange, handleClear, handleSearch } = useTextFilterHandlers(name);
469+
const { handleChange, handleClear, handleSearch } = useTextFilterHandlers(
470+
name,
471+
pendingFilters,
472+
setPendingFilters,
473+
onSetFilters,
474+
);
456475
return <DataViewTextFilter
457476
key={`filter-${name}`}
458477
filterId={name}

api/src/main/webui/src/components/home/ClustersDataView.tsx

Lines changed: 4 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -13,18 +13,17 @@ import {
1313
ResourceListDataViewColumnMapper,
1414
ResourceListDataViewRowMapper
1515
} from '../common/ResourceListDataView';
16+
import { UseQueryResult } from '@tanstack/react-query';
1617

1718
const columnNames = ['name', 'namespace', 'version', 'status'];
1819

1920
interface ClustersDataViewProps {
20-
clusterResponse?: ListResponse<KafkaCluster>;
21-
isLoading?: boolean;
21+
clusterResult: UseQueryResult<ListResponse<KafkaCluster>, Error>;
2222
onDataViewChange: (params: ResourceListParams) => void;
2323
}
2424

2525
export function ClustersDataView({
26-
clusterResponse,
27-
isLoading = false,
26+
clusterResult,
2827
onDataViewChange,
2928
}: ClustersDataViewProps) {
3029

@@ -126,9 +125,8 @@ export function ClustersDataView({
126125

127126
return (
128127
<ResourceListDataView
129-
listResponse={clusterResponse}
128+
resourceResult={clusterResult}
130129
onDataViewChange={onDataViewChange}
131-
isLoading={isLoading}
132130
ariaLabel={t('kafka.clusterList')}
133131
ouiaIdPrefix='kafka-clusters'
134132
dataFilters={{

0 commit comments

Comments
 (0)