Skip to content

Commit

Permalink
fix: Handle errors in app content library (#14430)
Browse files Browse the repository at this point in the history
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
Co-authored-by: andreastanderen <[email protected]>
  • Loading branch information
3 people authored Jan 21, 2025
1 parent 6ec1b07 commit 3fabf1f
Show file tree
Hide file tree
Showing 3 changed files with 112 additions and 50 deletions.
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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';
Expand Down Expand Up @@ -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'),
});
Expand All @@ -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(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'));
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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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);
Expand All @@ -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,
Expand All @@ -144,21 +153,30 @@ const goToLibraryPage = async (user: UserEvent, libraryPage: string) => {
await user.click(libraryPageNavTile);
};

type renderAppContentLibraryProps = {
type RenderAppContentLibraryProps = {
queries?: Partial<ServicesContextProps>;
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)(<AppContentLibrary />);
queryClient = createQueryClientMock(),
}: RenderAppContentLibraryProps = {}): void => {
renderWithProviders(queries, queryClient)(<AppContentLibrary />);
};

function renderAppContentLibraryWithOptionLists(
props?: Omit<RenderAppContentLibraryProps, 'queryClient'>,
): 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;
}
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -17,56 +23,71 @@ 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<ApiError>) => isErrorUnknown(error),
});
const { data: optionListUsages, status: optionListUsagesStatus } = useOptionListsReferencesQuery(
org,
app,
);

const status = mergeQueryStatuses(optionListDataListStatus, optionListUsagesStatus);

switch (status) {
case 'pending':
return <StudioPageSpinner spinnerTitle={t('general.loading')} />;
case 'error':
return <StudioPageError message={t('app_content_library.fetch_error')} />;
case 'success':
return (
<AppContentLibraryWithData
optionListDataList={optionListDataList}
optionListUsages={optionListUsages}
/>
);
}
}

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 <StudioPageSpinner spinnerTitle={t('general.loading')}></StudioPageSpinner>;
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<ApiError>) => {
if (isErrorUnknown(error)) {
toast.error(t('ux_editor.modal_properties_code_list_upload_generic_error'));
}
},
});
};

const handleUpdate = ({ title, codeList }: CodeListWithMetadata) => {
const handleUpdate = ({ title, codeList }: CodeListWithMetadata): void => {
updateOptionList({ optionListId: title, optionList: codeList });
};

const { getContentResourceLibrary } = new ResourceContentLibraryImpl({
pages: {
codeList: {
props: {
codeListsData,
codeListsData: codeListDataList,
onDeleteCodeList: deleteOptionList,
onUpdateCodeListId: handleUpdateCodeListId,
onUpdateCodeList: handleUpdate,
Expand All @@ -85,3 +106,25 @@ export function AppContentLibrary(): React.ReactElement {

return <div>{getContentResourceLibrary()}</div>;
}

function useUploadOptionList(org: string, app: string): (file: File) => void {
const { mutate: uploadOptionList } = useAddOptionListMutation(org, app, {
hideDefaultError: (error: AxiosError<ApiError>) => 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<ApiError>) => {
if (isErrorUnknown(error)) {
toast.error(t('ux_editor.modal_properties_code_list_upload_generic_error'));
}
},
}),
[uploadOptionList, t],
);
}
1 change: 1 addition & 0 deletions frontend/language/src/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,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",
Expand Down

0 comments on commit 3fabf1f

Please sign in to comment.