From 5a673610b525eee7af979eef990824aa72b3f36b Mon Sep 17 00:00:00 2001 From: Tomas Date: Wed, 15 Jan 2025 14:27:57 +0100 Subject: [PATCH 1/3] fix: Add error handling content library component --- .../AppContentLibrary.test.tsx | 58 ++++++---- .../appContentLibrary/AppContentLibrary.tsx | 101 +++++++++++++----- frontend/language/src/nb.json | 1 + 3 files changed, 111 insertions(+), 49 deletions(-) diff --git a/frontend/app-development/features/appContentLibrary/AppContentLibrary.test.tsx b/frontend/app-development/features/appContentLibrary/AppContentLibrary.test.tsx index 33f988cc2ce..93ed5b6ae3e 100644 --- a/frontend/app-development/features/appContentLibrary/AppContentLibrary.test.tsx +++ b/frontend/app-development/features/appContentLibrary/AppContentLibrary.test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import { screen } from '@testing-library/react'; +import { screen, waitFor } from '@testing-library/react'; import { AppContentLibrary } from './AppContentLibrary'; import { textMock } from '@studio/testing/mocks/i18nMock'; import { renderWithProviders } from '../../test/mocks'; @@ -12,6 +12,7 @@ import userEvent from '@testing-library/user-event'; import type { CodeList } from '@studio/components'; import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext'; import type { OptionListData } from 'app-shared/types/OptionList'; +import type { QueryClient } from '@tanstack/react-query'; const uploadCodeListButtonTextMock = 'Upload Code List'; const updateCodeListButtonTextMock = 'Update Code List'; @@ -51,7 +52,7 @@ describe('AppContentLibrary', () => { afterEach(jest.clearAllMocks); it('renders the AppContentLibrary with codeLists and images resources available in the content menu', () => { - renderAppContentLibrary(); + renderAppContentLibraryWithOptionLists(); const libraryTitle = screen.getByRole('heading', { name: textMock('app_content_library.landing_page.title'), }); @@ -63,14 +64,22 @@ describe('AppContentLibrary', () => { }); it('renders a spinner when waiting for option lists', () => { - renderAppContentLibrary({ shouldPutDataOnCache: false }); + renderAppContentLibrary(); const spinner = screen.getByText(textMock('general.loading')); expect(spinner).toBeInTheDocument(); }); + it('Renders an error message when the option lists query fails', async () => { + const getOptionLists = () => Promise.reject([]); + renderAppContentLibrary({ queries: { getOptionLists } }); + await waitFor(expect(screen.queryByText(textMock('general.loading'))).not.toBeInTheDocument); + const errorMessage = screen.getByText(textMock('app_content_library.fetch_error')); + expect(errorMessage).toBeInTheDocument(); + }); + it('calls onUploadOptionList when onUploadCodeList is triggered', async () => { const user = userEvent.setup(); - renderAppContentLibrary(); + renderAppContentLibraryWithOptionLists(); await goToLibraryPage(user, 'code_lists'); const uploadCodeListButton = screen.getByRole('button', { name: uploadCodeListButtonTextMock }); await user.click(uploadCodeListButton); @@ -80,7 +89,7 @@ describe('AppContentLibrary', () => { it('renders success toast when onUploadOptionList is called successfully', async () => { const user = userEvent.setup(); - renderAppContentLibrary(); + renderAppContentLibraryWithOptionLists(); await goToLibraryPage(user, 'code_lists'); const uploadCodeListButton = screen.getByRole('button', { name: uploadCodeListButtonTextMock }); await user.click(uploadCodeListButton); @@ -93,7 +102,7 @@ describe('AppContentLibrary', () => { it('renders error toast when onUploadOptionList is rejected with unknown error code', async () => { const user = userEvent.setup(); const uploadOptionList = jest.fn().mockImplementation(() => Promise.reject({ response: {} })); - renderAppContentLibrary({ queries: { uploadOptionList } }); + renderAppContentLibraryWithOptionLists({ queries: { uploadOptionList } }); await goToLibraryPage(user, 'code_lists'); const uploadCodeListButton = screen.getByRole('button', { name: uploadCodeListButtonTextMock }); await user.click(uploadCodeListButton); @@ -105,7 +114,7 @@ describe('AppContentLibrary', () => { it('calls onUpdateOptionList when onUpdateCodeList is triggered', async () => { const user = userEvent.setup(); - renderAppContentLibrary(); + renderAppContentLibraryWithOptionLists(); await goToLibraryPage(user, 'code_lists'); const updateCodeListButton = screen.getByRole('button', { name: updateCodeListButtonTextMock }); await user.click(updateCodeListButton); @@ -120,7 +129,7 @@ describe('AppContentLibrary', () => { it('calls onUpdateOptionListId when onUpdateCodeListId is triggered', async () => { const user = userEvent.setup(); - renderAppContentLibrary(); + renderAppContentLibraryWithOptionLists(); await goToLibraryPage(user, 'code_lists'); const updateCodeListIdButton = screen.getByRole('button', { name: updateCodeListIdButtonTextMock, @@ -144,21 +153,30 @@ const goToLibraryPage = async (user: UserEvent, libraryPage: string) => { await user.click(libraryPageNavTile); }; -type renderAppContentLibraryProps = { +type RenderAppContentLibraryProps = { queries?: Partial; - shouldPutDataOnCache?: boolean; - optionListsData?: OptionListData[]; + queryClient?: QueryClient; }; const renderAppContentLibrary = ({ queries = {}, - shouldPutDataOnCache = true, - optionListsData = optionListsDataMock, -}: renderAppContentLibraryProps = {}) => { - const queryClientMock = createQueryClientMock(); - if (shouldPutDataOnCache) { - queryClientMock.setQueryData([QueryKey.OptionLists, org, app], optionListsData); - queryClientMock.setQueryData([QueryKey.OptionListsUsage, org, app], []); - } - renderWithProviders(queries, queryClientMock)(); + queryClient = createQueryClientMock(), +}: RenderAppContentLibraryProps = {}): void => { + renderWithProviders(queries, queryClient)(); }; + +function renderAppContentLibraryWithOptionLists( + props?: Omit, +): void { + const queryClient = createQueryClientWithOptionsDataList(optionListsDataMock); + renderAppContentLibrary({ ...props, queryClient }); +} + +function createQueryClientWithOptionsDataList( + optionListDataList: OptionListData[] | undefined, +): QueryClient { + const queryClient = createQueryClientMock(); + queryClient.setQueryData([QueryKey.OptionLists, org, app], optionListDataList); + queryClient.setQueryData([QueryKey.OptionListsUsage, org, app], []); + return queryClient; +} diff --git a/frontend/app-development/features/appContentLibrary/AppContentLibrary.tsx b/frontend/app-development/features/appContentLibrary/AppContentLibrary.tsx index c021cbc821f..d3908efc68b 100644 --- a/frontend/app-development/features/appContentLibrary/AppContentLibrary.tsx +++ b/frontend/app-development/features/appContentLibrary/AppContentLibrary.tsx @@ -1,10 +1,16 @@ -import type { CodeListReference, CodeListWithMetadata } from '@studio/content-library'; +import type { + CodeListData, + CodeListReference, + CodeListWithMetadata, +} from '@studio/content-library'; import { ResourceContentLibraryImpl } from '@studio/content-library'; -import React from 'react'; +import type { ReactElement } from 'react'; +import React, { useCallback } from 'react'; + import { useOptionListsQuery, useOptionListsReferencesQuery } from 'app-shared/hooks/queries'; import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams'; import { mapToCodeListDataList } from './utils/mapToCodeListDataList'; -import { StudioPageSpinner } from '@studio/components'; +import { StudioPageError, StudioPageSpinner } from '@studio/components'; import { useTranslation } from 'react-i18next'; import type { ApiError } from 'app-shared/types/api/ApiError'; import { toast } from 'react-toastify'; @@ -17,47 +23,62 @@ import { useDeleteOptionListMutation, } from 'app-shared/hooks/mutations'; import { mapToCodeListUsages } from './utils/mapToCodeListUsages'; +import type { OptionListData } from 'app-shared/types/OptionList'; +import type { OptionListReferences } from 'app-shared/types/OptionListReferences'; +import { mergeQueryStatuses } from 'app-shared/utils/tanstackQueryUtils'; export function AppContentLibrary(): React.ReactElement { const { org, app } = useStudioEnvironmentParams(); const { t } = useTranslation(); - const { data: optionListsData, isPending: optionListsDataPending } = useOptionListsQuery( + const { data: optionListDataList, status: optionListDataListStatus } = useOptionListsQuery( org, app, ); - const { mutate: deleteOptionList } = useDeleteOptionListMutation(org, app); - const { mutate: uploadOptionList } = useAddOptionListMutation(org, app, { - hideDefaultError: (error: AxiosError) => isErrorUnknown(error), - }); + const { data: optionListUsages, status: optionListUsagesStatus } = useOptionListsReferencesQuery( + org, + app, + ); + + const status = mergeQueryStatuses(optionListDataListStatus, optionListUsagesStatus); + + switch (status) { + case 'pending': + return ; + case 'error': + return ; + case 'success': + return ( + + ); + } +} + +type AppContentLibraryWithDataProps = { + optionListDataList: OptionListData[]; + optionListUsages: OptionListReferences; +}; + +function AppContentLibraryWithData({ + optionListDataList, + optionListUsages, +}: AppContentLibraryWithDataProps): ReactElement { + const { org, app } = useStudioEnvironmentParams(); const { mutate: updateOptionList } = useUpdateOptionListMutation(org, app); const { mutate: updateOptionListId } = useUpdateOptionListIdMutation(org, app); - const { data: optionListUsages, isPending: optionListsUsageIsPending } = - useOptionListsReferencesQuery(org, app); - - if (optionListsDataPending || optionListsUsageIsPending) - return ; + const { mutate: deleteOptionList } = useDeleteOptionListMutation(org, app); + const handleUpload = useUploadOptionList(org, app); - const codeListsData = mapToCodeListDataList(optionListsData); + const codeListDataList: CodeListData[] = mapToCodeListDataList(optionListDataList); const codeListsUsages: CodeListReference[] = mapToCodeListUsages(optionListUsages); - const handleUpdateCodeListId = (optionListId: string, newOptionListId: string) => { + const handleUpdateCodeListId = (optionListId: string, newOptionListId: string): void => { updateOptionListId({ optionListId, newOptionListId }); }; - const handleUpload = (file: File) => { - uploadOptionList(file, { - onSuccess: () => { - toast.success(t('ux_editor.modal_properties_code_list_upload_success')); - }, - onError: (error: AxiosError) => { - if (isErrorUnknown(error)) { - toast.error(t('ux_editor.modal_properties_code_list_upload_generic_error')); - } - }, - }); - }; - const handleUpdate = ({ title, codeList }: CodeListWithMetadata) => { updateOptionList({ optionListId: title, optionList: codeList }); }; @@ -66,7 +87,7 @@ export function AppContentLibrary(): React.ReactElement { pages: { codeList: { props: { - codeListsData, + codeListsData: codeListDataList, onDeleteCodeList: deleteOptionList, onUpdateCodeListId: handleUpdateCodeListId, onUpdateCodeList: handleUpdate, @@ -85,3 +106,25 @@ export function AppContentLibrary(): React.ReactElement { return
{getContentResourceLibrary()}
; } + +function useUploadOptionList(org: string, app: string): (file: File) => void { + const { mutate: uploadOptionList } = useAddOptionListMutation(org, app, { + hideDefaultError: (error: AxiosError) => isErrorUnknown(error), + }); + const { t } = useTranslation(); + + return useCallback( + (file: File) => + uploadOptionList(file, { + onSuccess: () => { + toast.success(t('ux_editor.modal_properties_code_list_upload_success')); + }, + onError: (error: AxiosError) => { + if (isErrorUnknown(error)) { + toast.error(t('ux_editor.modal_properties_code_list_upload_generic_error')); + } + }, + }), + [uploadOptionList, t], + ); +} diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index 315e14e48be..f44f3819519 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -42,6 +42,7 @@ "app_content_library.code_lists.save_new_code_list": "Lagre", "app_content_library.code_lists.search_label": "Søk på kodelister", "app_content_library.code_lists.upload_code_list": "Last opp din egen kodeliste", + "app_content_library.fetch_error": "Det har oppstått et problem ved henting av data til biblioteket.", "app_content_library.images.info_box.description": "Du kan bruke bildene i biblioteket til å legge inn bilder i skjemaet. Du kan også laste opp et bilde med organisasjonens logo, og legge det som logobilde i innstillingene for appen.", "app_content_library.images.info_box.title": "Hva kan du bruke bildene til?", "app_content_library.images.no_content": "Dette biblioteket har ingen bilder", From 70ae7599fde37c58d93ae91b1e19a457434194bc Mon Sep 17 00:00:00 2001 From: Tomas Engebretsen Date: Mon, 20 Jan 2025 09:24:14 +0100 Subject: [PATCH 2/3] Update frontend/app-development/features/appContentLibrary/AppContentLibrary.test.tsx Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> --- .../features/appContentLibrary/AppContentLibrary.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app-development/features/appContentLibrary/AppContentLibrary.test.tsx b/frontend/app-development/features/appContentLibrary/AppContentLibrary.test.tsx index 93ed5b6ae3e..dda27a7e455 100644 --- a/frontend/app-development/features/appContentLibrary/AppContentLibrary.test.tsx +++ b/frontend/app-development/features/appContentLibrary/AppContentLibrary.test.tsx @@ -70,7 +70,7 @@ describe('AppContentLibrary', () => { }); it('Renders an error message when the option lists query fails', async () => { - const getOptionLists = () => Promise.reject([]); + const getOptionLists = () => Promise.reject(new Error('Test error')); renderAppContentLibrary({ queries: { getOptionLists } }); await waitFor(expect(screen.queryByText(textMock('general.loading'))).not.toBeInTheDocument); const errorMessage = screen.getByText(textMock('app_content_library.fetch_error')); From 4c78eb29067108ea5b3a8250aeb507f97ae66f23 Mon Sep 17 00:00:00 2001 From: Tomas Date: Mon, 20 Jan 2025 09:33:37 +0100 Subject: [PATCH 3/3] Add return type --- .../features/appContentLibrary/AppContentLibrary.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/app-development/features/appContentLibrary/AppContentLibrary.tsx b/frontend/app-development/features/appContentLibrary/AppContentLibrary.tsx index d3908efc68b..4b35abe90ac 100644 --- a/frontend/app-development/features/appContentLibrary/AppContentLibrary.tsx +++ b/frontend/app-development/features/appContentLibrary/AppContentLibrary.tsx @@ -79,7 +79,7 @@ function AppContentLibraryWithData({ updateOptionListId({ optionListId, newOptionListId }); }; - const handleUpdate = ({ title, codeList }: CodeListWithMetadata) => { + const handleUpdate = ({ title, codeList }: CodeListWithMetadata): void => { updateOptionList({ optionListId: title, optionList: codeList }); };