Skip to content

Commit

Permalink
feat: "Upload codelist" functionality in component config (#13763)
Browse files Browse the repository at this point in the history
Co-authored-by: Tomas Engebretsen <[email protected]>
Co-authored-by: Erling Hauan <[email protected]>
  • Loading branch information
3 people authored Oct 22, 2024
1 parent dacb720 commit 4333644
Show file tree
Hide file tree
Showing 20 changed files with 421 additions and 149 deletions.
27 changes: 27 additions & 0 deletions backend/src/Designer/Controllers/OptionsController.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Altinn.Studio.Designer.Helpers;
Expand Down Expand Up @@ -128,6 +129,32 @@ public async Task<ActionResult> CreateOrOverwriteOptionsList(string org, string
return Ok(newOptionsList);
}

/// <summary>
/// Create new options list.
/// </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>
/// <param name="file">File being uploaded.</param>
/// <param name="cancellationToken"><see cref="CancellationToken"/> that observes if operation is cancelled.</param>
[HttpPost]
[Route("upload")]
public async Task<IActionResult> UploadFile(string org, string repo, [FromForm] IFormFile file, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);
string fileName = file.FileName.Replace(".json", "");

try
{
List<Option> newOptionsList = await _optionsService.UploadNewOption(org, repo, developer, fileName, file, cancellationToken);
return Ok(newOptionsList);
}
catch (JsonException e)
{
return BadRequest(e.Message);
}
}

/// <summary>
/// Deletes an option list.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -767,12 +767,15 @@ public async Task<string> GetOptionsList(string optionsListId, CancellationToken
/// <param name="payload">The contents of the new options list as a string</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that observes if operation is cancelled.</param>
/// <returns>The new options list as a string.</returns>
public async Task<string> CreateOrOverwriteOptionsList(string optionsListId, string payload, CancellationToken cancellationToken = default)
public async Task<string> CreateOrOverwriteOptionsList(string optionsListId, List<Option> payload, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();

var serialiseOptions = new JsonSerializerOptions { WriteIndented = true };
string payloadString = JsonSerializer.Serialize(payload, serialiseOptions);

string optionsFilePath = Path.Combine(OptionsFolderPath, $"{optionsListId}.json");
await WriteTextByRelativePathAsync(optionsFilePath, payload, true, cancellationToken);
await WriteTextByRelativePathAsync(optionsFilePath, payloadString, true, cancellationToken);
string fileContent = await ReadTextByRelativePathAsync(optionsFilePath, cancellationToken);

return fileContent;
Expand Down
25 changes: 23 additions & 2 deletions backend/src/Designer/Services/Implementation/OptionsService.cs
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Altinn.Studio.Designer.Models;
using Altinn.Studio.Designer.Services.Interfaces;
using LibGit2Sharp;
using Microsoft.AspNetCore.Http;

namespace Altinn.Studio.Designer.Services.Implementation;

Expand Down Expand Up @@ -57,13 +59,32 @@ public async Task<List<Option>> CreateOrOverwriteOptionsList(string org, string
cancellationToken.ThrowIfCancellationRequested();
var altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(org, repo, developer);

string payloadString = JsonSerializer.Serialize(payload, new JsonSerializerOptions() { WriteIndented = true });
string updatedOptionsString = await altinnAppGitRepository.CreateOrOverwriteOptionsList(optionsListId, payloadString, cancellationToken);
string updatedOptionsString = await altinnAppGitRepository.CreateOrOverwriteOptionsList(optionsListId, payload, cancellationToken);
var updatedOptions = JsonSerializer.Deserialize<List<Option>>(updatedOptionsString);

return updatedOptions;
}

/// <inheritdoc />
public async Task<List<Option>> UploadNewOption(string org, string repo, string developer, string optionsListId, IFormFile payload, CancellationToken cancellationToken = default)
{
cancellationToken.ThrowIfCancellationRequested();

List<Option> deserializedOptions = JsonSerializer.Deserialize<List<Option>>(payload.OpenReadStream(),
new JsonSerializerOptions { WriteIndented = true, AllowTrailingCommas = true });

IEnumerable<Option> result = deserializedOptions.Where(option => string.IsNullOrEmpty(option.Value) || string.IsNullOrEmpty(option.Label));
if (result.Any())
{
throw new JsonException("Uploaded file is missing one of the following attributes for an option: value or label.");
}

var altinnAppGitRepository = _altinnGitRepositoryFactory.GetAltinnAppGitRepository(org, repo, developer);
await altinnAppGitRepository.CreateOrOverwriteOptionsList(optionsListId, deserializedOptions, cancellationToken);

return deserializedOptions;
}

/// <inheritdoc />
public void DeleteOptionsList(string org, string repo, string developer, string optionsListId)
{
Expand Down
13 changes: 13 additions & 0 deletions backend/src/Designer/Services/Interfaces/IOptionsService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
using System.Threading;
using System.Threading.Tasks;
using Altinn.Studio.Designer.Models;
using Microsoft.AspNetCore.Http;

namespace Altinn.Studio.Designer.Services.Interfaces;

Expand Down Expand Up @@ -42,6 +43,18 @@ public interface IOptionsService
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that observes if operation is cancelled.</param>
public Task<List<Option>> CreateOrOverwriteOptionsList(string org, string repo, string developer, string optionsListId, List<Option> payload, CancellationToken cancellationToken = default);

/// <summary>
/// Adds a new option to the option list.
/// If the file already exists, it will be overwritten.
/// </summary>
/// <param name="org">Orginisation</param>
/// <param name="repo">Repository</param>
/// <param name="developer">Username of developer</param>
/// <param name="optionsListId">Name of the new options list</param>
/// <param name="payload">The options list contents</param>
/// <param name="cancellationToken">A <see cref="CancellationToken"/> that observes if operation is cancelled.</param>
public Task<List<Option>> UploadNewOption(string org, string repo, string developer, string optionsListId, IFormFile payload, CancellationToken cancellationToken = default);

/// <summary>
/// Deletes an options list from the app repository.
/// </summary>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ public async Task CreateOrOverwriteOptions_WithAppThatHasNoOptionLists_ShouldCre
string newOptionsListString = JsonSerializer.Serialize(newOptionsList, jsonOptions);

// Act
string savedOptionsList = await altinnAppGitRepository.CreateOrOverwriteOptionsList(newOptionName, newOptionsListString);
string savedOptionsList = await altinnAppGitRepository.CreateOrOverwriteOptionsList(newOptionName, newOptionsList);

// Assert
Assert.Equal(newOptionsListString, savedOptionsList);
Expand Down Expand Up @@ -372,7 +372,7 @@ public async Task CreateOrOverwriteOptions_WithAppThatHasOptionLists_ShouldOverw
string newOptionsListString = JsonSerializer.Serialize(newOptionsList, jsonOptions);

// Act
string savedOptionsList = await altinnAppGitRepository.CreateOrOverwriteOptionsList(newOptionName, newOptionsListString);
string savedOptionsList = await altinnAppGitRepository.CreateOrOverwriteOptionsList(newOptionName, newOptionsList);

// Assert
Assert.Equal(newOptionsListString, savedOptionsList);
Expand Down
5 changes: 5 additions & 0 deletions frontend/language/src/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -1461,6 +1461,10 @@
"ux_editor.modal_properties_code_list_read_more": "<0 href=\"{{optionsDocs}}\" >Les mer om kodelister</0>",
"ux_editor.modal_properties_code_list_read_more_dynamic": "<0 href=\"{{optionsDocs}}\" >Les mer om dynamiske kodelister</0>",
"ux_editor.modal_properties_code_list_read_more_static": "<0 href=\"{{optionsDocs}}\" >Les mer om statiske kodelister</0>",
"ux_editor.modal_properties_code_list_upload": "Last opp din egen kodeliste",
"ux_editor.modal_properties_code_list_upload_duplicate_error": "Opplastning feilet. Du prøvde å laste opp en fil som finnes fra før.",
"ux_editor.modal_properties_code_list_upload_generic_error": "Opplastning feilet. Filen du lastet opp er ikke satt opp riktig.",
"ux_editor.modal_properties_code_list_upload_success": "Filen ble lastet opp.",
"ux_editor.modal_properties_component_change_id": "Komponent-ID",
"ux_editor.modal_properties_component_change_id_information": "Ved redigering av komponent ID vil Studio automatisk oppdatere IDen der den er brukt som referanse, men det er ikke garantert at alle eksemplarer er oppdatert.",
"ux_editor.modal_properties_component_id_not_unique_error": "Komponenten må ha en unik ID",
Expand Down Expand Up @@ -1596,6 +1600,7 @@
"ux_editor.modal_text": "Tekst",
"ux_editor.modal_text_resource_body": "Tekstinnhold",
"ux_editor.modal_text_resource_body_add": "Legg til tekst",
"ux_editor.model_properties_code_list_filename_error": "Filnavnet er ugyldig. Du kan bruke tall, understrek, punktum, bindestrek, og store/små bokstaver fra det norske alfabetet. Filnavnet må starte med en engelsk bokstav.",
"ux_editor.multi_page_warning": "Denne siden inneholder grupper med flere sider. Denne funksjonaliteten er på nåværende tidspunkt ikke støttet i Altinn Studio. Du kan se og redigere komponentene, men ikke sideinformasjonen. Hvis en komponent legges til eller flyttes i en slik gruppe, blir den automatisk plassert på samme side som komponenten over.",
"ux_editor.no_components_selected": "Velg en side for å se forhåndsvisningen",
"ux_editor.no_text": "Ingen tekst",
Expand Down
2 changes: 2 additions & 0 deletions frontend/packages/shared/src/api/mutations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ import {
altinn2DelegationsMigrationPath,
imagePath,
addImagePath,
optionListPath,
} from 'app-shared/api/paths';
import type { AddLanguagePayload } from 'app-shared/types/api/AddLanguagePayload';
import type { AddRepoParams } from 'app-shared/types/api';
Expand Down Expand Up @@ -113,6 +114,7 @@ export const updateAppPolicy = (org: string, app: string, payload: Policy) => pu
export const updateAppMetadata = (org: string, app: string, payload: ApplicationMetadata) => put(appMetadataPath(org, app), payload);
export const updateAppConfig = (org: string, app: string, payload: AppConfig) => post(serviceConfigPath(org, app), payload);
export const uploadDataModel = (org: string, app: string, form: FormData) => post<void, FormData>(dataModelsUploadPath(org, app), form, { headers: { 'Content-Type': 'multipart/form-data' } });
export const uploadOptionList = (org: string, app: string, payload: FormData) => post<void, FormData>(optionListPath(org, app), payload, { headers: { 'Content-Type': 'multipart/form-data' } });
export const upsertTextResources = (org: string, app: string, language: string, payload: ITextResourcesObjectFormat) => put<ITextResourcesObjectFormat>(textResourcesPath(org, app, language), payload);

// Resourceadm
Expand Down
1 change: 1 addition & 0 deletions frontend/packages/shared/src/api/paths.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const dataModelAddXsdFromRepoPath = (org, app, filePath) => `${basePath}/
export const ruleHandlerPath = (org, app, layoutSetName) => `${basePath}/${org}/${app}/app-development/rule-handler?${s({ layoutSetName })}`; // Get, Post
export const widgetSettingsPath = (org, app) => `${basePath}/${org}/${app}/app-development/widget-settings`; // Get
export const optionListsPath = (org, app) => `${basePath}/${org}/${app}/options/option-lists`; // Get
export const optionListPath = (org, app) => `${basePath}/${org}/${app}/options/upload/`; // Post
export const optionListIdsPath = (org, app) => `${basePath}/${org}/${app}/app-development/option-list-ids`; // Get
export const ruleConfigPath = (org, app, layoutSetName) => `${basePath}/${org}/${app}/app-development/rule-config?${s({ layoutSetName })}`; // Get, Post
export const appMetadataModelIdsPath = (org, app, onlyUnReferenced) => `${basePath}/${org}/${app}/app-development/model-ids?${s({ onlyUnReferenced })}`; // Get
Expand Down
1 change: 1 addition & 0 deletions frontend/packages/shared/src/mocks/queriesMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ export const queriesMock: ServicesContextProps = {
updateAppMetadata: jest.fn().mockImplementation(() => Promise.resolve()),
updateAppConfig: jest.fn().mockImplementation(() => Promise.resolve()),
uploadDataModel: jest.fn().mockImplementation(() => Promise.resolve<JsonSchema>({})),
uploadOptionList: jest.fn().mockImplementation(() => Promise.resolve()),
upsertTextResources: jest
.fn()
.mockImplementation(() => Promise.resolve<ITextResourcesObjectFormat>({})),
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.studioFileUploader {
padding-top: var(--fds-spacing-2);
padding-bottom: var(--fds-spacing-1);
}

.linkStaticCodeLists {
margin-bottom: 0;
padding-top: var(--fds-spacing-2);
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ describe('EditCodeList', () => {
afterEach(() => {
queryClientMock.clear();
});

it('should render the component', async () => {
await render({
queries: {
Expand All @@ -37,18 +38,6 @@ describe('EditCodeList', () => {
).toBeInTheDocument();
});

it('should render the component when optionListIds is empty', async () => {
await render({
queries: {
getOptionListIds: jest.fn().mockImplementation(() => Promise.resolve<string[]>([])),
},
});

expect(
await screen.findByText(textMock('ux_editor.modal_properties_no_options_found_message')),
).toBeInTheDocument();
});

it('should call onChange when option list changes', async () => {
const handleComponentChangeMock = jest.fn();
const user = userEvent.setup();
Expand Down Expand Up @@ -130,6 +119,91 @@ describe('EditCodeList', () => {
await screen.findByText(textMock('ux_editor.modal_properties_error_message')),
).toBeInTheDocument();
});

it('should render success toast if file upload is successful', async () => {
const user = userEvent.setup();
const file = new File(['hello'], 'hello.json', { type: 'text/json' });
await render({
queries: {
getOptionListIds: jest
.fn()
.mockImplementation(() => Promise.resolve<string[]>(optionListIdsMock)),
},
});

const btn = screen.getByRole('button', {
name: textMock('ux_editor.modal_properties_code_list_upload'),
});
await user.click(btn);

const fileInput = screen.getByLabelText(
textMock('ux_editor.modal_properties_code_list_upload'),
);

await user.upload(fileInput, file);

expect(await screen.findByRole('alert')).toHaveTextContent(
textMock('ux_editor.modal_properties_code_list_upload_success'),
);
});

it('should render error toast if file already exists', async () => {
const user = userEvent.setup();
const file = new File([optionListIdsMock[0]], optionListIdsMock[0] + '.json', {
type: 'text/json',
});
await render({
queries: {
getOptionListIds: jest
.fn()
.mockImplementation(() => Promise.resolve<string[]>(optionListIdsMock)),
},
});

const btn = screen.getByRole('button', {
name: textMock('ux_editor.modal_properties_code_list_upload'),
});
await user.click(btn);

const fileInput = screen.getByLabelText(
textMock('ux_editor.modal_properties_code_list_upload'),
);

await user.upload(fileInput, file);

expect(await screen.findByRole('alert')).toHaveTextContent(
textMock('ux_editor.modal_properties_code_list_upload_duplicate_error'),
);
});

it('should render alert on invalid file name', async () => {
const user = userEvent.setup();
const invalidFileName = '_InvalidFileName.json';
const file = new File([optionListIdsMock[0]], invalidFileName, {
type: 'text/json',
});
await render({
queries: {
getOptionListIds: jest
.fn()
.mockImplementation(() => Promise.resolve<string[]>(optionListIdsMock)),
},
});

const btn = screen.getByRole('button', {
name: textMock('ux_editor.modal_properties_code_list_upload'),
});
await user.click(btn);

const fileInput = screen.getByLabelText(
textMock('ux_editor.modal_properties_code_list_upload'),
);
await user.upload(fileInput, file);

expect(await screen.findByRole('alert')).toHaveTextContent(
textMock('ux_editor.model_properties_code_list_filename_error'),
);
});
});

const render = async ({
Expand All @@ -139,11 +213,11 @@ const render = async ({
} = {}) => {
renderWithProviders(
<EditCodeList
handleComponentChange={handleComponentChange}
component={{
...mockComponent,
...componentProps,
}}
handleComponentChange={handleComponentChange}
/>,
{
queries,
Expand Down
Loading

0 comments on commit 4333644

Please sign in to comment.