Skip to content

Commit

Permalink
Merge branch 'main' into 13916-StudioDragAndDrop
Browse files Browse the repository at this point in the history
  • Loading branch information
wrt95 authored Oct 29, 2024
2 parents 4e1a6b5 + 378bf66 commit 27de4eb
Show file tree
Hide file tree
Showing 17 changed files with 356 additions and 19 deletions.
3 changes: 3 additions & 0 deletions frontend/language/src/nb.json
Original file line number Diff line number Diff line change
Expand Up @@ -1318,6 +1318,9 @@
"ux_editor.component_properties.subform.choose_layout_set_description": " Før du kan bruke komponenten Tabell for underskjema, må du velge hvilket underskjema du skal bruke den med. Deretter kan du velge hvilke egenskaper komponenten skal ha.",
"ux_editor.component_properties.subform.choose_layout_set_header": "Velg underskjemaet du vil bruke",
"ux_editor.component_properties.subform.choose_layout_set_label": "Velg et underskjema",
"ux_editor.component_properties.subform.create_layout_set_button": "Lag et nytt underskjema",
"ux_editor.component_properties.subform.create_layout_set_description": "Hvis du velger å lage et nytt underskjema, oppretter vi et tomt underskjema for deg. Det må du selv utforme, før du kan sette opp tabellen.",
"ux_editor.component_properties.subform.created_layout_set_name": "Navn på underskjema",
"ux_editor.component_properties.subform.go_to_layout_set": "Gå til utforming av underskjemaet",
"ux_editor.component_properties.subform.no_layout_sets_acting_as_subform": "Det finnes ingen sidegrupper i løsningen som kan brukes som et underskjema",
"ux_editor.component_properties.subform.selected_layout_set_label": "Underskjema",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,5 @@

.description {
margin-top: var(--fds-spacing-2);
margin-bottom: var(--fds-spacing-4);
margin-bottom: var(--fds-spacing-1);
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export type StudioRecommendedNextActionProps = {
saveButtonText?: string;
onSkip?: React.MouseEventHandler<HTMLButtonElement>;
skipButtonText?: string;
title: string;
description: string;
title?: string;
description?: string;
hideSaveButton?: boolean;
hideSkipButton?: boolean;
children: React.ReactNode;
Expand Down
3 changes: 3 additions & 0 deletions frontend/libs/studio-hooks/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,5 +22,8 @@
"jest-environment-jsdom": "^29.7.0",
"ts-jest": "^29.1.1",
"typescript": "5.6.2"
},
"peerDependencies": {
"react-router-dom": ">=6.0.0"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import { renderHook } from '@testing-library/react';
import { type UseOrgAppScopedStorage, useOrgAppScopedStorage } from './useOrgAppScopedStorage';
import { useParams } from 'react-router-dom';

jest.mock('react-router-dom', () => ({
useParams: jest.fn(),
}));

const mockedOrg: string = 'testOrg';
const mockedApp: string = 'testApp';
const scopedStorageKey: string = 'testOrg-testApp';
const storagesToTest: Array<UseOrgAppScopedStorage['storage']> = ['localStorage', 'sessionStorage'];

describe('useOrgAppScopedStorage', () => {
afterEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
});

it.each(storagesToTest)(
'initializes ScopedStorageImpl with correct storage scope, %s',
(storage) => {
const { result } = renderUseOrgAppScopedStorage({ storage });

result.current.setItem('key', 'value');

expect(result.current.setItem).toBeDefined();
expect(result.current.getItem).toBeDefined();
expect(result.current.removeItem).toBeDefined();
expect(window[storage].getItem(scopedStorageKey)).toBe('{"key":"value"}');
},
);

it.each(storagesToTest)('should retrieve parsed objects from %s', (storage) => {
const { result } = renderUseOrgAppScopedStorage({ storage });

result.current.setItem('person', { name: 'John', age: 18 });

expect(result.current.getItem('person')).toEqual({
name: 'John',
age: 18,
});
});

it.each(storagesToTest)('should be possible to remove item from %s', (storage) => {
const { result } = renderUseOrgAppScopedStorage({ storage });

result.current.setItem('key', 'value');
result.current.removeItem('key');
expect(result.current.getItem('key')).toBeUndefined();
});

it('should use localStorage as default storage', () => {
const { result } = renderUseOrgAppScopedStorage({});
result.current.setItem('key', 'value');

expect(window.localStorage.getItem(scopedStorageKey)).toBe('{"key":"value"}');
});
});

const renderUseOrgAppScopedStorage = ({ storage }: UseOrgAppScopedStorage) => {
(useParams as jest.Mock).mockReturnValue({ org: mockedOrg, app: mockedApp });
const { result } = renderHook(() =>
useOrgAppScopedStorage({
storage,
}),
);
return { result };
};
36 changes: 36 additions & 0 deletions frontend/libs/studio-hooks/src/hooks/useOrgAppScopedStorage.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { useParams } from 'react-router-dom';
import {
type ScopedStorage,
type ScopedStorageResult,
ScopedStorageImpl,
} from '@studio/pure-functions';

type OrgAppParams = {
org: string;
app: string;
};

const supportedStorageMap: Record<UseOrgAppScopedStorage['storage'], ScopedStorage> = {
localStorage: window.localStorage,
sessionStorage: window.sessionStorage,
};

export type UseOrgAppScopedStorage = {
storage?: 'localStorage' | 'sessionStorage';
};

type UseOrgAppScopedStorageResult = ScopedStorageResult;
export const useOrgAppScopedStorage = ({
storage = 'localStorage',
}: UseOrgAppScopedStorage): UseOrgAppScopedStorageResult => {
const { org, app } = useParams<OrgAppParams>();

const storageKey: string = `${org}-${app}`;
const scopedStorage = new ScopedStorageImpl(supportedStorageMap[storage], storageKey);

return {
setItem: scopedStorage.setItem,
getItem: scopedStorage.getItem,
removeItem: scopedStorage.removeItem,
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@ type StorageKey = string;

export interface ScopedStorage extends Pick<Storage, 'setItem' | 'getItem' | 'removeItem'> {}

export interface ScopedStorageResult extends ScopedStorage {
setItem: <T>(key: string, value: T) => void;
getItem: <T>(key: string) => T;
removeItem: (key: string) => void;
}

export class ScopedStorageImpl implements ScopedStorage {
private readonly storageKey: StorageKey;
private readonly scopedStorage: ScopedStorage;
Expand All @@ -12,6 +18,9 @@ export class ScopedStorageImpl implements ScopedStorage {
) {
this.storageKey = this.key;
this.scopedStorage = this.storage;
this.setItem = this.setItem.bind(this);
this.getItem = this.getItem.bind(this);
this.removeItem = this.removeItem.bind(this);
}

public setItem<T>(key: string, value: T): void {
Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export { ScopedStorageImpl, type ScopedStorage } from './ScopedStorage';
export { ScopedStorageImpl, type ScopedStorage, type ScopedStorageResult } from './ScopedStorage';
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
.savelayoutSetButton {
display: flex;
align-self: flex-start;
border: 2px solid var(--success-color);
color: var(--success-color);
}

.headerIcon {
font-size: large;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import React from 'react';
import { renderWithProviders } from '../../../../../../testing/mocks';
import { CreateNewSubformLayoutSet } from './CreateNewSubformLayoutSet';
import type { ComponentType } from 'app-shared/types/ComponentType';
import { textMock } from '@studio/testing/mocks/i18nMock';
import { screen, waitFor } from '@testing-library/react';
import { createQueryClientMock } from 'app-shared/mocks/queryClientMock';
import { app, org } from '@studio/testing/testids';
import { QueryKey } from 'app-shared/types/QueryKey';
import { layoutSets } from 'app-shared/mocks/mocks';
import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse';
import userEvent from '@testing-library/user-event';
import type { FormComponent } from '../../../../../../types/FormComponent';
import { AppContext } from '../../../../../../AppContext';
import { appContextMock } from '../../../../../../testing/appContextMock';

const onSubFormCreatedMock = jest.fn();

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

it('displays the card with label and input field', () => {
renderCreateNewSubformLayoutSet();
const card = screen.getByRole('textbox', {
name: textMock('ux_editor.component_properties.subform.created_layout_set_name'),
});

expect(card).toBeInTheDocument();
});

it('displays the input field', () => {
renderCreateNewSubformLayoutSet();
const input = screen.getByRole('textbox');
expect(input).toBeInTheDocument();
});

it('displays the save button', () => {
renderCreateNewSubformLayoutSet();
const saveButton = screen.getByRole('button', { name: textMock('general.close') });
expect(saveButton).toBeInTheDocument();
});

it('calls onSubFormCreated when save button is clicked', async () => {
const user = userEvent.setup();
renderCreateNewSubformLayoutSet();
const input = screen.getByRole('textbox');
await user.type(input, 'NewSubForm');
const saveButton = screen.getByRole('button', { name: textMock('general.close') });
await user.click(saveButton);
await waitFor(() => expect(onSubFormCreatedMock).toHaveBeenCalledTimes(1));
expect(onSubFormCreatedMock).toHaveBeenCalledWith('NewSubForm');
});
});

const renderCreateNewSubformLayoutSet = (
layoutSetsMock: LayoutSets = layoutSets,
componentProps: Partial<FormComponent<ComponentType.Subform>> = {},
) => {
const queryClient = createQueryClientMock();
queryClient.setQueryData([QueryKey.LayoutSets, org, app], layoutSetsMock);
return renderWithProviders(
<AppContext.Provider value={{ ...appContextMock }}>
<CreateNewSubformLayoutSet onSubFormCreated={onSubFormCreatedMock} {...componentProps} />
</AppContext.Provider>,
{ queryClient },
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { StudioButton, StudioCard, StudioTextfield } from '@studio/components';
import { ClipboardIcon, CheckmarkIcon } from '@studio/icons';
import { useAddLayoutSetMutation } from 'app-development/hooks/mutations/useAddLayoutSetMutation';
import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams';
import classes from './CreateNewSubformLayoutSet.module.css';

type CreateNewSubformLayoutSetProps = {
onSubFormCreated: (layoutSetName: string) => void;
};

export const CreateNewSubformLayoutSet = ({
onSubFormCreated,
}: CreateNewSubformLayoutSetProps): React.ReactElement => {
const { t } = useTranslation();
const [newSubForm, setNewSubForm] = useState('');
const { org, app } = useStudioEnvironmentParams();
const { mutate: addLayoutSet } = useAddLayoutSetMutation(org, app);

const createNewSubform = () => {
if (!newSubForm) return;
addLayoutSet({
layoutSetIdToUpdate: newSubForm,
layoutSetConfig: {
id: newSubForm,
type: 'subform',
},
});
onSubFormCreated(newSubForm);
setNewSubForm('');
};

function handleChange(e: React.ChangeEvent<HTMLInputElement>) {
setNewSubForm(e.target.value);
}

return (
<StudioCard>
<StudioCard.Content>
<StudioCard.Header>
<ClipboardIcon className={classes.headerIcon} />
</StudioCard.Header>
<StudioTextfield
label={t('ux_editor.component_properties.subform.created_layout_set_name')}
value={newSubForm}
size='sm'
onChange={handleChange}
/>
<StudioButton
className={classes.savelayoutSetButton}
icon={<CheckmarkIcon />}
onClick={createNewSubform}
title={t('general.close')}
variant='tertiary'
color='success'
/>
</StudioCard.Content>
</StudioCard>
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { CreateNewSubformLayoutSet } from './CreateNewSubformLayoutSet';
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.button {
padding-left: 0;
border-radius: var(--fds-sizing-1);
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,29 @@ import React, { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { DefinedLayoutSet } from './DefinedLayoutSet/DefinedLayoutSet';
import { SelectLayoutSet } from './SelectLayoutSet/SelectLayoutSet';
import { StudioRecommendedNextAction } from '@studio/components';
import { StudioParagraph, StudioProperty, StudioRecommendedNextAction } from '@studio/components';
import { PlusIcon } from '@studio/icons';
import classes from './EditLayoutSet.module.css';
import { CreateNewSubformLayoutSet } from './CreateNewSubformLayoutSet';

type EditLayoutSetProps = {
existingLayoutSetForSubform: string;
onUpdateLayoutSet: (layoutSetId: string) => void;
onSubFormCreated: (layoutSetName: string) => void;
};

export const EditLayoutSet = ({
existingLayoutSetForSubform,
onUpdateLayoutSet,
onSubFormCreated,
}: EditLayoutSetProps): React.ReactElement => {
const { t } = useTranslation();
const [isLayoutSetSelectorVisible, setIsLayoutSetSelectorVisible] = useState<boolean>(false);
const [showCreateSubform, setShowCreateSubform] = useState<boolean>(false);

function handleClick() {
setShowCreateSubform(true);
}

if (isLayoutSetSelectorVisible) {
return (
Expand All @@ -26,23 +36,34 @@ export const EditLayoutSet = ({
/>
);
}

const layoutSetIsUndefined = !existingLayoutSetForSubform;
if (layoutSetIsUndefined) {
return (
<StudioRecommendedNextAction
title={t('ux_editor.component_properties.subform.choose_layout_set_header')}
description={t('ux_editor.component_properties.subform.choose_layout_set_description')}
hideSaveButton={true}
hideSkipButton={true}
>
<SelectLayoutSet
existingLayoutSetForSubform={existingLayoutSetForSubform}
onUpdateLayoutSet={onUpdateLayoutSet}
onSetLayoutSetSelectorVisible={setIsLayoutSetSelectorVisible}
showButtons={false}
/>
</StudioRecommendedNextAction>
<>
<StudioRecommendedNextAction
title={t('ux_editor.component_properties.subform.choose_layout_set_header')}
description={t('ux_editor.component_properties.subform.choose_layout_set_description')}
hideSaveButton={true}
hideSkipButton={true}
>
<StudioParagraph size='sm'>
{t('ux_editor.component_properties.subform.create_layout_set_description')}
</StudioParagraph>
<SelectLayoutSet
existingLayoutSetForSubform={existingLayoutSetForSubform}
onUpdateLayoutSet={onUpdateLayoutSet}
onSetLayoutSetSelectorVisible={setIsLayoutSetSelectorVisible}
showButtons={false}
/>
<StudioProperty.Button
className={classes.button}
property={t('ux_editor.component_properties.subform.create_layout_set_button')}
icon={<PlusIcon />}
onClick={handleClick}
/>
</StudioRecommendedNextAction>
{showCreateSubform && <CreateNewSubformLayoutSet onSubFormCreated={onSubFormCreated} />}
</>
);
}

Expand Down
Loading

0 comments on commit 27de4eb

Please sign in to comment.