diff --git a/src/messages.ts b/src/messages.ts index 1620914b5b..06f7ffd519 100644 --- a/src/messages.ts +++ b/src/messages.ts @@ -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; diff --git a/src/taxonomy/data/apiHooks.test.jsx b/src/taxonomy/data/apiHooks.test.jsx index 94120d1eff..f80d2c9025 100644 --- a/src/taxonomy/data/apiHooks.test.jsx +++ b/src/taxonomy/data/apiHooks.test.jsx @@ -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]); + } }); }); diff --git a/src/taxonomy/data/apiHooks.ts b/src/taxonomy/data/apiHooks.ts index 4fe1a3d06f..b47ee23f1b 100644 --- a/src/taxonomy/data/apiHooks.ts +++ b/src/taxonomy/data/apiHooks.ts @@ -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'; @@ -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)); - } + await getAuthenticatedHttpClient().post( + apiUrls.createTag(taxonomyId), + { tag: value, parent_tag_value: parentTagValue }, + ); }, onSuccess: () => { queryClient.invalidateQueries({ @@ -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({ diff --git a/src/taxonomy/tag-list/OptionalExpandLink.tsx b/src/taxonomy/tag-list/OptionalExpandLink.tsx index edfa6580f2..e42c198dcb 100644 --- a/src/taxonomy/tag-list/OptionalExpandLink.tsx +++ b/src/taxonomy/tag-list/OptionalExpandLink.tsx @@ -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 { diff --git a/src/taxonomy/tag-list/TagListTable.test.jsx b/src/taxonomy/tag-list/TagListTable.test.jsx index 5b30ad9c25..d9022c84fb 100644 --- a/src/taxonomy/tag-list/TagListTable.test.jsx +++ b/src/taxonomy/tag-list/TagListTable.test.jsx @@ -1,4 +1,5 @@ import React from 'react'; +import { AxiosError } from 'axios'; import { render, waitFor, @@ -598,8 +599,16 @@ describe('', () => { 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'); @@ -609,12 +618,18 @@ describe('', () => { 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')); diff --git a/src/taxonomy/tag-list/TagListTable.tsx b/src/taxonomy/tag-list/TagListTable.tsx index c1a1a92dcf..c2d00319d8 100644 --- a/src/taxonomy/tag-list/TagListTable.tsx +++ b/src/taxonomy/tag-list/TagListTable.tsx @@ -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, diff --git a/src/taxonomy/tag-list/UsageCountDisplay.tsx b/src/taxonomy/tag-list/UsageCountDisplay.tsx new file mode 100644 index 0000000000..d5ef03bb45 --- /dev/null +++ b/src/taxonomy/tag-list/UsageCountDisplay.tsx @@ -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; }) => { + const count = getTagListRowData(row).usageCount ?? 0; + + if (count <= 0) { + return null; + } + + return ( + + {count} + + ); +}; + +export default UsageCountDisplay; diff --git a/src/taxonomy/tag-list/hooks.ts b/src/taxonomy/tag-list/hooks.ts index c6a4f12c42..a1518b9de9 100644 --- a/src/taxonomy/tag-list/hooks.ts +++ b/src/taxonomy/tag-list/hooks.ts @@ -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, @@ -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(); @@ -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 }) }); } }; @@ -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 }) }); } }; diff --git a/src/taxonomy/tag-list/tagColumns.tsx b/src/taxonomy/tag-list/tagColumns.tsx index a7b459861a..7b8874331f 100644 --- a/src/taxonomy/tag-list/tagColumns.tsx +++ b/src/taxonomy/tag-list/tagColumns.tsx @@ -1,5 +1,4 @@ import { - Bubble, Icon, IconButton, IconButtonWithTooltip, @@ -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): TagListRowData => ( - row.original as unknown as TagListRowData -); - interface GetColumnsArgs { setIsCreatingTopTag: (isCreating: boolean) => void; setCreatingParentId: (id: RowId | null) => void; @@ -49,17 +39,6 @@ interface GetColumnsArgs { maxDepth: number; } -const UsageCountDisplay = ({ row }: { row: Row; }) => { - const count = asTagListRowData(row).usageCount ?? 0; - return ( - count > 0 && ( - - {count} - - ) - ); -}; - interface ActionsHeaderProps { onStartDraft: () => void; setDraftError: (error: string) => void; @@ -175,7 +154,7 @@ function getColumns({ cell: ({ row }) => { const { value, - } = asTagListRowData(row); + } = getTagListRowData(row); return ( @@ -205,14 +184,14 @@ function getColumns({ /> ), cell: ({ row }) => { - const rowData = asTagListRowData(row); + const rowData = getTagListRowData(row); if (rowData.isNew || rowData.isEditing) { return
; } const disableAddSubtag = hasOpenDraft || !canAddTag; - const disableEditTag = hasOpenDraft || row.original.canChangeTag === false; + const disableEditTag = hasOpenDraft || rowData.canChangeTag === false; const startSubtagDraft = () => { onStartDraft(); diff --git a/src/taxonomy/tag-list/tagTree.ts b/src/taxonomy/tag-list/tagTree.ts index fe68df0e66..eddf27d002 100644 --- a/src/taxonomy/tag-list/tagTree.ts +++ b/src/taxonomy/tag-list/tagTree.ts @@ -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[]; diff --git a/src/taxonomy/tag-list/types.ts b/src/taxonomy/tag-list/types.ts new file mode 100644 index 0000000000..4c27d0e133 --- /dev/null +++ b/src/taxonomy/tag-list/types.ts @@ -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; +} diff --git a/src/taxonomy/tag-list/utils.ts b/src/taxonomy/tag-list/utils.ts new file mode 100644 index 0000000000..012ca2b824 --- /dev/null +++ b/src/taxonomy/tag-list/utils.ts @@ -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): TagListRowData => ( + row.original as unknown as TagListRowData +); diff --git a/src/taxonomy/tree-table/CreateRow.test.tsx b/src/taxonomy/tree-table/CreateRow.test.tsx index cca6f8a18f..57a7840441 100644 --- a/src/taxonomy/tree-table/CreateRow.test.tsx +++ b/src/taxonomy/tree-table/CreateRow.test.tsx @@ -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; }) => ( {children} @@ -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), }); diff --git a/src/taxonomy/tree-table/CreateRow.tsx b/src/taxonomy/tree-table/CreateRow.tsx index 4091db0a56..f130560b02 100644 --- a/src/taxonomy/tree-table/CreateRow.tsx +++ b/src/taxonomy/tree-table/CreateRow.tsx @@ -1,24 +1,6 @@ -import React, { useState } from 'react'; -import { Button, Spinner } from '@openedx/paragon'; -import { useIntl } from '@edx/frontend-platform/i18n'; - -import { EditableCell } from './EditableCell'; -import type { CreateRowMutationState, TreeColumnDef } from './types'; -import messages from './messages'; - -interface DraftRowProps { - draftError: string; - initialValue?: string; - onSave: (value: string) => void; - onCancel: () => void; - mutationState: CreateRowMutationState; - columns: TreeColumnDef[]; - indent?: number; - validate: (value: string, mode?: 'soft' | 'hard') => boolean; - requireValueChangeToEnableSave?: boolean; - rowTestId?: string; - rowId?: string; -} +import React from 'react'; +import type { CreateRowMutationState } from './types'; +import DraftRow from './DraftRow'; interface CreateRowProps { draftError: string; @@ -27,118 +9,10 @@ interface CreateRowProps { setIsCreatingTopRow: (isCreating: boolean) => void; exitDraftWithoutSave: () => void; createRowMutation: CreateRowMutationState; - columns: TreeColumnDef[]; indent?: number; validate: (value: string, mode?: 'soft' | 'hard') => boolean; } -interface EditRowProps { - draftError: string; - setDraftError: (error: string) => void; - initialValue: string; - handleUpdateRow: (value: string) => void; - cancelEditRow: () => void; - updateRowMutation: CreateRowMutationState; - columns: TreeColumnDef[]; - indent?: number; - validate: (value: string, mode?: 'soft' | 'hard') => boolean; -} - -const DraftRow: React.FC = ({ - draftError, - initialValue = '', - onSave, - onCancel, - mutationState, - columns, - indent = 0, - validate, - requireValueChangeToEnableSave = false, - rowTestId, - rowId, -}) => { - const [rowValue, setRowValue] = useState(initialValue); - const [saveDisabled, setSaveDisabled] = useState(true); - const intl = useIntl(); - - const updateSaveDisabled = (value: string) => { - const trimmedValue = value.trim(); - const isValid = validate(value, 'soft'); - const isUnchanged = requireValueChangeToEnableSave && trimmedValue === initialValue.trim(); - setSaveDisabled(!isValid || !trimmedValue || isUnchanged || mutationState.isPending || false); - }; - - const handleValueChange = (e: React.ChangeEvent) => { - const { value } = e.target; - setRowValue(value); - updateSaveDisabled(value); - }; - - const handleSave = () => { - onSave(rowValue.trim()); - }; - - const handleValueCellKeyPress = (e: React.KeyboardEvent) => { - if (e.key === 'Enter' && !saveDisabled && !draftError) { - e.preventDefault(); - handleSave(); - return; - } - - if (e.key === 'Escape') { - e.preventDefault(); - onCancel(); - } - }; - - return ( - - -
- -
- - - - - - - - - - {mutationState.isPending && ( - - )} - - - - ); -}; - const CreateRow: React.FC = ({ draftError, setDraftError, @@ -146,7 +20,6 @@ const CreateRow: React.FC = ({ setIsCreatingTopRow, exitDraftWithoutSave, createRowMutation, - columns, indent = 0, validate, }) => { @@ -162,7 +35,6 @@ const CreateRow: React.FC = ({ onSave={handleCreateRow} onCancel={handleCancel} mutationState={createRowMutation} - columns={columns} indent={indent} validate={validate} rowId="creating-top-row" @@ -171,35 +43,4 @@ const CreateRow: React.FC = ({ ); }; -const EditRow: React.FC = ({ - draftError, - setDraftError, - initialValue, - handleUpdateRow, - cancelEditRow, - updateRowMutation, - columns, - indent = 0, - validate, -}) => { - const handleCancel = () => { - setDraftError(''); - cancelEditRow(); - }; - - return ( - - ); -}; - -export { CreateRow, EditRow }; +export default CreateRow; diff --git a/src/taxonomy/tree-table/DraftRow.tsx b/src/taxonomy/tree-table/DraftRow.tsx new file mode 100644 index 0000000000..1be3b01a2c --- /dev/null +++ b/src/taxonomy/tree-table/DraftRow.tsx @@ -0,0 +1,120 @@ +import React, { useState } from 'react'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { Button, Spinner } from '@openedx/paragon'; +import { Row } from '@tanstack/react-table'; + +import UsageCountDisplay from '@src/taxonomy/tag-list/UsageCountDisplay'; +import { EditableCell } from './EditableCell'; +import type { CreateRowMutationState, TreeRowData } from './types'; +import messages from './messages'; + +interface DraftRowProps { + draftError: string; + initialValue?: string; + onSave: (value: string) => void; + onCancel: () => void; + mutationState: CreateRowMutationState; + indent?: number; + validate: (value: string, mode?: 'soft' | 'hard') => boolean; + requireValueChangeToEnableSave?: boolean; + rowTestId?: string; + rowId?: string; + row?: Row; +} + +const DraftRow: React.FC = ({ + draftError, + initialValue = '', + onSave, + onCancel, + mutationState, + indent = 0, + validate, + requireValueChangeToEnableSave = false, + rowTestId, + rowId, + row, +}) => { + const [rowValue, setRowValue] = useState(initialValue); + const [saveDisabled, setSaveDisabled] = useState(true); + const intl = useIntl(); + + const updateSaveDisabled = (value: string) => { + const trimmedValue = value.trim(); + const isValid = validate(value, 'soft'); + const isUnchanged = requireValueChangeToEnableSave && trimmedValue === initialValue.trim(); + setSaveDisabled(!isValid || !trimmedValue || isUnchanged || mutationState.isPending || false); + }; + + const handleValueChange = (e: React.ChangeEvent) => { + const { value } = e.target; + setRowValue(value); + updateSaveDisabled(value); + }; + + const handleSave = () => { + onSave(rowValue.trim()); + }; + + const handleValueCellKeyPress = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && !saveDisabled && !draftError) { + e.preventDefault(); + handleSave(); + return; + } + + if (e.key === 'Escape') { + e.preventDefault(); + onCancel(); + } + }; + + return ( + + +
+ +
+ + + {row ? : null} + + + + + + + + + + {mutationState.isPending && ( + + )} + + + + ); +}; + +export default DraftRow; diff --git a/src/taxonomy/tree-table/EditRow.tsx b/src/taxonomy/tree-table/EditRow.tsx new file mode 100644 index 0000000000..12a83fa055 --- /dev/null +++ b/src/taxonomy/tree-table/EditRow.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import { Row } from '@tanstack/react-table'; +import type { CreateRowMutationState, TreeRowData } from './types'; +import DraftRow from './DraftRow'; + +interface EditRowProps { + draftError: string; + setDraftError: (error: string) => void; + initialValue: string; + handleUpdateRow: (value: string) => void; + cancelEditRow: () => void; + updateRowMutation: CreateRowMutationState; + indent?: number; + validate: (value: string, mode?: 'soft' | 'hard') => boolean; + row: Row; +} + +const EditRow: React.FC = ({ + draftError, + setDraftError, + initialValue, + handleUpdateRow, + cancelEditRow, + updateRowMutation, + indent = 0, + validate, + row, +}) => { + const handleCancel = () => { + setDraftError(''); + cancelEditRow(); + }; + + return ( + + ); +}; + +export default EditRow; diff --git a/src/taxonomy/tree-table/EditableCell.tsx b/src/taxonomy/tree-table/EditableCell.tsx index 8cdc10aabc..d4de95fa74 100644 --- a/src/taxonomy/tree-table/EditableCell.tsx +++ b/src/taxonomy/tree-table/EditableCell.tsx @@ -7,8 +7,9 @@ import React, { import { Form } from '@openedx/paragon'; import { useIntl } from '@edx/frontend-platform/i18n'; + +import OptionalExpandLink from '@src/taxonomy/tag-list/OptionalExpandLink'; import messages from './messages'; -import OptionalExpandLink from '../tag-list/OptionalExpandLink'; /** * Props for the EditableCell component. diff --git a/src/taxonomy/tree-table/NestedRows.tsx b/src/taxonomy/tree-table/NestedRows.tsx index b3735e00c3..c2647f9f98 100644 --- a/src/taxonomy/tree-table/NestedRows.tsx +++ b/src/taxonomy/tree-table/NestedRows.tsx @@ -7,7 +7,8 @@ import type { TreeColumnDef, CreateRowMutationState, } from './types'; -import { CreateRow, EditRow } from './CreateRow'; +import CreateRow from './CreateRow'; +import EditRow from './EditRow'; interface NestedRowsProps { /** The parent row object from TanStack React Table */ @@ -103,7 +104,6 @@ const NestedRows = ({ setIsCreatingTopRow={setIsCreatingTopRow} exitDraftWithoutSave={onCancelCreation} createRowMutation={createRowMutation} - columns={[]} indent={indent} validate={validate} /> @@ -124,9 +124,9 @@ const NestedRows = ({ exitDraftWithoutSave(); }} updateRowMutation={updateRowMutation} - columns={columns} indent={indent} validate={validate} + row={row} /> ) : ( @@ -139,9 +139,7 @@ const NestedRows = ({ return ( {isFirstColumn ?
{content}
: diff --git a/src/taxonomy/tree-table/SaveErrorAlert.tsx b/src/taxonomy/tree-table/SaveErrorAlert.tsx new file mode 100644 index 0000000000..ef0788140f --- /dev/null +++ b/src/taxonomy/tree-table/SaveErrorAlert.tsx @@ -0,0 +1,44 @@ +import React, { useEffect } from 'react'; +import { + Alert, +} from '@openedx/paragon'; + +import { Info } from '@openedx/paragon/icons'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import './TableView.scss'; +import messages from './messages'; + +interface SaveErrorAlertProps { + draftError: string | undefined; + isError: boolean | undefined; + isUpdateError: boolean | undefined; +} +const SaveErrorAlert = ({ draftError, isError, isUpdateError }: SaveErrorAlertProps) => { + const intl = useIntl(); + const hasError: boolean = Boolean((isError || isUpdateError) && !!draftError); + const [alertOpen, setAlertOpen] = React.useState(hasError); + + useEffect(() => { + setAlertOpen(hasError); + }, [hasError, isError, isUpdateError, draftError]); + + if (!alertOpen) { return null; } + + return ( + { + setAlertOpen(false); + }} + > + + {intl.formatMessage(messages.errorSavingTitle)} + + {intl.formatMessage(messages.errorSavingMessage, { errorMessage: draftError })} + + ); +}; + +export default SaveErrorAlert; diff --git a/src/taxonomy/tree-table/TableBody.tsx b/src/taxonomy/tree-table/TableBody.tsx index e7b42c3114..0962c4c6c7 100644 --- a/src/taxonomy/tree-table/TableBody.tsx +++ b/src/taxonomy/tree-table/TableBody.tsx @@ -13,7 +13,8 @@ import type { TreeColumnDef, TreeTable, } from './types'; -import { CreateRow, EditRow } from './CreateRow'; +import CreateRow from './CreateRow'; +import EditRow from './EditRow'; interface TableBodyProps { columns: TreeColumnDef[]; @@ -86,7 +87,6 @@ const TableBody = ({ setIsCreatingTopRow={setIsCreatingTopRow} exitDraftWithoutSave={exitDraftWithoutSave} createRowMutation={createRowMutation} - columns={columns} validate={validate} /> )} @@ -105,15 +105,15 @@ const TableBody = ({ exitDraftWithoutSave(); }} updateRowMutation={updateRowMutation} - columns={columns} validate={validate} + row={row} /> ) : ( {row.getVisibleCells() - .map((cell, index) => ( - + .map((cell) => ( + {flexRender(cell.column.columnDef.cell, cell.getContext())} ))} diff --git a/src/taxonomy/tree-table/TableView.scss b/src/taxonomy/tree-table/TableView.scss index 19647fadf2..706a0eb5f8 100644 --- a/src/taxonomy/tree-table/TableView.scss +++ b/src/taxonomy/tree-table/TableView.scss @@ -10,11 +10,7 @@ overflow-wrap: anywhere; } -.tree-table-indent { - padding-inline-start: var(--pgn-spacing-spacer-base); -} - -@for $depth from 2 through 10 { +@for $depth from 0 through 10 { .tree-table-indent-#{$depth} { padding-inline-start: calc(var(--pgn-spacing-spacer-base) * #{$depth}); } diff --git a/src/taxonomy/tree-table/TableView.test.tsx b/src/taxonomy/tree-table/TableView.test.tsx index ff7b50cd88..eca55131b7 100644 --- a/src/taxonomy/tree-table/TableView.test.tsx +++ b/src/taxonomy/tree-table/TableView.test.tsx @@ -48,6 +48,7 @@ describe('TableView', () => { it('shows and dismisses save error banner', () => { const props = baseProps(); props.createRowMutation = { isPending: false, isError: true }; + props.draftError = 'Request failed with status code 500'; render(, { wrapper }); diff --git a/src/taxonomy/tree-table/TableView.tsx b/src/taxonomy/tree-table/TableView.tsx index a1c4dc7ae5..707792f452 100644 --- a/src/taxonomy/tree-table/TableView.tsx +++ b/src/taxonomy/tree-table/TableView.tsx @@ -5,7 +5,6 @@ import { Card, ActionRow, Pagination, - Alert, Icon, } from '@openedx/paragon'; @@ -18,7 +17,7 @@ import { type PaginationState, } from '@tanstack/react-table'; -import { ArrowDropUpDown, Info } from '@openedx/paragon/icons'; +import { ArrowDropUpDown } from '@openedx/paragon/icons'; import { useIntl } from '@edx/frontend-platform/i18n'; import TableBody from './TableBody'; import './TableView.scss'; @@ -30,6 +29,7 @@ import type { TreeRowData, } from './types'; import messages from './messages'; +import SaveErrorAlert from './SaveErrorAlert'; interface TableViewProps { treeData: TreeRowData[]; @@ -102,25 +102,10 @@ const TableView = ({ const { isError } = createRowMutation; const { isError: isUpdateError } = updateRowMutation; - const [showError, setShowError] = React.useState(true); return ( <> - {(isError || isUpdateError) && showError && ( - setShowError(false)} - > - - {intl.formatMessage(messages.errorSavingTitle)} - - {intl.formatMessage(messages.errorSavingMessage, { - errorMessage: draftError || intl.formatMessage(messages.errorSavingMessage, { errorMessage: '' }), - })} - - )} +
@@ -147,7 +132,7 @@ const TableView = ({ {headerGroup.headers.map((header, index) => ( {header.isPlaceholder ? null