Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix: Handle errors in app content library #14430

Merged
merged 5 commits into from
Jan 21, 2025
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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([]);
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,47 +23,62 @@
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) => {
updateOptionList({ optionListId: title, optionList: codeList });
};
Expand All @@ -66,7 +87,7 @@
pages: {
codeList: {
props: {
codeListsData,
codeListsData: codeListDataList,
onDeleteCodeList: deleteOptionList,
onUpdateCodeListId: handleUpdateCodeListId,
onUpdateCodeList: handleUpdate,
Expand All @@ -85,3 +106,25 @@

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),

Check warning on line 112 in frontend/app-development/features/appContentLibrary/AppContentLibrary.tsx

View check run for this annotation

Codecov / codecov/patch

frontend/app-development/features/appContentLibrary/AppContentLibrary.tsx#L112

Added line #L112 was not covered by tests
});
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 @@ -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",
Expand Down
Loading