Skip to content

Commit

Permalink
feat(ui-editor): export form definition (#13390)
Browse files Browse the repository at this point in the history
  • Loading branch information
nkylstad authored Sep 4, 2024
1 parent b798641 commit 1bb09ba
Show file tree
Hide file tree
Showing 28 changed files with 860 additions and 5 deletions.
28 changes: 28 additions & 0 deletions backend/src/Designer/Controllers/OptionsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,34 @@ public ActionResult<string[]> GetOptionsListIds(string org, string repo)
return Ok(optionsListIds);
}

/// <summary>
/// Fetches the contents of all the options lists belonging to the app.
/// </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>
[HttpGet]
[Route("option-lists")]
public async Task<ActionResult<Dictionary<string, List<Option>>>> GetOptionLists(string org, string repo)
{
try
{
string developer = AuthenticationHelper.GetDeveloperUserName(HttpContext);
string[] optionListIds = _optionsService.GetOptionsListIds(org, repo, developer);
Dictionary<string, List<Option>> optionLists = [];
foreach (string optionListId in optionListIds)
{
List<Option> optionList = await _optionsService.GetOptionsList(org, repo, developer, optionListId);
optionLists.Add(optionListId, optionList);
}
return Ok(optionLists);
}
catch (NotFoundException)
{
return NoContent();
}
}

/// <summary>
/// Fetches a specific option list.
/// </summary>
Expand Down
3 changes: 2 additions & 1 deletion frontend/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,4 +14,5 @@ language/old/*
script.js
/testing/playwright/test-results

*storybook.log
*storybook.log
/libs/studio-components/storybook-static/*
1 change: 1 addition & 0 deletions frontend/language/src/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -1566,6 +1566,7 @@
"ux_editor.text_resource_bindings.delete_confirm": "Ja, fjern teksten",
"ux_editor.text_resource_bindings.delete_confirm_question": "Er du sikker på at du vil fjerne denne teksten fra komponenten?",
"ux_editor.text_resource_bindings.delete_info": "Merk at selve tekstressursen ikke slettes, kun knytningen fra denne komponenten.",
"ux_editor.top_bar.export_form": "Eksporter skjema",
"ux_editor.unknown_group_reference": "Referansen med ID {{id}} er ugyldig, da det ikke eksisterer noen komponent med denne ID-en. Vennligst slett denne referansen for å rette feilen.",
"ux_editor.unknown_group_reference_help_text_title": "Ukjent referanse",
"ux_editor.unsupported_version_message.too_new_1": "Denne siden er foreløpig ikke tilgjengelig for apper som kjører på {{version}} av app-frontend. Dette er fordi det er en del konfigurasjon i skjemaene som endrer seg fra {{closestSupportedVersion}} til {{version}}.",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { Canvas, Meta } from '@storybook/blocks';
import { Heading, Paragraph } from '@digdir/designsystemet-react';
import * as StudioBlobDownloaderStories from './StudioBlobDownloader.stories';

<Meta of={StudioBlobDownloaderStories} />

<Heading level={1} size='small'>
StudioBlobDownloader
</Heading>
<Paragraph>
StudioBlowDownloader is a link that triggers the download of the specified data in the specified
file format.
</Paragraph>

<Canvas of={StudioBlobDownloaderStories.Preview} />
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import React from 'react';
import type { Meta, StoryFn } from '@storybook/react';
import { StudioBlobDownloader } from './StudioBlobDownloader';

type Story = StoryFn<typeof StudioBlobDownloader>;

const meta: Meta = {
title: 'Studio/StudioBlobDownloader',
component: StudioBlobDownloader,
};
export default meta;

export const Preview: Story = (args): React.ReactElement => <StudioBlobDownloader {...args} />;
Preview.args = {
data: JSON.stringify({ test: 'test' }),
fileName: 'testtest.json',
fileType: 'application/json',
linkText: 'Download JSON',
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import React from 'react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { StudioBlobDownloader } from './StudioBlobDownloader';
import type { StudioBlobDownloaderProps } from './StudioBlobDownloader';
import { BlobDownloader } from '@studio/pure-functions';

describe('StudioBlobDownloader', () => {
type ExampleData = {
testField1: string;
testField2: number;
};

const mockData: ExampleData = {
testField1: 'test',
testField2: 1,
};

const handleDownloadClickMock = jest.fn();

beforeAll(() => {
jest
.spyOn(BlobDownloader.prototype, 'handleDownloadClick')
.mockImplementation(handleDownloadClickMock);
});

afterAll(() => {
jest.clearAllMocks();
});

it('should render', () => {
renderStudioBlobDownloader({ data: JSON.stringify(mockData) });
expect(screen.getByRole('button', { name: 'Download' })).toBeInTheDocument();
});

it('should create a link to Blob with the correct data', async () => {
jest.spyOn(console, 'error').mockImplementationOnce(() => {}); // Suppress expected jsdom error message from attempted download/navigate
renderStudioBlobDownloader({ data: JSON.stringify(mockData) });
const user = userEvent.setup();
const downloadButton = screen.getByRole('button', { name: 'Download' });
await user.click(downloadButton);
expect(handleDownloadClickMock).toHaveBeenCalled();
});
});

const renderStudioBlobDownloader = (props: Partial<StudioBlobDownloaderProps>) => {
const defaultProps: StudioBlobDownloaderProps = {
data: '{}',
fileName: 'test.json',
linkText: 'Download',
};
render(<StudioBlobDownloader {...defaultProps} {...props} />);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import React, { useMemo } from 'react';
import { BlobDownloader } from '@studio/pure-functions';
import { StudioButton } from '../StudioButton';
import type { StudioButtonProps } from '../StudioButton';

export type StudioBlobDownloaderProps = {
data: string;
fileName: string;
fileType?: string;
linkText: string;
} & StudioButtonProps;

export const StudioBlobDownloader = ({
data,
fileName,
fileType = 'application/json',
linkText,
...rest
}: StudioBlobDownloaderProps) => {
const blobDownloader = useMemo(
() => new BlobDownloader(data, fileType, fileName),
[data, fileType, fileName],
);
const handleExportClick = () => {
blobDownloader.handleDownloadClick();
};

return (
<StudioButton {...rest} onClick={handleExportClick} variant='tertiary'>
{linkText}
</StudioButton>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export { StudioBlobDownloader } from './StudioBlobDownloader';
export type { StudioBlobDownloaderProps } from './StudioBlobDownloader';
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { type ChangeEvent } from 'react';
import { StudioNativeSelect } from '@studio/components';
import { StudioNativeSelect } from '../../../../../../StudioNativeSelect';
import type { Props } from './Props';
import { GatewayActionContext } from '../../../../../enums/GatewayActionContext';
import { useStudioExpressionContext } from '../../../../../StudioExpressionContext';
Expand Down
1 change: 1 addition & 0 deletions frontend/libs/studio-components/src/components/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './StudioAnimateHeight';
export * from './StudioBlobDownloader';
export * from './StudioBooleanToggleGroup';
export * from './StudioButton';
export * from './StudioCard';
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { BlobDownloader } from './BlobDownloader';

describe('BlobDownloader', () => {
const data = { test: 'test' };

beforeEach(() => {
global.URL.createObjectURL = jest.fn();
global.URL.revokeObjectURL = jest.fn();
});

afterEach(() => {
jest.clearAllMocks();
});

it('should generate a download url', () => {
const blobDownloader = new BlobDownloader(JSON.stringify(data));
blobDownloader.getDownloadURL();
const testBlob = new Blob([JSON.stringify(data)], { type: 'application/json' });
expect(global.URL.createObjectURL).toHaveBeenCalledWith(testBlob);
});

it('should revoke a download url', () => {
const blobDownloader = new BlobDownloader(JSON.stringify(data));
const downloadUrl = blobDownloader.getDownloadURL();
blobDownloader.revokeDownloadURL(downloadUrl);
expect(global.URL.revokeObjectURL).toHaveBeenCalledWith(downloadUrl);
});

it('should generate a download url with a custom file type', () => {
const fileType = 'application/pdf';
const blobDownloaderWithFileType = new BlobDownloader(JSON.stringify(data), fileType);
blobDownloaderWithFileType.getDownloadURL();
const testBlob = new Blob([JSON.stringify(data)], { type: fileType });
expect(global.URL.createObjectURL).toHaveBeenCalledWith(testBlob);
});

it('should handle clicking the download link', () => {
const blobDownloader = new BlobDownloader(JSON.stringify(data));
const mockGetDownloadURL = jest.fn();
const mockGetRevokeDownloadURL = jest.fn();
jest.spyOn(blobDownloader, 'getDownloadURL').mockImplementation(mockGetDownloadURL);
jest.spyOn(blobDownloader, 'revokeDownloadURL').mockImplementation(mockGetRevokeDownloadURL);
blobDownloader.handleDownloadClick();
expect(mockGetDownloadURL).toHaveBeenCalled();
expect(mockGetRevokeDownloadURL).toHaveBeenCalled();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
export class BlobDownloader {
private readonly data: string;
private readonly fileType: string;
private readonly fileName: string;

constructor(data: string, fileType = 'application/json', fileName = 'data.json') {
this.data = data;
this.fileType = fileType;
this.fileName = fileName;
}

public getDownloadURL(): string {
const blob = this.generateBlobToDownlaod();
return this.generateDownloadUrl(blob);
}

public revokeDownloadURL(url: string): void {
return URL.revokeObjectURL(url);
}

public handleDownloadClick(): void {
const link = document.createElement('a');
link.href = this.getDownloadURL();
link.download = this.fileName;
link.click();
this.revokeDownloadURL(link.href);
}

private generateBlobToDownlaod(): Blob {
return new Blob([this.data], { type: this.fileType });
}

private generateDownloadUrl(blob: Blob): string {
return URL.createObjectURL(blob);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { BlobDownloader } from './BlobDownloader';
1 change: 1 addition & 0 deletions frontend/libs/studio-pure-functions/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './ArrayUtils';
export * from './BlobDownloader';
export * from './DateUtils';
export * from './NumberUtils';
export * from './ObjectUtils';
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 @@ -28,6 +28,7 @@ export const dataModelAddXsdFromRepoPath = (org, app, filePath) => `${basePath}/
// FormEditor
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 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
2 changes: 2 additions & 0 deletions frontend/packages/shared/src/api/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
layoutSetsPath,
layoutSettingsPath,
optionListIdsPath,
optionListsPath,
orgsListPath,
accessListsPath,
accessListPath,
Expand Down Expand Up @@ -99,6 +100,7 @@ export const getInstanceIdForPreview = (owner: string, app: string) => get<strin
export const getLayoutNames = (owner: string, app: string) => get<string[]>(layoutNamesPath(owner, app));
export const getLayoutSets = (owner: string, app: string) => get<LayoutSets>(layoutSetsPath(owner, app));
export const getNewsList = (language: 'nb' | 'en') => get<NewsList>(newsListUrl(language));
export const getOptionLists = (owner: string, app: string) => get<string[]>(optionListsPath(owner, app));
export const getOptionListIds = (owner: string, app: string) => get<string[]>(optionListIdsPath(owner, app));
export const getOrgList = () => get<OrgList>(orgListUrl());
export const getOrganizations = () => get<Organization[]>(orgsListPath());
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 @@ -103,6 +103,7 @@ export const queriesMock: ServicesContextProps = {
getLayoutSets: jest.fn().mockImplementation(() => Promise.resolve<LayoutSets>(layoutSets)),
getNewsList: jest.fn().mockImplementation(() => Promise.resolve<NewsList>(newsList)),
getOptionListIds: jest.fn().mockImplementation(() => Promise.resolve<string[]>([])),
getOptionLists: jest.fn().mockImplementation(() => Promise.resolve<string[]>([])),
getOrgList: jest.fn().mockImplementation(() => Promise.resolve<OrgList>(orgList)),
getOrganizations: jest.fn().mockImplementation(() => Promise.resolve<Organization[]>([])),
getRepoMetadata: jest.fn().mockImplementation(() => Promise.resolve<Repository>(repository)),
Expand Down
1 change: 1 addition & 0 deletions frontend/packages/shared/src/types/QueryKey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export enum QueryKey {
LayoutSchema = 'LayoutSchema',
LayoutSets = 'LayoutSets',
NewsList = 'NewsList',
OptionLists = 'OptionLists',
OptionListIds = 'OptionListIds',
OrgList = 'OrgList',
Organizations = 'Organizations',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ export interface ExternalData {
[key: string]: any;
}

type ExternalComponentBase<T extends ComponentType = ComponentType> = {
export type ExternalComponentBase<T extends ComponentType = ComponentType> = {
id: string;
type: T;
dataModelBindings?: KeyValuePairs<string>;
Expand Down
3 changes: 2 additions & 1 deletion frontend/packages/shared/src/utils/featureToggleUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ export type SupportedFeatureFlags =
| 'componentConfigBeta'
| 'shouldOverrideAppLibCheck'
| 'resourceMigration'
| 'multipleDataModelsPerTask';
| 'multipleDataModelsPerTask'
| 'exportForm';

/*
* Please add all the features that you want to be toggle on by default here.
Expand Down
Loading

0 comments on commit 1bb09ba

Please sign in to comment.