Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/messages.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ const messages = defineMessages({
id: 'authoring.alert.support.text',
defaultMessage: 'Support Page',
},
unknownError: {
id: 'authoring.alert.error.unknown',
defaultMessage: 'Unknown error',
},
});

export default messages;
16 changes: 12 additions & 4 deletions src/taxonomy/data/apiHooks.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -110,11 +110,19 @@ describe('import taxonomy api calls', () => {
expect(axiosMock.history.put[0].url).toEqual(apiUrls.tagsPlanImport(1));
});

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

await expect(result.current.mutateAsync({ value: 'ab' })).rejects.toEqual(Error(duplicateError));
try {
await result.current.mutateAsync({ value: 'ab' });
// expect: if code reaches this line, the test should fail because an error should have been thrown
expect('This line should not be reached').toBe(false);
} catch (error) {
// we check the response data, not the error message, because of how react-query surfaces errors from axios
// @ts-ignore
expect(error.response.data).toEqual([duplicateMessage]);
}
});
});
26 changes: 8 additions & 18 deletions src/taxonomy/data/apiHooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { camelCaseObject } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { useIntl } from '@edx/frontend-platform/i18n';
import { apiUrls, ALL_TAXONOMIES, getApiErrorMessage } from './api';
import * as api from './api';
import type { QueryOptions, TagListData } from './types';
Expand Down Expand Up @@ -229,18 +228,13 @@ export const useSubTags = (taxonomyId: number, parentTagValue: string) =>

export const useCreateTag = (taxonomyId: number) => {
const queryClient = useQueryClient();
const intl = useIntl();

return useMutation({
mutationFn: async ({ value, parentTagValue }: { value: string; parentTagValue?: string; }) => {
try {
await getAuthenticatedHttpClient().post(
apiUrls.createTag(taxonomyId),
{ tag: value, parent_tag_value: parentTagValue },
);
} catch (err) {
throw new Error(getApiErrorMessage(err, intl));
}
Comment on lines -241 to -243
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is moving away from the catch/rethrow pattern desired for more than just these functions? Not something I would want to block this PR on, but if we're moving away from that it'd be good to make a follow-up issue to rework the rest of the places the catch/rethrow pattern is used in this file and no longer need to import getApiErrorMessage.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pattern may be fine in other places where we don't want to display a part of a backend response body to the user. Also backend responses are inconsistent right now (there's a backend ADR working on that), so they may be handled differently in different places.

In this case the display string we want to show is in an array in err.response.data[field_key]. We have to extract this in our try catch block higher up anyway, depending on whether this is an AxiosError.
Throwing an Error is firstly not great (it should be specific) and secondly it does not have the fields with the information, so this is removing necessary information.

Rethrowing the AxiosError as a generic error here creates a loss of information, where just surfacing the AxiosError is completely fine. And a line like catch (err) { throw err } serves no purpose.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This pattern may be fine in other places where we don't want to display a part of a backend response body to the user.

I understand the use case for it, it's more of a question of where the error message formatting should live. This PR is moving the formatting for the errors from happening inside this data/apiHooks.ts file into tag-list/hooks.ts. I like that change, and think it likely makes sense to do something similar for some of the other hooks in this file like useImportPlan.

As I mentioned before, this isn't a blocking thing, just something that seems like it'd be worth looking into as a follow-up issue.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure! I think so too.

If the error responses are becoming standardized on the backend, do you think it makes sense to wait for that so that we can handle them in the same way throughout the authoring frontend? Or do you think it's a smaller issue that should be done before then?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have a strong opinion on the timing. If it'll be easier to move away from the catch/rethrow pattern after the backend errors are standardized then waiting likely makes sense. I just want to make sure moving away from the pattern is tracked somewhere if it's something we plan on doing.

await getAuthenticatedHttpClient().post(
apiUrls.createTag(taxonomyId),
{ tag: value, parent_tag_value: parentTagValue },
);
},
onSuccess: () => {
queryClient.invalidateQueries({
Expand All @@ -261,14 +255,10 @@ export const useUpdateTag = (taxonomyId: number) => {

return useMutation({
mutationFn: async ({ value, originalValue }: { value: string; originalValue: string; }) => {
try {
await getAuthenticatedHttpClient().patch(
apiUrls.updateTag(taxonomyId),
{ tag: originalValue, updated_tag_value: value },
);
} catch (err) {
throw new Error(getApiErrorMessage(err));
}
await getAuthenticatedHttpClient().patch(
apiUrls.updateTag(taxonomyId),
{ tag: originalValue, updated_tag_value: value },
);
},
onSuccess: () => {
queryClient.invalidateQueries({
Expand Down
2 changes: 1 addition & 1 deletion src/taxonomy/tag-list/OptionalExpandLink.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { ExpandLess, ExpandMore } from '@openedx/paragon/icons';
import { Row } from '@tanstack/react-table';
import { useIntl } from '@edx/frontend-platform/i18n';

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

interface OptionalExpandLinkProps {
Expand Down
25 changes: 20 additions & 5 deletions src/taxonomy/tag-list/TagListTable.test.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { AxiosError } from 'axios';
import {
render,
waitFor,
Expand Down Expand Up @@ -598,8 +599,16 @@ describe('<TagListTable />', () => {
expect(screen.getByText(/invalid character/i)).toBeInTheDocument();
});

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

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

expect(await screen.findByText('Tag with this name already exists')).toBeInTheDocument();
expect(await screen.findByText('Error creating tag: Tag with this name already exists')).toBeInTheDocument();
});

it('should keep the inline row and show a failure toast when save request fails', async () => {
axiosMock.onPost(createTagUrl).reply(500, {
error: 'Internal server error',
axiosMock.onPost(createTagUrl).reply(() => {
const error = new AxiosError('Request failed with status code 500');
error.response = {
data: {
tag: ['Internal server error'],
},
};
return Promise.reject(error);
});

fireEvent.click(await screen.findByLabelText('Create Tag'));
Expand Down
4 changes: 2 additions & 2 deletions src/taxonomy/tag-list/TagListTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import React, {
useEffect,
} from 'react';
import type { PaginationState } from '@tanstack/react-table';
import { useTagListData, useCreateTag, useUpdateTag } from '../data/apiHooks';
import { TableView } from '@src/taxonomy/tree-table';
import { useTagListData, useCreateTag, useUpdateTag } from '@src/taxonomy/data/apiHooks';
import { TagTree } from './tagTree';
import { TableView } from '../tree-table';
import type {
RowId,
TreeColumnDef,
Expand Down
24 changes: 24 additions & 0 deletions src/taxonomy/tag-list/UsageCountDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import {
Bubble,
} from '@openedx/paragon';
import type { Row } from '@tanstack/react-table';
import type {
TreeRowData,
} from '@src/taxonomy/tree-table/types';
import { getTagListRowData } from './utils';

const UsageCountDisplay = ({ row }: { row: Row<TreeRowData>; }) => {
Comment thread
brian-smith-tcril marked this conversation as resolved.
const count = getTagListRowData(row).usageCount ?? 0;

if (count <= 0) {
return null;
}

return (
<Bubble expandable>
{count}
</Bubble>
);
};

export default UsageCountDisplay;
54 changes: 42 additions & 12 deletions src/taxonomy/tag-list/hooks.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useReducer } from 'react';
import { AxiosError } from 'axios';
import { useIntl } from '@edx/frontend-platform/i18n';

import { useCreateTag, useUpdateTag } from '../data/apiHooks';
import globalMessages from '@src/messages';
import { useCreateTag, useUpdateTag } from '@src/taxonomy/data/apiHooks';
import type { RowId } from '@src/taxonomy/tree-table/types';
import { TagTree } from './tagTree';
import { TagListTableError } from './errors';
import type { RowId } from '../tree-table/types';
import {
TABLE_MODES,
TRANSITION_TABLE,
Expand Down Expand Up @@ -167,6 +169,38 @@ const useEditActions = ({
return true;
};

const formatErrorMessage = (errorMessage: string): string => {
// Remove trailing period for better message formatting
return errorMessage.replace(/\.$/, '');
};

const getAxiosErrorMessage = (axiosError: AxiosError) => {
const responseData = axiosError.response?.data;
const tagError = responseData ?
Object.entries(responseData)?.find((errItem: [string, unknown]) => (
['tag', 'value', 'updated_tag_value'].includes(errItem[0].toLowerCase())
)) :
null;

const errorMessages = tagError ? tagError[1] : (
axiosError.message || intl.formatMessage(globalMessages.unknownError)
);
const errorMessage = Array.isArray(errorMessages) ? errorMessages.join('; ') : String(errorMessages);
return formatErrorMessage(errorMessage);
};

const getErrorMessage = (error: unknown): string => {
if (error instanceof AxiosError) {
return getAxiosErrorMessage(error);
}

if (error instanceof Error && error.message) {
return formatErrorMessage(error.message);
}

return intl.formatMessage(globalMessages.unknownError);
};

const handleCreateTag = async (value: string, parentTagValue?: string) => {
const trimmed = value.trim();

Expand All @@ -186,11 +220,9 @@ const useEditActions = ({
setIsCreatingTopTag(false);
setCreatingParentId(null);
} catch (error) {
const message = intl.formatMessage(messages.tagCreationErrorMessage, { errorMessage: (error as Error)?.message });
setDraftError(
(error as Error)?.message || intl.formatMessage(messages.tagCreationErrorMessage, { errorMessage: '' }),
);
setToast({ show: true, message });
const errorMessage = getErrorMessage(error);
setDraftError(errorMessage);
setToast({ show: true, message: intl.formatMessage(messages.tagCreationErrorMessage, { errorMessage }) });
}
};

Expand All @@ -217,11 +249,9 @@ const useEditActions = ({
message: intl.formatMessage(messages.tagUpdateSuccessMessage, { name: trimmed }),
});
} catch (error) {
const message = intl.formatMessage(messages.tagUpdateErrorMessage, { errorMessage: (error as Error)?.message });
setDraftError(
(error as Error)?.message || intl.formatMessage(messages.tagUpdateErrorMessage, { errorMessage: '' }),
);
setToast({ show: true, message });
const errorMessage = getErrorMessage(error);
setDraftError(errorMessage);
setToast({ show: true, message: intl.formatMessage(messages.tagUpdateErrorMessage, { errorMessage }) });
}
};

Expand Down
37 changes: 8 additions & 29 deletions src/taxonomy/tag-list/tagColumns.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,4 @@
import {
Bubble,
Icon,
IconButton,
IconButtonWithTooltip,
Expand All @@ -12,28 +11,19 @@ import {
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import type { Row } from '@tanstack/react-table';

import messages from './messages';
import type {
RowId,
TreeColumnDef,
TreeRowData,
} from '../tree-table/types';
} from '@src/taxonomy/tree-table/types';
import type { TagListRowData } from './types';
import messages from './messages';
import OptionalExpandLink from './OptionalExpandLink';
import UsageCountDisplay from './UsageCountDisplay';
import { getTagListRowData } from './utils';

const EDITABLE_COLUMNS = ['value'];

interface TagListRowData extends TreeRowData {
depth: number;
childCount: number;
usageCount?: number;
isNew?: boolean;
isEditing?: boolean;
}

const asTagListRowData = (row: Row<TreeRowData>): TagListRowData => (
row.original as unknown as TagListRowData
);

interface GetColumnsArgs {
setIsCreatingTopTag: (isCreating: boolean) => void;
setCreatingParentId: (id: RowId | null) => void;
Expand All @@ -49,17 +39,6 @@ interface GetColumnsArgs {
maxDepth: number;
}

const UsageCountDisplay = ({ row }: { row: Row<TreeRowData>; }) => {
const count = asTagListRowData(row).usageCount ?? 0;
return (
count > 0 && (
<Bubble expandable>
{count}
</Bubble>
)
);
};

interface ActionsHeaderProps {
onStartDraft: () => void;
setDraftError: (error: string) => void;
Expand Down Expand Up @@ -175,7 +154,7 @@ function getColumns({
cell: ({ row }) => {
const {
value,
} = asTagListRowData(row);
} = getTagListRowData(row);

return (
<span className="d-flex align-items-center gap-2">
Expand Down Expand Up @@ -205,14 +184,14 @@ function getColumns({
/>
),
cell: ({ row }) => {
const rowData = asTagListRowData(row);
const rowData = getTagListRowData(row);

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

const disableAddSubtag = hasOpenDraft || !canAddTag;
const disableEditTag = hasOpenDraft || row.original.canChangeTag === false;
const disableEditTag = hasOpenDraft || rowData.canChangeTag === false;

const startSubtagDraft = () => {
onStartDraft();
Expand Down
2 changes: 1 addition & 1 deletion src/taxonomy/tag-list/tagTree.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { TagData } from '@src/taxonomy/data/types';
import { TagTreeError } from './errors';
import type { TagData } from '../data/types';

export interface TagTreeNode extends TagData {
subRows?: TagTreeNode[];
Expand Down
9 changes: 9 additions & 0 deletions src/taxonomy/tag-list/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { TreeRowData } from '@src/taxonomy/tree-table/types';

export interface TagListRowData extends TreeRowData {
depth: number;
childCount: number;
usageCount?: number;
isNew?: boolean;
isEditing?: boolean;
}
12 changes: 12 additions & 0 deletions src/taxonomy/tag-list/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { Row } from '@openedx/paragon';
import { TreeRowData } from '@src/taxonomy/tree-table/types';
import { TagListRowData } from './types';

/** getTagListRowData
*
* Minimal getter function for `row.original`. Mainly because the naming of `original` is not expressive,
* and it needs to be cast to the correct type.
*/
export const getTagListRowData = (row: Row<TreeRowData>): TagListRowData => (
row.original as unknown as TagListRowData
);
3 changes: 1 addition & 2 deletions src/taxonomy/tree-table/CreateRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import React from 'react';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { fireEvent, render, screen } from '@testing-library/react';

import { CreateRow } from './CreateRow';
import CreateRow from './CreateRow';

const wrapper = ({ children }: { children: React.ReactNode; }) => (
<IntlProvider locale="en" messages={{}}>{children}</IntlProvider>
Expand All @@ -15,7 +15,6 @@ const baseProps = () => ({
setIsCreatingTopRow: jest.fn(),
exitDraftWithoutSave: jest.fn(),
createRowMutation: { isPending: false },
columns: [{ id: 'value' }],
validate: jest.fn((value: string) => value.trim().length > 0),
});

Expand Down
Loading