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

refactor: adapt contract when fetching all option lists to visualize error per option list #14269

Merged
Merged
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
31 changes: 26 additions & 5 deletions backend/src/Designer/Controllers/OptionsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Altinn.Studio.Designer.Exceptions.Options;
using Altinn.Studio.Designer.Helpers;
using Altinn.Studio.Designer.Models;
using Altinn.Studio.Designer.Models.Dto;
Expand Down Expand Up @@ -57,20 +58,40 @@ public ActionResult<string[]> GetOptionsListIds(string org, string repo)
/// </summary>
/// <param name="org">Unique identifier of the organisation responsible for the app.</param>
/// <param name="repo">Application identifier which is unique within an organisation.</param>
/// <returns>Dictionary of all option lists belonging to the app</returns>
/// <returns>List of <see cref="OptionListData" /> objects with all option lists belonging to the app with data
/// set if option list is valid, or hasError set if option list is invalid.</returns>
[HttpGet]
[Route("option-lists")]
public async Task<ActionResult<Dictionary<string, List<Option>>>> GetOptionLists(string org, string repo)
public async Task<ActionResult<List<OptionListData>>> GetOptionLists(string org, string repo)
{
try
{
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);
string[] optionListIds = _optionsService.GetOptionsListIds(org, repo, developer);
Dictionary<string, List<Option>> optionLists = [];
List<OptionListData> optionLists = [];
foreach (string optionListId in optionListIds)
{
List<Option> optionList = await _optionsService.GetOptionsList(org, repo, developer, optionListId);
optionLists.Add(optionListId, optionList);
try
{
List<Option> optionList = await _optionsService.GetOptionsList(org, repo, developer, optionListId);
OptionListData optionListData = new()
{
Title = optionListId,
Data = optionList,
HasError = false
};
optionLists.Add(optionListData);
}
catch (InvalidOptionsFormatException)
{
OptionListData optionListData = new()
{
Title = optionListId,
Data = null,
HasError = true
};
optionLists.Add(optionListData);
}
}
return Ok(optionLists);
}
Expand Down
15 changes: 15 additions & 0 deletions backend/src/Designer/Models/Dto/OptionListData.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
using System.Collections.Generic;
using System.Text.Json.Serialization;
using JetBrains.Annotations;

namespace Altinn.Studio.Designer.Models.Dto;

public class OptionListData
{
[JsonPropertyName("title")]
public string Title { get; set; }
[JsonPropertyName("data")]
[CanBeNull] public List<Option> Data { get; set; }
[JsonPropertyName("hasError")]
public bool? HasError { get; set; }
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text.Json;
using System.Threading.Tasks;
using Altinn.Studio.Designer.Filters;
using Altinn.Studio.Designer.Models;
using Altinn.Studio.Designer.Models.Dto;
using Designer.Tests.Controllers.ApiTests;
using FluentAssertions;
using Microsoft.AspNetCore.Http;
Expand Down Expand Up @@ -56,6 +58,29 @@ public async Task GetOptionsListIds_Returns200OK_WithEmptyOptionsListIdArray_Whe
Assert.Empty(responseList);
}

[Fact]
public async Task GetOptionLists_Returns200OK_WithOptionListsData()
{
// Arrange
const string repo = "app-with-options";
string apiUrl = $"/designer/api/ttd/{repo}/options/option-lists";
using HttpRequestMessage httpRequestMessage = new(HttpMethod.Get, apiUrl);

// Act
using HttpResponseMessage response = await HttpClient.SendAsync(httpRequestMessage);
string responseBody = await response.Content.ReadAsStringAsync();
List<OptionListData> responseList = JsonSerializer.Deserialize<List<OptionListData>>(responseBody);

// Assert
Assert.Equal(StatusCodes.Status200OK, (int)response.StatusCode);
responseList.Should().BeEquivalentTo(new List<OptionListData>
{
new () { Title = "options-with-null-fields", Data = null, HasError = true },
new () { Title = "other-options", HasError = false },
new () { Title = "test-options", HasError = false }
}, options => options.Excluding(x => x.Data));
}

[Fact]
public async Task GetSingleOptionsList_Returns200Ok_WithOptionsList()
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,16 +9,17 @@ import { app, org } from '@studio/testing/testids';
import { queriesMock } from 'app-shared/mocks/queriesMock';
import type { UserEvent } from '@testing-library/user-event';
import userEvent from '@testing-library/user-event';
import type { OptionsLists } from 'app-shared/types/api/OptionsLists';
import type { CodeList } from '@studio/components';
import type { ServicesContextProps } from 'app-shared/contexts/ServicesContext';
import type { OptionsListsResponse } from 'app-shared/types/api/OptionsLists';

const uploadCodeListButtonTextMock = 'Upload Code List';
const updateCodeListButtonTextMock = 'Update Code List';
const updateCodeListIdButtonTextMock = 'Update Code List Id';
const codeListNameMock = 'codeListNameMock';
const newCodeListNameMock = 'newCodeListNameMock';
const codeListMock: CodeList = [{ value: '', label: '' }];
const optionListsDataMock: OptionsListsResponse = [{ title: codeListNameMock, data: codeListMock }];
jest.mock(
'../../../libs/studio-content-library/src/ContentLibrary/LibraryBody/pages/CodeListPage',
() => ({
Expand Down Expand Up @@ -46,10 +47,6 @@ jest.mock(
}),
);

const optionListsMock: OptionsLists = {
list1: [{ label: 'label', value: 'value' }],
};

describe('AppContentLibrary', () => {
afterEach(jest.clearAllMocks);

Expand All @@ -66,7 +63,7 @@ describe('AppContentLibrary', () => {
});

it('renders a spinner when waiting for option lists', () => {
renderAppContentLibrary({ optionLists: {} });
renderAppContentLibrary({ optionListsData: [] });
const spinner = screen.getByText(textMock('general.loading'));
expect(spinner).toBeInTheDocument();
});
Expand Down Expand Up @@ -123,7 +120,7 @@ describe('AppContentLibrary', () => {

it('calls onUpdateOptionListId when onUpdateCodeListId is triggered', async () => {
const user = userEvent.setup();
renderAppContentLibrary(optionListsMock);
renderAppContentLibrary();
await goToLibraryPage(user, 'code_lists');
const updateCodeListIdButton = screen.getByRole('button', {
name: updateCodeListIdButtonTextMock,
Expand All @@ -149,16 +146,16 @@ const goToLibraryPage = async (user: UserEvent, libraryPage: string) => {

type renderAppContentLibraryProps = {
queries?: Partial<ServicesContextProps>;
optionLists?: OptionsLists;
optionListsData?: OptionsListsResponse;
};

const renderAppContentLibrary = ({
queries = {},
optionLists = optionListsMock,
optionListsData = optionListsDataMock,
}: renderAppContentLibraryProps = {}) => {
const queryClientMock = createQueryClientMock();
if (Object.keys(optionLists).length) {
queryClientMock.setQueryData([QueryKey.OptionLists, org, app], optionLists);
if (optionListsData.length) {
queryClientMock.setQueryData([QueryKey.OptionLists, org, app], optionListsData);
}
renderWithProviders(queries, queryClientMock)(<AppContentLibrary />);
};
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
import type { CodeListWithMetadata } from '@studio/content-library';
import { ResourceContentLibraryImpl } from '@studio/content-library';
import React from 'react';
import { useOptionListsQuery } from 'app-shared/hooks/queries/useOptionListsQuery';
import { useOptionListsQuery } from 'app-shared/hooks/queries';
import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams';
import { convertOptionListsToCodeLists } from './utils/convertOptionListsToCodeLists';
import { convertOptionsListsDataToCodeListsData } from './utils/convertOptionsListsDataToCodeListsData';
import { StudioPageSpinner } from '@studio/components';
import { useTranslation } from 'react-i18next';
import type { ApiError } from 'app-shared/types/api/ApiError';
Expand All @@ -19,21 +19,20 @@ import {
export function AppContentLibrary(): React.ReactElement {
const { org, app } = useStudioEnvironmentParams();
const { t } = useTranslation();
const {
data: optionLists,
isPending: optionListsPending,
isError: optionListsError,
} = useOptionListsQuery(org, app);
const { data: optionListsData, isPending: optionListsDataPending } = useOptionListsQuery(
org,
app,
);
const { mutate: uploadOptionList } = useAddOptionListMutation(org, app, {
hideDefaultError: (error: AxiosError<ApiError>) => isErrorUnknown(error),
});
const { mutate: updateOptionList } = useUpdateOptionListMutation(org, app);
const { mutate: updateOptionListId } = useUpdateOptionListIdMutation(org, app);

if (optionListsPending)
if (optionListsDataPending)
return <StudioPageSpinner spinnerTitle={t('general.loading')}></StudioPageSpinner>;

const codeLists = convertOptionListsToCodeLists(optionLists);
const codeListsData = convertOptionsListsDataToCodeListsData(optionListsData);

const handleUpdateCodeListId = (optionListId: string, newOptionListId: string) => {
updateOptionListId({ optionListId, newOptionListId });
Expand All @@ -60,11 +59,10 @@ export function AppContentLibrary(): React.ReactElement {
pages: {
codeList: {
props: {
codeLists: codeLists,
codeListsData,
onUpdateCodeListId: handleUpdateCodeListId,
onUpdateCodeList: handleUpdate,
onUploadCodeList: handleUpload,
fetchDataError: optionListsError,
},
},
images: {
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
import type { CodeListData } from '@studio/content-library';
import { convertOptionsListsDataToCodeListsData } from './convertOptionsListsDataToCodeListsData';
import type { OptionsListsResponse } from 'app-shared/types/api/OptionsLists';

describe('convertOptionsListsDataToCodeListsData', () => {
it('converts option lists data to code lists data correctly', () => {
const optionListId: string = 'optionListId';
const optionListsData: OptionsListsResponse = [
{
title: optionListId,
data: [
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' },
],
hasError: false,
},
];
const result: CodeListData[] = convertOptionsListsDataToCodeListsData(optionListsData);
expect(result).toEqual([
{
title: optionListId,
data: [
{ label: 'Option 1', value: '1' },
{ label: 'Option 2', value: '2' },
],
hasError: false,
},
]);
});

it('sets hasError to true in result when optionListsResponse returns an option list with error', () => {
const optionListId: string = 'optionListId';
const optionListsData: OptionsListsResponse = [
{
title: optionListId,
data: null,
hasError: true,
},
];
const result: CodeListData[] = convertOptionsListsDataToCodeListsData(optionListsData);
expect(result).toEqual([{ title: optionListId, data: null, hasError: true }]);
});

it('returns a result with empty code list data array when the input option list data is empty', () => {
const optionListsData: OptionsListsResponse = [];
const result: CodeListData[] = convertOptionsListsDataToCodeListsData(optionListsData);
expect(result).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import type { OptionsListData, OptionsListsResponse } from 'app-shared/types/api/OptionsLists';
import type { CodeListData } from '@studio/content-library';

export const convertOptionsListsDataToCodeListsData = (optionListsData: OptionsListsResponse) => {
const codeListsData = [];
optionListsData.map((optionListData) => {
const codeListData = convertOptionsListDataToCodeListData(optionListData);
codeListsData.push(codeListData);
});
return codeListsData;
};

const convertOptionsListDataToCodeListData = (optionListData: OptionsListData) => {
const codeListData: CodeListData = {
title: optionListData.title,
data: optionListData.data,
hasError: optionListData.hasError,
};
return codeListData;
};
2 changes: 1 addition & 1 deletion frontend/language/src/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
"app_content_library.code_lists.create_new_code_list_modal_title": "Lag ny kodeliste",
"app_content_library.code_lists.create_new_code_list_name": "Navn",
"app_content_library.code_lists.edit_code_list_placeholder_text": "Her kommer det redigeringsmuligheter snart",
"app_content_library.code_lists.fetch_error": "Kunne ikke hente kodelister fra appen.",
"app_content_library.code_lists.fetch_error": "Kunne ikke hente kodelisten fra appen.",
"app_content_library.code_lists.info_box.description": "En kodeliste er en liste med strukturerte data. Den inneholder definerte alternativer som alle har en unik kode. For eksempel kan du ha en kodeliste med kommunenavn i skjemaet ditt, som brukerne kan velge fra en nedtrekksmeny. Brukerne ser bare navnet, ikke koden.",
"app_content_library.code_lists.info_box.title": "Hva er en kodeliste?",
"app_content_library.code_lists.no_content": "Dette biblioteket har ingen kodelister",
Expand Down
14 changes: 9 additions & 5 deletions frontend/libs/studio-content-library/mocks/mockPagesConfig.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import type { PagesConfig } from '../src/types/PagesProps';
import type { CodeListData } from '../src';

export const codeListData: CodeListData = {
title: 'CodeList1',
data: [{ value: 'value', label: 'label' }],
hasError: false,
};
export const codeListsDataMock: CodeListData[] = [codeListData];

export const mockPagesConfig: PagesConfig = {
codeList: {
props: {
codeLists: [
{ title: 'CodeList1', codeList: [] },
{ title: 'CodeList2', codeList: [] },
],
codeListsData: codeListsDataMock,
onUpdateCodeListId: () => {},
onUpdateCodeList: () => {},
onUploadCodeList: () => {},
fetchDataError: false,
},
},
images: {
Expand Down
Loading
Loading