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

feat: "Upload codelist" functionality in component config #13763

Open
wants to merge 43 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
4b20b88
Fix link in codelist config.
Konrad-Simso Oct 8, 2024
89a22f2
Upload button added.
Konrad-Simso Oct 10, 2024
f627d58
Edit language and fix typing.
Konrad-Simso Oct 10, 2024
ce088e4
Fix linting, rename variable and allow trailing commas, it gets fixed…
Konrad-Simso Oct 10, 2024
9cc37a3
Rename useValidateFileName.ts to validateFileNameUtils.ts
Konrad-Simso Oct 10, 2024
9a23d27
Add test for useAddOptionMutation.ts
Konrad-Simso Oct 10, 2024
e199eaf
Add tests for EditCodeList.tsx and remove a toast as it's not correct…
Konrad-Simso Oct 10, 2024
73e8b3b
Edit tests and remove unused imports
Konrad-Simso Oct 10, 2024
627433d
Rename hook to use react-hook rules.
Konrad-Simso Oct 10, 2024
1b5bb7a
Edit filename again
Konrad-Simso Oct 10, 2024
da0bb76
Render hook correctly in test file.
Konrad-Simso Oct 10, 2024
d9542a1
Fix whitespaces in backend for linting.
Konrad-Simso Oct 10, 2024
ac92608
Fixing spacing
Konrad-Simso Oct 10, 2024
f230629
Move file out of Utils folder, since it's a React hook and not a util…
Konrad-Simso Oct 10, 2024
efd12b8
Endringer fra review.
Konrad-Simso Oct 11, 2024
541efa4
Refactor useValidateFileName into smaller utils functions and move re…
Konrad-Simso Oct 11, 2024
1fb11bf
- Rename function in validateFileNameUtils.ts
Konrad-Simso Oct 11, 2024
bbc4c79
- Add check for when FormField should be visible.
Konrad-Simso Oct 11, 2024
b24128c
Remove unused import.
Konrad-Simso Oct 11, 2024
6819cfa
- Less generic catch.
Konrad-Simso Oct 11, 2024
3308902
Remove unused imports again
Konrad-Simso Oct 11, 2024
735a0a1
Edit styling
Konrad-Simso Oct 14, 2024
1af9611
Merge branch 'main' into feature/codelist-configuration
Konrad-Simso Oct 14, 2024
155196c
Remove a test and fix 2 tests.
Konrad-Simso Oct 14, 2024
2395cec
Edit test and replace forEach with for of loops for iterator object.
Konrad-Simso Oct 15, 2024
ae5ccbf
Remove unused parameter, extract filename from File
Konrad-Simso Oct 15, 2024
06087e3
Changes from 2nd review:
Konrad-Simso Oct 15, 2024
8640803
fixed typing of props, and changed an error message.
Konrad-Simso Oct 16, 2024
fd06550
Simplify file uploader component
TomasEng Oct 16, 2024
9a603ca
Update file name
TomasEng Oct 16, 2024
2b5218a
Update reference to updated file name
TomasEng Oct 16, 2024
6f2b3a0
Edit import statement
Konrad-Simso Oct 16, 2024
1f7ae0a
Updated `FileUploaderWithValidation.tsx`
Konrad-Simso Oct 16, 2024
9182ce8
Merge branch 'simplify-file-uploader' into feature/codelist-configura…
Konrad-Simso Oct 16, 2024
55609b0
Refactoring
Konrad-Simso Oct 16, 2024
a594d40
Update tests
Konrad-Simso Oct 16, 2024
7ae920d
Merge branch 'main' into feature/codelist-configuration
TomasEng Oct 17, 2024
1a05b9e
Updates from 3rd review
Konrad-Simso Oct 17, 2024
388d011
Update nb.json and edit texts for tests.
Konrad-Simso Oct 17, 2024
34cbb40
Update test
Konrad-Simso Oct 17, 2024
c0fa350
Updates from testing:
Konrad-Simso Oct 22, 2024
4aa4506
Merge branch 'main' into feature/codelist-configuration
ErlingHauan Oct 22, 2024
a853e5f
Fix type error
ErlingHauan Oct 22, 2024
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
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
Loading