Skip to content

Commit

Permalink
feat: add editing mode to task cards
Browse files Browse the repository at this point in the history
Allow editing of layoutset name and datamodelbinding on task/layout
cards

commit-id:4f2b585e
  • Loading branch information
Jondyr committed Mar 10, 2025
1 parent f764773 commit 090ddd3
Show file tree
Hide file tree
Showing 5 changed files with 319 additions and 11 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ describe('taskCard', () => {
render();
await user.click(screen.getByTestId(studioIconCardPopoverTrigger));
expect(screen.getByRole('button', { name: /general.delete/ })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /ux_editor.task_card.edit/ })).toBeInTheDocument();
});

it('should display datatype id', async () => {
Expand All @@ -30,6 +31,37 @@ describe('taskCard', () => {
await user.click(screen.getByTestId(studioIconCardPopoverTrigger));
expect(screen.getByRole('button', { name: /general.delete/ })).toBeInTheDocument();
});

it('should open edit mode when clicking edit button', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByTestId(studioIconCardPopoverTrigger));
await user.click(screen.getByRole('button', { name: /ux_editor.task_card.edit/ }));

expect(screen.getByRole('button', { name: /general.save/ })).toBeInTheDocument();
});

it('should exit save mode when closing', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByTestId(studioIconCardPopoverTrigger));
await user.click(screen.getByRole('button', { name: /ux_editor.task_card.edit/ }));
await user.click(screen.getByRole('button', { name: /general.close/ }));
expect(screen.queryByRole('button', { name: /general.save/ })).not.toBeInTheDocument();
});

it('should exit save mode when saving', async () => {
const user = userEvent.setup();
render();
await user.click(screen.getByTestId(studioIconCardPopoverTrigger));
await user.click(screen.getByRole('button', { name: /ux_editor.task_card.edit/ }));
await user.type(
screen.getByRole('textbox', { name: /ux_editor.component_properties.layoutSet/ }),
'test',
);
await user.click(screen.getByRole('button', { name: /general.save/ }));
expect(screen.queryByRole('button', { name: /general.save/ })).not.toBeInTheDocument();
});
});

const render = () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import React from 'react';
import React, { useState, type MouseEvent } from 'react';
import type { LayoutSetModel } from 'app-shared/types/api/dto/LayoutSetModel';
import { StudioIconCard } from '@studio/components/src/components/StudioIconCard/StudioIconCard';
import { PencilIcon } from '@studio/icons';
import { getLayoutSetTypeTranslationKey } from 'app-shared/utils/layoutSetsUtils';
import { useTranslation } from 'react-i18next';
import { StudioButton, StudioDeleteButton, StudioParagraph } from '@studio/components';
import { useLayoutSetIcon } from '../../hooks/useLayoutSetIcon';
import { useDeleteLayoutSetMutation } from 'app-development/hooks/mutations/useDeleteLayoutSetMutation';
import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams';
import { TaskCardEditing } from './TaskCardEditing';

type TaskCardProps = {
layoutSetModel: LayoutSetModel;
Expand All @@ -20,18 +22,36 @@ export const TaskCard = ({ layoutSetModel }: TaskCardProps) => {
const taskName = getLayoutSetTypeTranslationKey(layoutSetModel);
const taskIcon = useLayoutSetIcon(layoutSetModel);

const contextButtons = layoutSetModel.type === 'subform' && (
<StudioDeleteButton
variant='tertiary'
confirmMessage={t('ux_editor.delete.subform.confirm')}
onDelete={() => {
deleteLayoutSet({ layoutSetIdToUpdate: layoutSetModel.id });
}}
>
{t('general.delete')}
</StudioDeleteButton>
const [editing, setEditing] = useState(false);

const contextButtons = (
<>
<StudioButton
variant='tertiary'
onClick={(_: MouseEvent<HTMLButtonElement>) => {
setEditing(true);
}}
>
<PencilIcon /> {t('ux_editor.task_card.edit')}
</StudioButton>
{layoutSetModel.type === 'subform' && (
<StudioDeleteButton
variant='tertiary'
confirmMessage={t('ux_editor.delete.subform.confirm')}
onDelete={() => {
deleteLayoutSet({ layoutSetIdToUpdate: layoutSetModel.id });
}}
>
{t('general.delete')}
</StudioDeleteButton>
)}
</>
);

if (editing) {
return <TaskCardEditing layoutSetModel={layoutSetModel} onClose={() => setEditing(false)} />;
}

return (
<StudioIconCard
icon={taskIcon.icon}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
.card {
width: 300px;
min-height: 250px;
}

.cardContent {
padding: var(--fds-spacing-2);
}

.btnGroup {
display: flex;
gap: var(--fds-spacing-2);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
import React from 'react';
import type { LayoutSetModel } from 'app-shared/types/api/dto/LayoutSetModel';
import userEvent from '@testing-library/user-event';
import { act, screen, waitFor } from '@testing-library/react';

Check failure on line 4 in frontend/packages/ux-editor/src/components/TaskNavigation/TaskCardEditing.test.tsx

View workflow job for this annotation

GitHub Actions / Typechecking and linting

'waitFor' is declared but its value is never read.

Check notice

Code scanning / CodeQL

Unused variable, import, function or class Note

Unused import waitFor.
import { renderWithProviders } from '../../testing/mocks';
import { TaskCardEditing, type TaskCardEditingProps } from './TaskCardEditing';
import { createQueryClientMock } from 'app-shared/mocks/queryClientMock';
import { QueryKey } from 'app-shared/types/QueryKey';
import { app, org } from '@studio/testing/testids';

const updateProcessDataTypesMutation = jest.fn().mockImplementation((params, options) => {
console.log('updateProcessDataTypesMutation');
console.log(params);
console.log(options);
options.onSettled();
});
const updateLayoutSetIdMutation = jest.fn().mockImplementation((params, options) => {
console.log('updateLayoutSetIdMutation');
console.log(params);
console.log(options);
options.onSettled();
});
jest.mock('app-development/hooks/mutations/useUpdateProcessDataTypesMutation', () => ({
useUpdateProcessDataTypesMutation: () => ({ mutate: updateProcessDataTypesMutation }),
}));
jest.mock('app-development/hooks/mutations/useUpdateLayoutSetIdMutation', () => ({
useUpdateLayoutSetIdMutation: () => ({ mutate: updateLayoutSetIdMutation }),
}));

describe('taskCard', () => {
it('should render with disabled save button without changes', () => {
render();
expect(screen.getByRole('button', { name: /general.save/ })).toBeDisabled();
});

it('should call onClose when clicking close button', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
render({ onClose });
await user.click(screen.getByRole('button', { name: /general.close/ }));
expect(onClose).toHaveBeenCalledTimes(1);
});

it('should call updateLayoutSetidMutation when layout set id is changed', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
render({ onClose });
await user.clear(
screen.getByRole('textbox', { name: /ux_editor.component_properties.layoutSet/ }),
);
const newLayoutSetId = 'CoolLayoutName';
await user.type(
screen.getByRole('textbox', { name: /ux_editor.component_properties.layoutSet/ }),
newLayoutSetId,
);
await user.click(screen.getByRole('button', { name: /general.save/ }));
expect(updateLayoutSetIdMutation).toHaveBeenCalledTimes(1);
expect(updateLayoutSetIdMutation).toHaveBeenCalledWith(
{
layoutSetIdToUpdate: mockLayoutSet.id,
newLayoutSetId: newLayoutSetId,
},
expect.anything(),
);
expect(onClose).toHaveBeenCalledTimes(1);
});

it('should call updateProcessDataTypesMutation when datamodel id is changed', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
render({ onClose });
await act(async () => {
await user.selectOptions(
screen.getByRole('combobox', { name: /ux_editor.modal_properties_data_model_binding/ }),
'unuseddatamodel',
);
});
await user.click(screen.getByRole('button', { name: /general.save/ }));
expect(updateProcessDataTypesMutation).toHaveBeenCalledTimes(1);

Check failure on line 79 in frontend/packages/ux-editor/src/components/TaskNavigation/TaskCardEditing.test.tsx

View workflow job for this annotation

GitHub Actions / Testing

taskCard › should call updateProcessDataTypesMutation when datamodel id is changed

expect(jest.fn()).toHaveBeenCalledTimes(expected) Expected number of calls: 1 Received number of calls: 0 at Object.toHaveBeenCalledTimes (packages/ux-editor/src/components/TaskNavigation/TaskCardEditing.test.tsx:79:44)
expect(updateProcessDataTypesMutation).toHaveBeenCalledWith(
{
layoutSetIdToUpdate: mockLayoutSet.id,
newLayoutSetId: '',
},
expect.anything(),
);
expect(onClose).toHaveBeenCalledTimes(1);
});

it('should show validation error when inputting invalid id', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
render({ onClose });
await user.type(
screen.getByRole('textbox', { name: /ux_editor.component_properties.layoutSet/ }),
' test4! &',
);
expect(
screen.getByRole('textbox', { name: /ux_editor.component_properties.layoutSet/ }),
).toBeInvalid();
expect(screen.getByRole('button', { name: /general.save/ })).toBeDisabled();
});
});

const mockLayoutSet: LayoutSetModel = {
id: 'test',
dataType: 'datamodell123',
type: 'subform',
task: { id: null, type: null },
};

const render = (props?: Partial<TaskCardEditingProps>) => {
const queryClient = createQueryClientMock();
queryClient.setQueryData(
[QueryKey.AppMetadataModelIds, org, app, true],
['datamodell123', 'unuseddatamodel'],
);
renderWithProviders(
<TaskCardEditing layoutSetModel={mockLayoutSet} onClose={jest.fn()} {...props} />,
{ queryClient },
);
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import {
StudioButton,
StudioCard,
StudioHeading,
StudioNativeSelect,
StudioSpinner,
StudioTextfield,
} from '@studio/components';
import { useUpdateLayoutSetIdMutation } from 'app-development/hooks/mutations/useUpdateLayoutSetIdMutation';
import { useUpdateProcessDataTypesMutation } from 'app-development/hooks/mutations/useUpdateProcessDataTypesMutation';
import { useAppMetadataModelIdsQuery } from 'app-shared/hooks/queries/useAppMetadataModelIdsQuery';
import { useLayoutSetsQuery } from 'app-shared/hooks/queries/useLayoutSetsQuery';
import { useStudioEnvironmentParams } from 'app-shared/hooks/useStudioEnvironmentParams';
import { useValidateLayoutSetName } from 'app-shared/hooks/useValidateLayoutSetName';
import type { LayoutSetModel } from 'app-shared/types/api/dto/LayoutSetModel';
import React, { type ChangeEvent } from 'react';
import classes from './TaskCardEditing.module.css';
import { getLayoutSetTypeTranslationKey } from 'app-shared/utils/layoutSetsUtils';
import { useTranslation } from 'react-i18next';

export type TaskCardEditingProps = {
layoutSetModel: LayoutSetModel;
onClose: () => void;
};

export const TaskCardEditing = ({ layoutSetModel, onClose }: TaskCardEditingProps) => {
const { org, app } = useStudioEnvironmentParams();
const { t } = useTranslation();

const { mutate: updateProcessDataType, isPending: updateProcessDataTypePending } =
useUpdateProcessDataTypesMutation(org, app);
const { mutate: mutateLayoutSetId, isPending: mutateLayoutSetIdPending } =
useUpdateLayoutSetIdMutation(org, app);
const { validateLayoutSetName } = useValidateLayoutSetName();
const { data: dataModels } = useAppMetadataModelIdsQuery(org, app, true);
const { data: layoutSets } = useLayoutSetsQuery(org, app);

const taskName = getLayoutSetTypeTranslationKey(layoutSetModel);
const [id, setId] = React.useState(layoutSetModel.id);
const [dataType, setDataType] = React.useState(layoutSetModel.dataType);

const idChanged = id !== layoutSetModel.id;
const dataTypeChanged = dataType !== layoutSetModel.dataType;
const fieldChanged = idChanged || dataTypeChanged;

const idValidationError = validateLayoutSetName(id, layoutSets, layoutSetModel.id);
const pendingMutation = updateProcessDataTypePending || mutateLayoutSetIdPending;
const disableSaveButton = !fieldChanged || Boolean(idValidationError) || pendingMutation;

const onSettled = () => {
if (!pendingMutation) onClose();
};

const saveChanges = () => {
if (idChanged) {
mutateLayoutSetId(
{ layoutSetIdToUpdate: layoutSetModel.id, newLayoutSetId: id },
{ onSettled },
);
}
if (dataTypeChanged) {
// TODO: implement subform datamodel change, maybe a common mutation
updateProcessDataType(
{
newDataTypes: [dataType],
connectedTaskId: layoutSetModel.task?.id,
},
{ onSettled },
);
}
};

return (
<StudioCard className={classes.card}>
<StudioCard.Header>
<StudioHeading size='xs'>{t(taskName)}</StudioHeading>
</StudioCard.Header>
<StudioCard.Content className={classes.cardContent}>
<StudioTextfield
label={t('ux_editor.component_properties.layoutSet')}
value={id}
error={idValidationError}
onKeyUp={(event) => {
if (event.key === 'Enter' && !disableSaveButton) saveChanges();
}}
onChange={(event: ChangeEvent<HTMLInputElement>) => setId(event.target.value)}
></StudioTextfield>
<StudioNativeSelect
label={t('ux_editor.modal_properties_data_model_binding')}
size='sm'
disabled={layoutSetModel.type === 'subform'}
value={dataType}
onChange={(event) => setDataType(event.target.value)}
>
{/* TODO: filter or validate this */}
<option value=''>Ingen datamodell</option>
{layoutSetModel.dataType && (
<option value={layoutSetModel.dataType}>{layoutSetModel.dataType}</option>
)}
{dataModels?.map((dataModel) => (
<option key={dataModel} value={dataModel}>
{dataModel}
</option>
))}
</StudioNativeSelect>
<div className={classes.btnGroup}>
<StudioButton
disabled={disableSaveButton}
onClick={() => saveChanges()}
variant='primary'
>
{pendingMutation ? <StudioSpinner size='xs' spinnerTitle='' /> : t('general.save')}
</StudioButton>
<StudioButton disabled={pendingMutation} onClick={() => onClose()} variant='secondary'>
{t('general.close')}
</StudioButton>
</div>
</StudioCard.Content>
</StudioCard>
);
};

0 comments on commit 090ddd3

Please sign in to comment.