Skip to content

Commit 9a416a2

Browse files
authored
fix: Taxonomy table QA fixes (#3000)
* fix: taxonomy alignment and error message * fix: lint and cleanup * fix: UsageCountDisplay * fix: tests * fix: test * fix: lint * fix: test accuracy * fix: typecheck for AxiosError * fix: PR comments * fix: Apply suggestions from code review * fix: lint * fix: types
1 parent 4a12a31 commit 9a416a2

23 files changed

Lines changed: 378 additions & 273 deletions

src/messages.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ const messages = defineMessages({
1010
id: 'authoring.alert.support.text',
1111
defaultMessage: 'Support Page',
1212
},
13+
unknownError: {
14+
id: 'authoring.alert.error.unknown',
15+
defaultMessage: 'Unknown error',
16+
},
1317
});
1418

1519
export default messages;

src/taxonomy/data/apiHooks.test.jsx

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -110,11 +110,19 @@ describe('import taxonomy api calls', () => {
110110
expect(axiosMock.history.put[0].url).toEqual(apiUrls.tagsPlanImport(1));
111111
});
112112

113-
it('should surface duplicate tag error returned as an array', async () => {
114-
const duplicateError = 'Tag with value \'ab\' already exists for taxonomy.';
115-
axiosMock.onPost(apiUrls.createTag(1)).reply(400, [duplicateError]);
113+
it('should surface tag errors', async () => {
114+
const duplicateMessage = 'Tag with value \'ab\' already exists for taxonomy.';
115+
axiosMock.onPost(apiUrls.createTag(1)).reply(400, [duplicateMessage]);
116116
const { result } = renderHook(() => useCreateTag(1), { wrapper });
117117

118-
await expect(result.current.mutateAsync({ value: 'ab' })).rejects.toEqual(Error(duplicateError));
118+
try {
119+
await result.current.mutateAsync({ value: 'ab' });
120+
// expect: if code reaches this line, the test should fail because an error should have been thrown
121+
expect('This line should not be reached').toBe(false);
122+
} catch (error) {
123+
// we check the response data, not the error message, because of how react-query surfaces errors from axios
124+
// @ts-ignore
125+
expect(error.response.data).toEqual([duplicateMessage]);
126+
}
119127
});
120128
});

src/taxonomy/data/apiHooks.ts

Lines changed: 8 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,6 @@
1313
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
1414
import { camelCaseObject } from '@edx/frontend-platform';
1515
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
16-
import { useIntl } from '@edx/frontend-platform/i18n';
1716
import { apiUrls, ALL_TAXONOMIES, getApiErrorMessage } from './api';
1817
import * as api from './api';
1918
import type { QueryOptions, TagListData } from './types';
@@ -229,18 +228,13 @@ export const useSubTags = (taxonomyId: number, parentTagValue: string) =>
229228

230229
export const useCreateTag = (taxonomyId: number) => {
231230
const queryClient = useQueryClient();
232-
const intl = useIntl();
233231

234232
return useMutation({
235233
mutationFn: async ({ value, parentTagValue }: { value: string; parentTagValue?: string; }) => {
236-
try {
237-
await getAuthenticatedHttpClient().post(
238-
apiUrls.createTag(taxonomyId),
239-
{ tag: value, parent_tag_value: parentTagValue },
240-
);
241-
} catch (err) {
242-
throw new Error(getApiErrorMessage(err, intl));
243-
}
234+
await getAuthenticatedHttpClient().post(
235+
apiUrls.createTag(taxonomyId),
236+
{ tag: value, parent_tag_value: parentTagValue },
237+
);
244238
},
245239
onSuccess: () => {
246240
queryClient.invalidateQueries({
@@ -261,14 +255,10 @@ export const useUpdateTag = (taxonomyId: number) => {
261255

262256
return useMutation({
263257
mutationFn: async ({ value, originalValue }: { value: string; originalValue: string; }) => {
264-
try {
265-
await getAuthenticatedHttpClient().patch(
266-
apiUrls.updateTag(taxonomyId),
267-
{ tag: originalValue, updated_tag_value: value },
268-
);
269-
} catch (err) {
270-
throw new Error(getApiErrorMessage(err));
271-
}
258+
await getAuthenticatedHttpClient().patch(
259+
apiUrls.updateTag(taxonomyId),
260+
{ tag: originalValue, updated_tag_value: value },
261+
);
272262
},
273263
onSuccess: () => {
274264
queryClient.invalidateQueries({

src/taxonomy/tag-list/OptionalExpandLink.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { ExpandLess, ExpandMore } from '@openedx/paragon/icons';
44
import { Row } from '@tanstack/react-table';
55
import { useIntl } from '@edx/frontend-platform/i18n';
66

7-
import type { TreeRowData } from '../tree-table/types';
7+
import type { TreeRowData } from '@src/taxonomy/tree-table/types';
88
import messages from './messages';
99

1010
interface OptionalExpandLinkProps {

src/taxonomy/tag-list/TagListTable.test.jsx

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import React from 'react';
2+
import { AxiosError } from 'axios';
23
import {
34
render,
45
waitFor,
@@ -598,8 +599,16 @@ describe('<TagListTable />', () => {
598599
expect(screen.getByText(/invalid character/i)).toBeInTheDocument();
599600
});
600601

601-
it('should show an inline duplicate-name error when the entered root tag already exists', async () => {
602-
axiosMock.onPost(createTagUrl).reply(400, ['Tag with this name already exists']);
602+
it('should show failure feedback when creating a duplicate root tag name', async () => {
603+
axiosMock.onPost(createTagUrl).reply(() => {
604+
const error = new AxiosError('Request failed with status code 400');
605+
error.response = {
606+
data: {
607+
tag: ['Tag with this name already exists'],
608+
},
609+
};
610+
return Promise.reject(error);
611+
});
603612

604613
fireEvent.click(await screen.findByLabelText('Create Tag'));
605614
const draftRow = await screen.findAllByRole('row');
@@ -609,12 +618,18 @@ describe('<TagListTable />', () => {
609618
fireEvent.change(input, { target: { value: 'root tag 1' } });
610619
fireEvent.click(saveButton);
611620

612-
expect(await screen.findByText('Tag with this name already exists')).toBeInTheDocument();
621+
expect(await screen.findByText('Error creating tag: Tag with this name already exists')).toBeInTheDocument();
613622
});
614623

615624
it('should keep the inline row and show a failure toast when save request fails', async () => {
616-
axiosMock.onPost(createTagUrl).reply(500, {
617-
error: 'Internal server error',
625+
axiosMock.onPost(createTagUrl).reply(() => {
626+
const error = new AxiosError('Request failed with status code 500');
627+
error.response = {
628+
data: {
629+
tag: ['Internal server error'],
630+
},
631+
};
632+
return Promise.reject(error);
618633
});
619634

620635
fireEvent.click(await screen.findByLabelText('Create Tag'));

src/taxonomy/tag-list/TagListTable.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,9 +4,9 @@ import React, {
44
useEffect,
55
} from 'react';
66
import type { PaginationState } from '@tanstack/react-table';
7-
import { useTagListData, useCreateTag, useUpdateTag } from '../data/apiHooks';
7+
import { TableView } from '@src/taxonomy/tree-table';
8+
import { useTagListData, useCreateTag, useUpdateTag } from '@src/taxonomy/data/apiHooks';
89
import { TagTree } from './tagTree';
9-
import { TableView } from '../tree-table';
1010
import type {
1111
RowId,
1212
TreeColumnDef,
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import {
2+
Bubble,
3+
} from '@openedx/paragon';
4+
import type { Row } from '@tanstack/react-table';
5+
import type {
6+
TreeRowData,
7+
} from '@src/taxonomy/tree-table/types';
8+
import { getTagListRowData } from './utils';
9+
10+
const UsageCountDisplay = ({ row }: { row: Row<TreeRowData>; }) => {
11+
const count = getTagListRowData(row).usageCount ?? 0;
12+
13+
if (count <= 0) {
14+
return null;
15+
}
16+
17+
return (
18+
<Bubble expandable>
19+
{count}
20+
</Bubble>
21+
);
22+
};
23+
24+
export default UsageCountDisplay;

src/taxonomy/tag-list/hooks.ts

Lines changed: 42 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
import { useReducer } from 'react';
2+
import { AxiosError } from 'axios';
23
import { useIntl } from '@edx/frontend-platform/i18n';
34

4-
import { useCreateTag, useUpdateTag } from '../data/apiHooks';
5+
import globalMessages from '@src/messages';
6+
import { useCreateTag, useUpdateTag } from '@src/taxonomy/data/apiHooks';
7+
import type { RowId } from '@src/taxonomy/tree-table/types';
58
import { TagTree } from './tagTree';
69
import { TagListTableError } from './errors';
7-
import type { RowId } from '../tree-table/types';
810
import {
911
TABLE_MODES,
1012
TRANSITION_TABLE,
@@ -167,6 +169,38 @@ const useEditActions = ({
167169
return true;
168170
};
169171

172+
const formatErrorMessage = (errorMessage: string): string => {
173+
// Remove trailing period for better message formatting
174+
return errorMessage.replace(/\.$/, '');
175+
};
176+
177+
const getAxiosErrorMessage = (axiosError: AxiosError) => {
178+
const responseData = axiosError.response?.data;
179+
const tagError = responseData ?
180+
Object.entries(responseData)?.find((errItem: [string, unknown]) => (
181+
['tag', 'value', 'updated_tag_value'].includes(errItem[0].toLowerCase())
182+
)) :
183+
null;
184+
185+
const errorMessages = tagError ? tagError[1] : (
186+
axiosError.message || intl.formatMessage(globalMessages.unknownError)
187+
);
188+
const errorMessage = Array.isArray(errorMessages) ? errorMessages.join('; ') : String(errorMessages);
189+
return formatErrorMessage(errorMessage);
190+
};
191+
192+
const getErrorMessage = (error: unknown): string => {
193+
if (error instanceof AxiosError) {
194+
return getAxiosErrorMessage(error);
195+
}
196+
197+
if (error instanceof Error && error.message) {
198+
return formatErrorMessage(error.message);
199+
}
200+
201+
return intl.formatMessage(globalMessages.unknownError);
202+
};
203+
170204
const handleCreateTag = async (value: string, parentTagValue?: string) => {
171205
const trimmed = value.trim();
172206

@@ -186,11 +220,9 @@ const useEditActions = ({
186220
setIsCreatingTopTag(false);
187221
setCreatingParentId(null);
188222
} catch (error) {
189-
const message = intl.formatMessage(messages.tagCreationErrorMessage, { errorMessage: (error as Error)?.message });
190-
setDraftError(
191-
(error as Error)?.message || intl.formatMessage(messages.tagCreationErrorMessage, { errorMessage: '' }),
192-
);
193-
setToast({ show: true, message });
223+
const errorMessage = getErrorMessage(error);
224+
setDraftError(errorMessage);
225+
setToast({ show: true, message: intl.formatMessage(messages.tagCreationErrorMessage, { errorMessage }) });
194226
}
195227
};
196228

@@ -217,11 +249,9 @@ const useEditActions = ({
217249
message: intl.formatMessage(messages.tagUpdateSuccessMessage, { name: trimmed }),
218250
});
219251
} catch (error) {
220-
const message = intl.formatMessage(messages.tagUpdateErrorMessage, { errorMessage: (error as Error)?.message });
221-
setDraftError(
222-
(error as Error)?.message || intl.formatMessage(messages.tagUpdateErrorMessage, { errorMessage: '' }),
223-
);
224-
setToast({ show: true, message });
252+
const errorMessage = getErrorMessage(error);
253+
setDraftError(errorMessage);
254+
setToast({ show: true, message: intl.formatMessage(messages.tagUpdateErrorMessage, { errorMessage }) });
225255
}
226256
};
227257

src/taxonomy/tag-list/tagColumns.tsx

Lines changed: 8 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
import {
2-
Bubble,
32
Icon,
43
IconButton,
54
IconButtonWithTooltip,
@@ -12,28 +11,19 @@ import {
1211
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
1312
import type { Row } from '@tanstack/react-table';
1413

15-
import messages from './messages';
1614
import type {
1715
RowId,
1816
TreeColumnDef,
1917
TreeRowData,
20-
} from '../tree-table/types';
18+
} from '@src/taxonomy/tree-table/types';
19+
import type { TagListRowData } from './types';
20+
import messages from './messages';
2121
import OptionalExpandLink from './OptionalExpandLink';
22+
import UsageCountDisplay from './UsageCountDisplay';
23+
import { getTagListRowData } from './utils';
2224

2325
const EDITABLE_COLUMNS = ['value'];
2426

25-
interface TagListRowData extends TreeRowData {
26-
depth: number;
27-
childCount: number;
28-
usageCount?: number;
29-
isNew?: boolean;
30-
isEditing?: boolean;
31-
}
32-
33-
const asTagListRowData = (row: Row<TreeRowData>): TagListRowData => (
34-
row.original as unknown as TagListRowData
35-
);
36-
3727
interface GetColumnsArgs {
3828
setIsCreatingTopTag: (isCreating: boolean) => void;
3929
setCreatingParentId: (id: RowId | null) => void;
@@ -49,17 +39,6 @@ interface GetColumnsArgs {
4939
maxDepth: number;
5040
}
5141

52-
const UsageCountDisplay = ({ row }: { row: Row<TreeRowData>; }) => {
53-
const count = asTagListRowData(row).usageCount ?? 0;
54-
return (
55-
count > 0 && (
56-
<Bubble expandable>
57-
{count}
58-
</Bubble>
59-
)
60-
);
61-
};
62-
6342
interface ActionsHeaderProps {
6443
onStartDraft: () => void;
6544
setDraftError: (error: string) => void;
@@ -175,7 +154,7 @@ function getColumns({
175154
cell: ({ row }) => {
176155
const {
177156
value,
178-
} = asTagListRowData(row);
157+
} = getTagListRowData(row);
179158

180159
return (
181160
<span className="d-flex align-items-center gap-2">
@@ -205,14 +184,14 @@ function getColumns({
205184
/>
206185
),
207186
cell: ({ row }) => {
208-
const rowData = asTagListRowData(row);
187+
const rowData = getTagListRowData(row);
209188

210189
if (rowData.isNew || rowData.isEditing) {
211190
return <div className="d-flex gap-2" />;
212191
}
213192

214193
const disableAddSubtag = hasOpenDraft || !canAddTag;
215-
const disableEditTag = hasOpenDraft || row.original.canChangeTag === false;
194+
const disableEditTag = hasOpenDraft || rowData.canChangeTag === false;
216195

217196
const startSubtagDraft = () => {
218197
onStartDraft();

src/taxonomy/tag-list/tagTree.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1+
import type { TagData } from '@src/taxonomy/data/types';
12
import { TagTreeError } from './errors';
2-
import type { TagData } from '../data/types';
33

44
export interface TagTreeNode extends TagData {
55
subRows?: TagTreeNode[];

0 commit comments

Comments
 (0)