Skip to content
Open
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;
2 changes: 1 addition & 1 deletion src/taxonomy/data/apiHooks.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@ describe('import taxonomy api calls', () => {
});

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

Expand Down
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 @@ -214,18 +213,13 @@ export const useSubTags = (taxonomyId: number, parentTagValue: string) => useQue

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));
}
await getAuthenticatedHttpClient().post(
apiUrls.createTag(taxonomyId),
{ tag: value, parent_tag_value: parentTagValue },
);
},
onSuccess: () => {
queryClient.invalidateQueries({
Expand All @@ -246,14 +240,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
56 changes: 56 additions & 0 deletions src/taxonomy/tag-list/TagListContext.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import React, { createContext, useContext } from 'react';

import type {
RowId,
CreateRowMutationState,
TreeRowData,
TreeColumnDef,
ToastState,
TreeTable,
} from '@src/taxonomy/tree-table/types';
import { OnChangeFn, PaginationState } from '@tanstack/react-table';

interface TagListContextValue {
isCreatingTopTag: boolean;
setIsCreatingTopTag: React.Dispatch<React.SetStateAction<boolean>>;
creatingParentId: RowId | null;
setCreatingParentId: React.Dispatch<React.SetStateAction<RowId | null>>;
editingRowId: RowId | null;
setEditingRowId: React.Dispatch<React.SetStateAction<RowId | null>>;
draftError: string;
setDraftError: React.Dispatch<React.SetStateAction<string>>;
hasOpenDraft: boolean;
canAddTag: boolean;
maxDepth: number;
createTagMutation: CreateRowMutationState;
updateTagMutation: CreateRowMutationState;
handleCreateTag: (value: string, parentTagValue?: string) => Promise<void>;
handleUpdateTag: (value: string, originalValue: string) => Promise<void>;
validate: (value: string, mode?: 'soft' | 'hard') => boolean;
startDraftMode: () => void;
exitDraftWithoutSave: () => void;
treeData: TreeRowData[];
columns: TreeColumnDef[];
pageCount: number;
enablePagination?: boolean;
pagination: PaginationState;
handlePaginationChange: OnChangeFn<PaginationState>;
isLoading: boolean;
toast: ToastState;
setToast: React.Dispatch<React.SetStateAction<ToastState>>;
table: TreeTable | null;
setTable: React.Dispatch<React.SetStateAction<TreeTable | null>>;
}

const TagListContext = createContext<TagListContextValue | null>(null);

const useTagListContext = (): TagListContextValue => {
const context = useContext(TagListContext);
if (!context) {
throw new Error('useTagListContext must be used within TagListContext.Provider');
}
return context;
};

export type { TagListContextValue };
export { TagListContext, useTagListContext };
26 changes: 21 additions & 5 deletions src/taxonomy/tag-list/TagListTable.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -587,8 +587,17 @@ 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 Error('Request failed with status code 400');
error.name = 'AxiosError';
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 @@ -598,12 +607,19 @@ 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 Error('Request failed with status code 500');
error.name = 'AxiosError';
error.response = {
data: {
tag: ['Internal server error'],
},
};
return Promise.reject(error);
});

fireEvent.click(await screen.findByLabelText('Create Tag'));
Expand Down
114 changes: 45 additions & 69 deletions src/taxonomy/tag-list/TagListTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,18 +4,19 @@ import React, {
useEffect,
} from 'react';
import type { PaginationState } from '@tanstack/react-table';
import { useTagListData, useCreateTag, useUpdateTag } from '../data/apiHooks';
import { TagTree } from './tagTree';
import { TableView } from '../tree-table';
import { TableView } from '@src/taxonomy/tree-table';
import { useTagListData, useCreateTag, useUpdateTag } from '@src/taxonomy/data/apiHooks';
import type {
RowId,
TreeColumnDef,
TreeRowData,
} from '../tree-table/types';
TreeTable,
} from '@src/taxonomy/tree-table/types';
import { TagTree } from './tagTree';
import {
TABLE_MODES,
} from './constants';
import { getColumns } from './tagColumns';
import { useTagColumns } from './tagColumns';
import { TagListContext } from './TagListContext';
import { useTableModes, useEditActions } from './hooks';

interface TagListTableProps {
Expand Down Expand Up @@ -47,8 +48,9 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => {
const [toast, setToast] = useState({ show: false, message: '', variant: 'success' });
const [tagTree, setTagTree] = useState<TagTree | null>(null);
const [isCreatingTopTag, setIsCreatingTopTag] = useState(false);
const [activeActionMenuRowId, setActiveActionMenuRowId] = useState<RowId | null>(null);
const [draftError, setDraftError] = useState('');
const [table, setTable] = useState<TreeTable | null>(null);

const treeData = (tagTree?.getAllAsDeepCopy() || []) as unknown as TreeRowData[];
const hasOpenDraft = isCreatingTopTag || creatingParentId !== null || editingRowId !== null;

Expand Down Expand Up @@ -81,8 +83,6 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => {
const updateTagMutation = useUpdateTag(taxonomyId);
const pageCount = tagList?.numPages ?? -1;

// TODO: to make this more readable, introduce a React context for the TagListTable instead of passing props.

// Custom Edit Actions Hook - handles table mode transitions, API calls,
// and updating the table without a full data reload when creating or editing tags.
const { handleCreateTag, handleUpdateTag, validate } = useEditActions({
Expand All @@ -98,40 +98,39 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => {
setEditingRowId,
});

const columns = useMemo<TreeColumnDef[]>(
() => getColumns({
setIsCreatingTopTag,
setCreatingParentId,
handleUpdateTag,
setEditingRowId,
onStartDraft: enterDraftMode,
setActiveActionMenuRowId,
hasOpenDraft,
canAddTag: tagList?.canAddTag !== false,
draftError,
setDraftError,
isSavingDraft: createTagMutation.isPending,
maxDepth,
}),
[
isCreatingTopTag,
tableMode,
activeActionMenuRowId,
hasOpenDraft,
creatingParentId,
tagList?.canAddTag,
draftError,
createTagMutation.isPending,
maxDepth,
setIsCreatingTopTag,
setCreatingParentId,
handleUpdateTag,
setEditingRowId,
enterDraftMode,
setActiveActionMenuRowId,
setDraftError,
],
);
const columns = useTagColumns();

// eslint-disable-next-line react/jsx-no-constructed-context-values
const contextValue = {
isCreatingTopTag,
setIsCreatingTopTag,
creatingParentId,
setCreatingParentId,
editingRowId,
setEditingRowId,
draftError,
setDraftError,
hasOpenDraft,
canAddTag: tagList?.canAddTag !== false,
maxDepth,
createTagMutation,
updateTagMutation,
handleCreateTag,
handleUpdateTag,
validate,
startDraftMode: enterDraftMode,
exitDraftWithoutSave,
treeData,
pageCount,
pagination,
handlePaginationChange,
isLoading,
toast,
setToast,
columns,
table,
setTable,
};

// RELOAD DATA IN VIEW MODE
useEffect(() => {
Expand All @@ -146,32 +145,9 @@ const TagListTable = ({ taxonomyId, maxDepth }: TagListTableProps) => {
}, [tagList?.results, tableMode]);

return (
<TableView
{...{
treeData,
columns,
pageCount,
pagination,
handlePaginationChange,
isLoading,
isCreatingTopRow: isCreatingTopTag,
draftError,
createRowMutation: createTagMutation,
updateRowMutation: updateTagMutation,
handleCreateRow: handleCreateTag,
handleUpdateRow: handleUpdateTag,
toast,
setToast,
setIsCreatingTopRow: setIsCreatingTopTag,
exitDraftWithoutSave,
creatingParentId,
setCreatingParentId,
setDraftError,
validate,
editingRowId,
setEditingRowId,
}}
/>
<TagListContext.Provider value={contextValue}>
<TableView />
</TagListContext.Provider>
);
};

Expand Down
28 changes: 28 additions & 0 deletions src/taxonomy/tag-list/UsageCountDisplay.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {
Bubble,
} from '@openedx/paragon';
import type { Row } from '@tanstack/react-table';
import type {
TreeRowData,
} from '@src/taxonomy/tree-table/types';
import { TagListRowData } from './types';

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

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

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

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

export default UsageCountDisplay;
Loading
Loading