diff --git a/frontend/app-development/layout/PageLayout.tsx b/frontend/app-development/layout/PageLayout.tsx index 54ba8aeecff..5a0ed07fbfb 100644 --- a/frontend/app-development/layout/PageLayout.tsx +++ b/frontend/app-development/layout/PageLayout.tsx @@ -22,7 +22,7 @@ export const PageLayout = (): React.ReactNode => { const { data: orgs, isPending: orgsPending } = useOrgListQuery(); const { data: repository } = useRepoMetadataQuery(org, app); - const repoOwnerIsOrg = !orgsPending && Object.keys(orgs).includes(repository?.owner.login); + const repoOwnerIsOrg = !orgsPending && Object.keys(orgs).includes(repository?.owner?.login); const { data: repoStatus, diff --git a/frontend/language/src/nb.json b/frontend/language/src/nb.json index dcb0aeb653f..b4b02f520ec 100644 --- a/frontend/language/src/nb.json +++ b/frontend/language/src/nb.json @@ -342,6 +342,7 @@ "general.choose_label": "Velg navn", "general.choose_method": "Velg metode", "general.close": "Lukk", + "general.close_item": "Lukk {{item}}", "general.close_schema": "Lukk skjema", "general.contact": "Kontakt og hjelp", "general.contains": "Inneholder", @@ -770,14 +771,16 @@ "process_editor.configuration_panel.edit_policy_alert_message": "Du må ha tilgangsregler som dekker alle oppgaver. Gå til Tilganger for å sjekke om du har en regel som dekker denne oppgaven. Hvis du ikke har en regel for oppgaven, kan du enten lage en ny regel eller inkludere denne oppgaven i en regel som allerede finnes.", "process_editor.configuration_panel.edit_policy_open_policy_editor_button": "Gå til Tilganger", "process_editor.configuration_panel.edit_policy_open_policy_editor_heading": "Åpne Tilganger for å redigere tilgangsregler", + "process_editor.configuration_panel_actions_action_card_custom": "Lag egendefinert handling", + "process_editor.configuration_panel_actions_action_card_custom_label": "Skriv inn navnet på handlingen du vil lage", + "process_editor.configuration_panel_actions_action_card_title": "Handling {{actionIndex}}", "process_editor.configuration_panel_actions_action_label": "Handling {{actionIndex}}: {{actionName}}", + "process_editor.configuration_panel_actions_action_selector_label": "Velg en handling fra listen", + "process_editor.configuration_panel_actions_action_tab_predefined": "Velg standard handling", "process_editor.configuration_panel_actions_action_type_help_text": "Hjelpetekst for valg av handlingstype", "process_editor.configuration_panel_actions_add_new": "Legg til ny handling", - "process_editor.configuration_panel_actions_combobox_description": "Velg en predefinert handling eller definer din egen ved å skrive inn navnet som fritekst i feltet", - "process_editor.configuration_panel_actions_custom_action": "Skriv en egendefinert handling", - "process_editor.configuration_panel_actions_delete_action": "Slett {{actionName}}-handlingen", - "process_editor.configuration_panel_actions_set_server_action_info": "Handlingen skal utføres uten å endre status på prosessen. Dette alternativet er kun tilgjengelig for egendefinerte handlinger.", - "process_editor.configuration_panel_actions_set_server_action_label": "Handlingen skal ikke påvirke prosessen", + "process_editor.configuration_panel_actions_set_server_action_info": "Angir at handlingen knyttes til neste steg i prosessen.", + "process_editor.configuration_panel_actions_set_server_action_label": "Knytt handlingen til neste steg.", "process_editor.configuration_panel_actions_title": "Handlinger", "process_editor.configuration_panel_change_task_id": "Endre id", "process_editor.configuration_panel_confirmation_task": "Oppgave: Bekreftelse", diff --git a/frontend/libs/studio-components/src/components/StudioCard/index.tsx b/frontend/libs/studio-components/src/components/StudioCard/index.tsx new file mode 100644 index 00000000000..6aa1bf5d835 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioCard/index.tsx @@ -0,0 +1 @@ +export { Card as StudioCard, type CardProps as StudioCardProps } from '@digdir/design-system-react'; diff --git a/frontend/libs/studio-components/src/components/StudioDivider/index.tsx b/frontend/libs/studio-components/src/components/StudioDivider/index.tsx new file mode 100644 index 00000000000..721a3011f66 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioDivider/index.tsx @@ -0,0 +1,4 @@ +export { + Divider as StudioDivider, + type DividerProps as StudioDividerProps, +} from '@digdir/design-system-react'; diff --git a/frontend/libs/studio-components/src/components/StudioParagraph/index.tsx b/frontend/libs/studio-components/src/components/StudioParagraph/index.tsx new file mode 100644 index 00000000000..48953eda1ee --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioParagraph/index.tsx @@ -0,0 +1 @@ +export { Paragraph as StudioParagraph } from '@digdir/design-system-react'; diff --git a/frontend/libs/studio-components/src/components/StudioTabs/index.ts b/frontend/libs/studio-components/src/components/StudioTabs/index.ts new file mode 100644 index 00000000000..87efd9fd4a8 --- /dev/null +++ b/frontend/libs/studio-components/src/components/StudioTabs/index.ts @@ -0,0 +1 @@ +export { Tabs as StudioTabs, type TabsProps as StudioTabsProps } from '@digdir/design-system-react'; diff --git a/frontend/libs/studio-components/src/components/index.ts b/frontend/libs/studio-components/src/components/index.ts index 833e68168f1..c663c262301 100644 --- a/frontend/libs/studio-components/src/components/index.ts +++ b/frontend/libs/studio-components/src/components/index.ts @@ -3,6 +3,7 @@ export * from './StudioBooleanToggleGroup'; export * from './StudioButton'; export * from './StudioCenter'; export * from './StudioCodeFragment'; +export * from './StudioCard'; export * from './StudioDecimalInput'; export * from './StudioDeleteButton'; export * from './StudioDropdownMenu'; @@ -15,6 +16,7 @@ export * from './StudioLabelWrapper'; export * from './StudioModal'; export * from './StudioNativeSelect'; export * from './StudioNotFoundPage'; +export * from './StudioParagraph'; export * from './StudioPageSpinner'; export * from './StudioPopover'; export * from './StudioProperty'; @@ -27,3 +29,5 @@ export * from './StudioTextfield'; export * from './StudioToggleableTextfield'; export * from './StudioToggleableTextfieldSchema'; export * from './StudioTreeView'; +export * from './StudioTabs'; +export * from './StudioDivider'; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/ActionsEditor.module.css b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/ActionsEditor.module.css new file mode 100644 index 00000000000..e51b586278b --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/ActionsEditor.module.css @@ -0,0 +1,31 @@ +/* Needs important to be able to override designsystem. Can be removed after we upgrade to latest version */ + +.cardHeader { + padding: var(--fds-spacing-2) !important; +} + +.cardFooter { + padding: 0 0 var(--fds-spacing-3) var(--fds-spacing-3) !important; + gap: var(--fds-spacing-3); +} + +.cardDivider { + margin: 0 !important; +} + +.cardContent { + padding-left: var(--fds-spacing-3) !important; + padding-right: var(--fds-spacing-3) !important; +} + +.tabsContainer { + border: 0; +} + +.tabsContent { + padding: var(--fds-spacing-2) 0 !important; +} + +.actionView { + margin-bottom: var(--fds-spacing-3); +} diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/ActionsEditor.test.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/ActionsEditor.test.tsx new file mode 100644 index 00000000000..9df84e9119e --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/ActionsEditor.test.tsx @@ -0,0 +1,181 @@ +import React from 'react'; +import userEvent from '@testing-library/user-event'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { render, screen, waitFor } from '@testing-library/react'; +import { BpmnContext } from '../../../../../contexts/BpmnContext'; +import { ActionsEditor, type ActionsEditorProps } from './ActionsEditor'; +import { mockBpmnContextValue } from '../../../../../../test/mocks/bpmnContextMock'; +import { BpmnActionModeler, type Action } from '../../../../../utils/bpmn/BpmnActionModeler'; +import { BpmnConfigPanelFormContextProvider } from '../../../../../contexts/BpmnConfigPanelContext'; + +jest.mock('../../../../../utils/bpmn/BpmnActionModeler'); + +const actionElementMock: Action = { + $type: 'altinn:Action', + action: 'reject', +}; + +describe('ActionsEditor', () => { + it('should display action in view mode by default', () => { + renderActionsEditor(); + const actionButton = screen.getByRole('button', { + name: textMock('process_editor.configuration_panel_actions_action_label', { + actionIndex: 1, + actionName: 'reject', + }), + }); + expect(actionButton).toBeInTheDocument(); + }); + + it('should display edit mode when mode is set to edit', () => { + renderActionsEditor({ mode: 'edit' }); + const actionSelector = screen.getByLabelText( + textMock('process_editor.configuration_panel_actions_action_selector_label'), + ); + expect(actionSelector).toBeInTheDocument(); + }); + + it('should display view mode when mode is set to view', () => { + renderActionsEditor({ mode: 'view' }); + const actionButton = screen.getByRole('button', { + name: textMock('process_editor.configuration_panel_actions_action_label', { + actionIndex: 1, + actionName: 'reject', + }), + }); + expect(actionButton).toBeInTheDocument(); + }); + + it('should change to edit mode when clicking on action button', async () => { + const user = userEvent.setup(); + renderActionsEditor(); + const actionButton = screen.getByRole('button', { + name: textMock('process_editor.configuration_panel_actions_action_label', { + actionIndex: 1, + actionName: 'reject', + }), + }); + + await user.click(actionButton); + const actionSelector = screen.getByLabelText( + textMock('process_editor.configuration_panel_actions_action_selector_label'), + ); + expect(actionSelector).toBeInTheDocument(); + }); + + it('should be possible to toggle to view mode from edit mode by clicking close button', async () => { + const user = userEvent.setup(); + renderActionsEditor({ mode: 'edit' }); + + const cancelButton = screen.getByRole('button', { + name: textMock('general.close_item', { + item: 'reject', + }), + }); + await user.click(cancelButton); + + const actionButton = screen.getByRole('button', { + name: textMock('process_editor.configuration_panel_actions_action_label', { + actionIndex: 1, + actionName: 'reject', + }), + }); + expect(actionButton).toBeInTheDocument(); + }); + + it('should be possible to delete action from task', async () => { + const user = userEvent.setup(); + const deleteActionFromTaskMock = jest.fn(); + + (BpmnActionModeler as jest.Mock).mockImplementation(() => ({ + deleteActionFromTask: deleteActionFromTaskMock, + })); + + renderActionsEditor({ mode: 'edit' }); + + const deleteButton = screen.getByRole('button', { + name: textMock('general.delete_item', { + item: 'reject', + }), + }); + await user.click(deleteButton); + + await waitFor(() => + expect(deleteActionFromTaskMock).toHaveBeenCalledWith( + expect.objectContaining({ + $type: 'altinn:Action', + action: 'reject', + }), + ), + ); + + const actionButton = screen.queryByRole('button', { + name: textMock('process_editor.configuration_panel_actions_action_label', { + actionIndex: 1, + actionName: 'reject', + }), + }); + expect(actionButton).not.toBeInTheDocument(); + }); + + it('should be possible to toggle between predefined and custom actions', async () => { + const user = userEvent.setup(); + (BpmnActionModeler as jest.Mock).mockImplementation(() => ({ + getTypeForAction: () => 'serverAction', + })); + + renderActionsEditor({ mode: 'edit' }); + + const customActionButton = screen.getByRole('tab', { + name: textMock('process_editor.configuration_panel_actions_action_card_custom'), + }); + + await user.click(customActionButton); + const customActionTextfield = screen.getByLabelText( + textMock('process_editor.configuration_panel_actions_action_card_custom_label'), + ); + expect(customActionTextfield).toBeInTheDocument(); + + const predefinedActionButton = screen.getByRole('tab', { + name: textMock('process_editor.configuration_panel_actions_action_tab_predefined'), + }); + await user.click(predefinedActionButton); + const predefinedActionSelect = screen.getByLabelText( + textMock('process_editor.configuration_panel_actions_action_selector_label'), + ); + expect(predefinedActionSelect).toBeInTheDocument(); + }); + + it('should display custom action view when action is of type custom', () => { + (BpmnActionModeler as jest.Mock).mockImplementation(() => ({ + getTypeForAction: () => 'serverAction', + })); + + renderActionsEditor({ + actionElement: { ...actionElementMock, action: 'my-custom-action' }, + mode: 'edit', + }); + const customActionTextfield = screen.getByLabelText( + textMock('process_editor.configuration_panel_actions_action_card_custom_label'), + ); + expect(customActionTextfield).toBeInTheDocument(); + }); +}); + +type RenderActionsEditorProps = { + mode?: ActionsEditorProps['mode']; + actionElement?: Action; +}; +const renderActionsEditor = (props?: Partial) => { + return render( + + + + + , + ); +}; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/ActionsEditor.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/ActionsEditor.tsx new file mode 100644 index 00000000000..5019bbc2cea --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/ActionsEditor.tsx @@ -0,0 +1,142 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { + StudioCard, + StudioTabs, + StudioDivider, + StudioButton, + StudioDeleteButton, + StudioProperty, + StudioParagraph, +} from '@studio/components'; +import { CheckmarkIcon } from '@studio/icons'; +import { CustomActions } from './CustomActions'; +import { PredefinedActions } from './PredefinedActions'; +import { useBpmnContext } from '../../../../../contexts/BpmnContext'; +import { type Action, BpmnActionModeler } from '../../../../../utils/bpmn/BpmnActionModeler'; +import { getPredefinedActions, isActionRequiredForTask } from '../../../../../utils/processActions'; +import classes from './ActionsEditor.module.css'; + +enum TabIds { + Predefined = 'predefined', + Custom = 'custom', +} + +type ComponentMode = 'edit' | 'view'; + +export type ActionsEditorProps = { + actionElement: Action; + actionIndex: number; + mode?: ComponentMode; +}; +export const ActionsEditor = ({ + actionElement, + actionIndex, + mode, +}: ActionsEditorProps): React.ReactElement => { + const [componentMode, setComponentMode] = React.useState(mode || 'view'); + const { t } = useTranslation(); + const { bpmnDetails } = useBpmnContext(); + const bpmnActionModeler = new BpmnActionModeler(bpmnDetails.element); + + const actionLabel = t('process_editor.configuration_panel_actions_action_label', { + actionIndex: actionIndex + 1, + actionName: actionElement.action, + }); + + if (componentMode === 'edit') { + return ( + setComponentMode('view')} + onDelete={() => bpmnActionModeler.deleteActionFromTask(actionElement)} + /> + ); + } + + return ( + setComponentMode('edit')} + property={actionLabel} + value={actionElement.action} + className={classes.actionView} + /> + ); +}; + +type ActionEditableProps = { + actionElement: Action; + actionIndex: number; + onClose: () => void; + onDelete: () => void; +}; +const ActionEditable = ({ + actionElement, + actionIndex, + onClose, + onDelete, +}: ActionEditableProps): React.ReactElement => { + const { t } = useTranslation(); + const { bpmnDetails } = useBpmnContext(); + + const isCustomAction = + actionElement.action !== undefined && + !getPredefinedActions(bpmnDetails.taskType).includes(actionElement.action); + + return ( + + + + {t('process_editor.configuration_panel_actions_action_card_title', { + actionIndex: actionIndex + 1, + })} + + + + + + + + {t('process_editor.configuration_panel_actions_action_tab_predefined')} + + + {t('process_editor.configuration_panel_actions_action_card_custom')} + + + + + + + + + + + + } + onClick={onClose} + /> + + + + ); +}; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/CustomActions/CustomAction.test.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/CustomActions/CustomAction.test.tsx new file mode 100644 index 00000000000..e6c7d2445f0 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/CustomActions/CustomAction.test.tsx @@ -0,0 +1,147 @@ +import React from 'react'; +import { userEvent } from '@testing-library/user-event'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { render, screen, waitFor } from '@testing-library/react'; +import { CustomActions, type CustomActionsProps } from './CustomActions'; +import { useActionHandler } from '../hooks/useOnActionChange'; +import { BpmnContext } from '../../../../../../contexts/BpmnContext'; +import { mockBpmnContextValue } from '../../../../../../../test/mocks/bpmnContextMock'; +import { type Action, BpmnActionModeler } from '../../../../../../utils/bpmn/BpmnActionModeler'; +import { BpmnConfigPanelFormContextProvider } from '../../../../../../contexts/BpmnConfigPanelContext'; + +jest.mock('../hooks/useOnActionChange'); +jest.mock('../../../../../../utils/bpmn/BpmnActionModeler'); + +const actionElementMock: Action = { + $type: 'altinn:Action', +}; + +describe('CustomActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should be possible to add new custom action', async () => { + const user = userEvent.setup(); + + const handeOnActionChangeMock = jest.fn(); + (useActionHandler as jest.Mock).mockImplementation(() => ({ + handleOnActionChange: handeOnActionChangeMock, + })); + + renderCustomAction(); + + const inputField = screen.getByLabelText( + textMock('process_editor.configuration_panel_actions_action_card_custom_label'), + ); + + const myCustomActionName = 'My custom action'; + await user.type(inputField, myCustomActionName); + await waitFor(() => expect(handeOnActionChangeMock).toHaveBeenCalledTimes(1)); + expect(handeOnActionChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ + value: myCustomActionName, + }), + }), + ); + }); + + it('should be possible to change action type', async () => { + const user = userEvent.setup(); + + (useActionHandler as jest.Mock).mockImplementation(() => ({ + handleOnActionChange: jest.fn(), + })); + + const updateTypeForActionMock = jest.fn(); + (BpmnActionModeler as jest.Mock).mockImplementation(() => ({ + updateTypeForAction: updateTypeForActionMock, + getTypeForAction: jest.fn().mockReturnValue('processAction'), + })); + + renderCustomAction(); + + const actionTypeSwitch = screen.getByLabelText( + textMock('process_editor.configuration_panel_actions_set_server_action_label'), + ); + await user.click(actionTypeSwitch); + + expect(updateTypeForActionMock).toHaveBeenCalledTimes(1); + expect(updateTypeForActionMock).toHaveBeenCalledWith(actionElementMock, 'serverAction'); + + await user.click(actionTypeSwitch); + expect(updateTypeForActionMock).toHaveBeenCalledTimes(2); + }); + + it('should be possible to change action type to process', async () => { + const user = userEvent.setup(); + + (useActionHandler as jest.Mock).mockImplementation(() => ({ + handleOnActionChange: jest.fn(), + })); + + const updateTypeForActionMock = jest.fn(); + (BpmnActionModeler as jest.Mock).mockImplementation(() => ({ + updateTypeForAction: updateTypeForActionMock, + getTypeForAction: jest.fn().mockReturnValue('serverAction'), + })); + + renderCustomAction(); + + const actionTypeSwitch = screen.getByLabelText( + textMock('process_editor.configuration_panel_actions_set_server_action_label'), + ); + await user.click(actionTypeSwitch); + + expect(updateTypeForActionMock).toHaveBeenCalledTimes(1); + expect(updateTypeForActionMock).toHaveBeenCalledWith(actionElementMock, 'processAction'); + }); + + it('should not be possible to change action type if action is predefined', async () => { + const user = userEvent.setup(); + + (useActionHandler as jest.Mock).mockImplementation(() => ({ + handleOnActionChange: jest.fn(), + })); + + const updateTypeForActionMock = jest.fn(); + (BpmnActionModeler as jest.Mock).mockImplementation(() => ({ + updateTypeForAction: updateTypeForActionMock, + getTypeForAction: jest.fn().mockReturnValue('Process'), + })); + + renderCustomAction({ actionElement: { ...actionElementMock, action: 'write' } }); + + const actionTypeSwitch = screen.getByLabelText( + textMock('process_editor.configuration_panel_actions_set_server_action_label'), + ); + await user.click(actionTypeSwitch); + + expect(updateTypeForActionMock).toHaveBeenCalledTimes(0); + }); + + it('should display help text for action type', () => { + (useActionHandler as jest.Mock).mockImplementation(() => ({ + handleOnActionChange: jest.fn(), + })); + + renderCustomAction(); + + const helpText = screen.getByText( + textMock('process_editor.configuration_panel_actions_action_type_help_text'), + ); + + expect(helpText).toBeInTheDocument(); + }); +}); + +const renderCustomAction = (props?: Partial) => { + return render( + + + + + , + ); +}; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/CustomActions/CustomActions.module.css b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/CustomActions/CustomActions.module.css new file mode 100644 index 00000000000..2e5bad1be73 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/CustomActions/CustomActions.module.css @@ -0,0 +1,10 @@ +.customActionTextfield { + margin-bottom: var(--fds-spacing-2); +} + +.actionTypeContainer { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; +} diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/CustomActions/CustomActions.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/CustomActions/CustomActions.tsx new file mode 100644 index 00000000000..4db4dd19d86 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/CustomActions/CustomActions.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { StudioTextfield } from '@studio/components'; +import { HelpText, Switch } from '@digdir/design-system-react'; +import { useDebounce } from 'app-shared/hooks/useDebounce'; +import { BpmnActionModeler, ActionType } from '../../../../../../utils/bpmn/BpmnActionModeler'; +import type { Action } from '../../../../../../utils/bpmn/BpmnActionModeler'; +import { useActionHandler } from '../hooks/useOnActionChange'; +import { getPredefinedActions } from '../../../../../../utils/processActions'; +import { useBpmnContext } from '../../../../../../contexts/BpmnContext'; +import classes from './CustomActions.module.css'; +import { useTranslation } from 'react-i18next'; + +export type CustomActionsProps = { + actionElement: Action; +}; +export const CustomActions = ({ actionElement }: CustomActionsProps): React.ReactElement => { + const { t } = useTranslation(); + const { bpmnDetails } = useBpmnContext(); + const { handleOnActionChange } = useActionHandler(actionElement); + const { debounce } = useDebounce({ debounceTimeInMs: 300 }); + const bpmnActionModeler = new BpmnActionModeler(bpmnDetails.element); + + const onCustomActionChange = (event: React.ChangeEvent): void => { + debounce(() => handleOnActionChange(event)); + }; + + const onActionTypeChange = (event: React.ChangeEvent): void => { + const isChecked = event.target.checked; + const actionType = isChecked ? ActionType.Server : ActionType.Process; + bpmnActionModeler.updateTypeForAction(actionElement, actionType); + }; + + const isCustomAction = !getPredefinedActions(bpmnDetails.taskType).includes(actionElement.action); + const currentActionType = bpmnActionModeler.getTypeForAction(actionElement) || ActionType.Process; + + return ( + <> + +
+ + {t('process_editor.configuration_panel_actions_set_server_action_label')} + + + {t('process_editor.configuration_panel_actions_set_server_action_info')} + +
+ + ); +}; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/CustomActions/index.ts b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/CustomActions/index.ts new file mode 100644 index 00000000000..dbba66e9456 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/CustomActions/index.ts @@ -0,0 +1 @@ +export { CustomActions } from './CustomActions'; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/PredefinedActions/PredefinedActions.test.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/PredefinedActions/PredefinedActions.test.tsx new file mode 100644 index 00000000000..e8788fcfce8 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/PredefinedActions/PredefinedActions.test.tsx @@ -0,0 +1,103 @@ +import React from 'react'; +import { userEvent } from '@testing-library/user-event'; +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { render, screen, waitFor } from '@testing-library/react'; +import { PredefinedActions } from './PredefinedActions'; +import { useActionHandler } from '../hooks/useOnActionChange'; +import { BpmnContext } from '../../../../../../contexts/BpmnContext'; +import { mockBpmnContextValue } from '../../../../../../../test/mocks/bpmnContextMock'; +import { type Action, BpmnActionModeler } from '../../../../../../utils/bpmn/BpmnActionModeler'; +import { BpmnConfigPanelFormContextProvider } from '../../../../../../contexts/BpmnConfigPanelContext'; + +jest.mock('../hooks/useOnActionChange'); +jest.mock('../../../../../../utils/bpmn/BpmnActionModeler'); + +const actionElementMock: Action = { + $type: 'altinn:Action', + action: 'write', +}; + +describe('PredefinedActions', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should be possible to choose predefined action', async () => { + const user = userEvent.setup(); + + const handeOnActionChangeMock = jest.fn(); + (useActionHandler as jest.Mock).mockImplementation(() => ({ + handleOnActionChange: handeOnActionChangeMock, + })); + + renderPredefinedActions(); + + const select = screen.getByLabelText( + textMock('process_editor.configuration_panel_actions_action_selector_label'), + ); + + await user.selectOptions(select, 'reject'); + + await waitFor(() => expect(handeOnActionChangeMock).toHaveBeenCalledTimes(1)); + expect(handeOnActionChangeMock).toHaveBeenCalledWith( + expect.objectContaining({ + target: expect.objectContaining({ + value: 'reject', + }), + }), + ); + }); + + it('should disable actions that are not available', async () => { + const user = userEvent.setup(); + + const handeOnActionChangeMock = jest.fn(); + (useActionHandler as jest.Mock).mockImplementation(() => ({ + handleOnActionChange: handeOnActionChangeMock, + })); + + (BpmnActionModeler as jest.Mock).mockImplementation(() => ({ + actionElements: { + action: [{ action: 'reject' }], + }, + })); + + renderPredefinedActions(); + + const predefinedActionsSelect = screen.getByLabelText( + textMock('process_editor.configuration_panel_actions_action_selector_label'), + ); + + await user.click(predefinedActionsSelect); + const predefinedOption = screen.getByRole('option', { name: 'reject' }); + expect(predefinedOption).toBeDisabled(); + }); + + it('should have blank value if action is not a predefined action', async () => { + const handeOnActionChangeMock = jest.fn(); + (useActionHandler as jest.Mock).mockImplementation(() => ({ + handleOnActionChange: handeOnActionChangeMock, + })); + + renderPredefinedActions({ actionElement: { ...actionElementMock, action: 'not-predefined' } }); + + const predefinedActionSelect = screen.getByLabelText( + textMock('process_editor.configuration_panel_actions_action_selector_label'), + ); + + expect(predefinedActionSelect).toHaveValue(' '); + }); +}); + +type RenderPredefinedActionsProps = { + actionElement?: Action; +}; +const renderPredefinedActions = (props?: RenderPredefinedActionsProps) => { + return render( + + + + + , + ); +}; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/PredefinedActions/PredefinedActions.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/PredefinedActions/PredefinedActions.tsx new file mode 100644 index 00000000000..b8cd175ab43 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/PredefinedActions/PredefinedActions.tsx @@ -0,0 +1,48 @@ +import React from 'react'; +import { useTranslation } from 'react-i18next'; +import { StudioNativeSelect } from '@studio/components'; +import { useActionHandler } from '../hooks/useOnActionChange'; +import { useBpmnContext } from '../../../../../../contexts/BpmnContext'; +import { getPredefinedActions, isActionAvailable } from '../../../../../../utils/processActions'; +import { type Action, BpmnActionModeler } from '../../../../../../utils/bpmn/BpmnActionModeler'; + +type PredefinedActionsProps = { + actionElement: Action; +}; +export const PredefinedActions = ({ + actionElement, +}: PredefinedActionsProps): React.ReactElement => { + const { t } = useTranslation(); + const { bpmnDetails } = useBpmnContext(); + const bpmnActionModeler = new BpmnActionModeler(bpmnDetails.element); + const { handleOnActionChange } = useActionHandler(actionElement); + + const actions = bpmnActionModeler.actionElements?.action || []; + const availablePredefinedActions = getPredefinedActions(bpmnDetails.taskType); + + const shouldDisableAction = (action: string): boolean => { + return !isActionAvailable(action, actions) && !(action === actionElement.action); + }; + + const isPredefinedAction = (action: string): boolean => { + return availablePredefinedActions.includes(action); + }; + + return ( + + + ), + )} + + ); +}; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/PredefinedActions/index.ts b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/PredefinedActions/index.ts new file mode 100644 index 00000000000..c1b5dac21a2 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/PredefinedActions/index.ts @@ -0,0 +1 @@ +export { PredefinedActions } from './PredefinedActions'; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/hooks/useOnActionChange.test.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/hooks/useOnActionChange.test.tsx new file mode 100644 index 00000000000..5aab33e0773 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/hooks/useOnActionChange.test.tsx @@ -0,0 +1,66 @@ +import React, { type ChangeEvent } from 'react'; +import { renderHook } from '@testing-library/react'; +import { useActionHandler } from './useOnActionChange'; +import { BpmnContext } from '../../../../../../contexts/BpmnContext'; +import { mockBpmnContextValue } from '../../../../../../../test/mocks/bpmnContextMock'; +import { type Action, BpmnActionModeler } from '../../../../../../utils/bpmn/BpmnActionModeler'; +import { BpmnConfigPanelFormContextProvider } from '../../../../../../contexts/BpmnConfigPanelContext'; + +jest.mock('../../../../../../utils/bpmn/BpmnActionModeler'); + +const actionElementMock: Action = { + $type: 'altinn:Action', + action: 'reject', +}; + +describe('useOnActionChange', () => { + it('should add action to task if no actions is already defined', async () => { + const addNewActionToTaskMock = jest.fn(); + (BpmnActionModeler as jest.Mock).mockImplementation(() => ({ + hasActionsAlready: false, + addNewActionToTask: addNewActionToTaskMock, + })); + + const { result } = renderHook(() => useActionHandler(actionElementMock), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + const event = { + target: { + value: 'approve', + }, + } as ChangeEvent; + + result.current.handleOnActionChange(event); + expect(addNewActionToTaskMock).toHaveBeenCalledTimes(1); + }); + + it('should update action name on action element if actions is already defined', async () => { + const updateActionNameOnActionElementMock = jest.fn(); + (BpmnActionModeler as jest.Mock).mockImplementation(() => ({ + hasActionsAlready: true, + updateActionNameOnActionElement: updateActionNameOnActionElementMock, + })); + + const { result } = renderHook(() => useActionHandler(actionElementMock), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + + const event = { + target: { + value: 'approve', + }, + } as ChangeEvent; + + result.current.handleOnActionChange(event); + expect(updateActionNameOnActionElementMock).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/hooks/useOnActionChange.ts b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/hooks/useOnActionChange.ts new file mode 100644 index 00000000000..f1b2f4983a4 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/hooks/useOnActionChange.ts @@ -0,0 +1,28 @@ +import type React from 'react'; +import { useBpmnContext } from '../../../../../../contexts/BpmnContext'; +import { type Action, BpmnActionModeler } from '../../../../../../utils/bpmn/BpmnActionModeler'; + +type UseOnActionChangeResult = { + handleOnActionChange: (event: React.ChangeEvent) => void; +}; +export const useActionHandler = (actionElement: Action): UseOnActionChangeResult => { + const { bpmnDetails } = useBpmnContext(); + const bpmnActionModeler = new BpmnActionModeler(bpmnDetails.element); + + const handleOnActionChange = (event: React.ChangeEvent) => { + const actionToSave = event.target.value; + + const shouldUpdateExistingActions = bpmnActionModeler.hasActionsAlready; + + if (shouldUpdateExistingActions) { + bpmnActionModeler.updateActionNameOnActionElement(actionElement, actionToSave); + return; + } + + bpmnActionModeler.addNewActionToTask(actionToSave); + }; + + return { + handleOnActionChange, + }; +}; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/index.ts b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/index.ts new file mode 100644 index 00000000000..6967e4fcd83 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsEditor/index.ts @@ -0,0 +1 @@ +export { ActionsEditor } from './ActionsEditor'; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsUtils.ts b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsUtils.ts deleted file mode 100644 index 1d9e570e325..00000000000 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/ActionsUtils.ts +++ /dev/null @@ -1,146 +0,0 @@ -import type { ModdleElement } from 'bpmn-js/lib/BaseModeler'; -import type Modeling from 'bpmn-js/lib/features/modeling/Modeling'; -import type BpmnFactory from 'bpmn-js/lib/features/modeling/BpmnFactory'; -import type { BpmnDetails } from '../../../../types/BpmnDetails'; -import type { ActionType } from './EditAction'; -import type { BpmnTaskType } from '../../../../types/BpmnTaskType'; - -export const addNewActionToTask = ( - bpmnFactory: BpmnFactory, - modeling: Modeling, - generatedActionName: string, - bpmnDetails: BpmnDetails, -) => { - const actionsElement: ModdleElement = - bpmnDetails.element.businessObject.extensionElements.values[0].actions; - const newActionElement: ModdleElement = bpmnFactory.create('altinn:Action', { - action: generatedActionName, - }); - // Task has actions in element from before - if (actionsElement) { - actionsElement.action.push(newActionElement); - updateExistingActionsOnTask(modeling, bpmnDetails, actionsElement); - } else { - addFirstActionOnTask(bpmnFactory, modeling, bpmnDetails, newActionElement); - } -}; - -export const updateActionNameOnActionElement = ( - actionElement: ModdleElement, - newAction: string, - modeling: Modeling, - bpmnDetails: BpmnDetails, -) => { - if (actionElement.action === newAction || newAction === '') return; - if (getPredefinedActions(bpmnDetails.taskType).includes(newAction)) { - delete actionElement.type; - } - updateActionNameOnExistingAction(modeling, bpmnDetails, actionElement, newAction); -}; - -export const deleteActionFromTask = ( - bpmnDetails: BpmnDetails, - actionElement: ModdleElement, - modeling: Modeling, -) => { - const actionsElement = bpmnDetails.element.businessObject.extensionElements.values[0].actions; - const index = actionsElement.action.indexOf(actionElement); - actionsElement.action.splice(index, 1); - if (actionsElement.action.length > 0) { - updateExistingActionsOnTask(modeling, bpmnDetails, actionsElement); - } else { - updateExistingActionsOnTask(modeling, bpmnDetails, undefined); - } -}; - -export const setActionTypeOnAction = ( - actionType: ActionType, - bpmnDetails: BpmnDetails, - actionElement: ModdleElement, - modeling: Modeling, -) => { - const actionsElement = bpmnDetails.element.businessObject.extensionElements.values[0].actions; - const actionIndex = actionsElement.action.indexOf(actionElement); - actionsElement.action[actionIndex]['type'] = actionType; - updateExistingActionsOnTask(modeling, bpmnDetails, actionsElement); -}; - -const addFirstActionOnTask = ( - bpmnFactory: BpmnFactory, - modeling: Modeling, - bpmnDetails: BpmnDetails, - newActionElement: ModdleElement, -) => { - const newActionsElement: ModdleElement = bpmnFactory.create('altinn:Actions', { - action: [newActionElement], - }); - updateExistingActionsOnTask(modeling, bpmnDetails, newActionsElement); -}; - -const updateExistingActionsOnTask = ( - modeling: Modeling, - bpmnDetails: BpmnDetails, - updatedActionsElement: ModdleElement[], -) => { - modeling.updateModdleProperties( - bpmnDetails.element, - bpmnDetails.element.businessObject.extensionElements.values[0], - { - actions: updatedActionsElement, - }, - ); -}; - -const updateActionNameOnExistingAction = ( - modeling: Modeling, - bpmnDetails: BpmnDetails, - actionElementToUpdate: ModdleElement, - newActionName: string, -) => { - modeling.updateModdleProperties(bpmnDetails.element, actionElementToUpdate, { - action: newActionName, - }); -}; - -export const getPredefinedActions = (bpmnTaskType: BpmnTaskType): string[] => { - const allPredefinedActions = ['write', 'reject', 'confirm']; - if (bpmnTaskType === 'signing') allPredefinedActions.push('sign'); - if (bpmnTaskType === 'payment') allPredefinedActions.push('pay'); - return allPredefinedActions; -}; - -export const getTypeForAction = (actionElement: ModdleElement) => { - return (actionElement?.type || actionElement?.$attrs?.type) ?? undefined; -}; - -export const isActionRequiredForTask = (action: string, bpmnTaskType: BpmnTaskType): boolean => { - if (bpmnTaskType === 'signing' && action === 'sign') return true; - if (bpmnTaskType === 'signing' && action === 'reject') return true; - if (bpmnTaskType === 'payment' && action === 'pay') return true; - if (bpmnTaskType === 'payment' && action === 'confirm') return true; - if (bpmnTaskType === 'payment' && action === 'reject') return true; - return bpmnTaskType === 'confirmation' && action === 'confirm'; -}; - -export const getAvailablePredefinedActions = ( - taskType: BpmnTaskType, - actionElements: ModdleElement[], -) => filterAvailableActions(getPredefinedActions(taskType), actionElements) ?? []; - -const filterAvailableActions = ( - predefinedActionNames: string[], - existingActionElements: ModdleElement[], -): string[] => { - return predefinedActionNames.filter((actionName: string) => - isActionAvailable(actionName, existingActionElements), - ); -}; - -const isActionAvailable = ( - actionName: string, - existingActionElements: ModdleElement[], -): boolean => { - return !existingActionElements.some( - (actionElement: ModdleElement) => actionElement.action === actionName, - ); -}; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditAction.module.css b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditAction.module.css deleted file mode 100644 index a42120f3731..00000000000 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditAction.module.css +++ /dev/null @@ -1,20 +0,0 @@ -.action { - padding: var(--studio-property-button-vertical-spacing) var(--fds-spacing-5); - margin-bottom: var(--studio-property-button-vertical-spacing); -} - -.editActionName { - display: flex; - flex-direction: row; - align-items: flex-end; - justify-content: space-between; - gap: var(--fds-spacing-4); -} - -.editActionType { - padding-top: var(--fds-spacing-4); - display: flex; - flex-direction: row; - align-items: center; - justify-content: space-between; -} diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditAction.test.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditAction.test.tsx deleted file mode 100644 index 13a88986cad..00000000000 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditAction.test.tsx +++ /dev/null @@ -1,369 +0,0 @@ -import React from 'react'; -import userEvent from '@testing-library/user-event'; -import { textMock } from '@studio/testing/mocks/i18nMock'; -import { render, screen } from '@testing-library/react'; -import { mockBpmnDetails, paymentActions } from '../../../../../test/mocks/bpmnDetailsMock'; -import { ActionType, EditAction } from './EditAction'; -import { - modelingMock, - updateModdlePropertiesMock, -} from '../../../../../test/mocks/bpmnModelerMock'; -import type { EditActionProps } from './EditAction'; - -const mockActionElementWrite = - mockBpmnDetails.element.businessObject.extensionElements.values[0].actions.action[0]; -const mockActionElementCustomServer = - mockBpmnDetails.element.businessObject.extensionElements.values[0].actions.action[1]; -const mockActionElementCustomProcess = - mockBpmnDetails.element.businessObject.extensionElements.values[0].actions.action[2]; -const mockAvailablePredefinedActions = ['reject', 'confirm']; -const defaultEditActionProps: EditActionProps = { - actionElementToEdit: mockActionElementWrite, - availablePredefinedActions: mockAvailablePredefinedActions, - bpmnDetails: mockBpmnDetails, - index: 0, - modeling: modelingMock as any, -}; - -describe('EditAction', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - it('should render a defined action as read only by default when action is required for task', () => { - const mockActionElementPay = paymentActions.actions.action[0]; - renderEditAction({ - ...defaultEditActionProps, - bpmnDetails: { - ...mockBpmnDetails, - taskType: 'payment', - }, - actionElementToEdit: mockActionElementPay, - }); - const definedAction = screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex: defaultEditActionProps.index + 1, - actionName: mockActionElementPay.action, - }), - }); - expect(definedAction).toHaveAttribute('aria-readonly'); - }); - - it('should render a defined action by default when action is set', () => { - renderEditAction(); - expect( - screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex: defaultEditActionProps.index + 1, - actionName: mockActionElementWrite.action, - }), - }), - ).toBeInTheDocument(); - }); - - it('should render help text for custom action', async () => { - const user = userEvent.setup(); - renderEditAction({ - ...defaultEditActionProps, - actionElementToEdit: mockActionElementCustomServer, - }); - const openEditModeButton = screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex: defaultEditActionProps.index + 1, - actionName: mockActionElementCustomServer.action, - }), - }); - await user.click(openEditModeButton); - const helpTextButton = screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_type_help_text'), - }); - await user.click(helpTextButton); - expect( - screen.getByText( - textMock('process_editor.configuration_panel_actions_set_server_action_info'), - ), - ).toBeInTheDocument(); - }); - - it('should render save button as disabled when clicking an option', async () => { - const user = userEvent.setup(); - renderEditAction({ - ...defaultEditActionProps, - actionElementToEdit: mockActionElementCustomServer, - }); - const openEditModeButton = screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex: defaultEditActionProps.index + 1, - actionName: mockActionElementCustomServer.action, - }), - }); - await user.click(openEditModeButton); - const helpTextButton = screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_type_help_text'), - }); - await user.click(helpTextButton); - expect( - screen.getByText( - textMock('process_editor.configuration_panel_actions_set_server_action_info'), - ), - ).toBeInTheDocument(); - }); - - it('should render switch that is enabled for an existing server action', async () => { - const user = userEvent.setup(); - renderEditAction({ - ...defaultEditActionProps, - actionElementToEdit: mockActionElementCustomServer, - }); - const openEditModeButton = screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex: defaultEditActionProps.index + 1, - actionName: mockActionElementCustomServer.action, - }), - }); - await user.click(openEditModeButton); - const serverActionSwitch = screen.getByRole('checkbox', { - name: `set_server_type_for_${mockActionElementCustomServer.action}_action`, - }); - expect(serverActionSwitch).toBeInTheDocument(); - expect(serverActionSwitch).toBeChecked(); - }); - - it('should render switch that is not checked for a task that has an existing custom processAction', async () => { - const user = userEvent.setup(); - renderEditAction({ - ...defaultEditActionProps, - actionElementToEdit: mockActionElementCustomProcess, - }); - const openEditModeButton = screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex: defaultEditActionProps.index + 1, - actionName: mockActionElementCustomProcess.action, - }), - }); - await user.click(openEditModeButton); - const processActionSwitch = screen.getByRole('checkbox', { - name: `set_server_type_for_${mockActionElementCustomProcess.action}_action`, - }); - expect(processActionSwitch).toBeInTheDocument(); - expect(processActionSwitch).not.toBeChecked(); - }); - - it('should call updateModdleProperties on modeling with actionType=server when setting serverAction', async () => { - const user = userEvent.setup(); - renderEditAction({ - ...defaultEditActionProps, - actionElementToEdit: mockActionElementCustomProcess, - }); - expect(mockActionElementCustomProcess.type).toBe(ActionType.Process); - const openEditModeButton = screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex: defaultEditActionProps.index + 1, - actionName: mockActionElementCustomProcess.action, - }), - }); - await user.click(openEditModeButton); - const actionTypeSwitch = screen.getByRole('checkbox', { - name: `set_server_type_for_${mockActionElementCustomProcess.action}_action`, - }); - await user.click(actionTypeSwitch); - expect(updateModdlePropertiesMock).toHaveBeenCalledTimes(1); - expect(updateModdlePropertiesMock).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - actions: expect.objectContaining({ - action: expect.arrayContaining([ - { action: mockActionElementCustomProcess.action, type: ActionType.Server }, - ]), - }), - }), - ); - }); - - it('should call updateModdleProperties on modeling with actionType=process when unsetting serverAction', async () => { - const user = userEvent.setup(); - renderEditAction({ - ...defaultEditActionProps, - actionElementToEdit: mockActionElementCustomServer, - }); - expect(mockActionElementCustomServer.type).toBe(ActionType.Server); - const openEditModeButton = screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex: defaultEditActionProps.index + 1, - actionName: mockActionElementCustomServer.action, - }), - }); - await user.click(openEditModeButton); - const actionTypeSwitch = screen.getByRole('checkbox', { - name: `set_server_type_for_${mockActionElementCustomServer.action}_action`, - }); - await user.click(actionTypeSwitch); - expect(updateModdlePropertiesMock).toHaveBeenCalledTimes(1); - expect(updateModdlePropertiesMock).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - actions: expect.objectContaining({ - action: expect.arrayContaining([ - { action: mockActionElementCustomServer.action, type: ActionType.Process }, - ]), - }), - }), - ); - }); - - it('should call updateModdleProperties on modeling with new action name when typing a new action in combobox', async () => { - const user = userEvent.setup(); - const newActionNameCustom = 'myCustomAction'; - renderEditAction(); - const openEditModeButton = screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex: defaultEditActionProps.index + 1, - actionName: mockActionElementWrite.action, - }), - }); - await user.click(openEditModeButton); - const combobox = screen.getByTitle(`combobox_${mockActionElementWrite.action}`); - await user.clear(combobox); - await user.type(combobox, newActionNameCustom); - await user.tab(); - const saveButton = screen.getByRole('button', { name: textMock('general.save') }); - await user.click(saveButton); - expect(updateModdlePropertiesMock).toHaveBeenCalledTimes(1); - expect(updateModdlePropertiesMock).toHaveBeenCalledWith( - mockBpmnDetails.element, - mockActionElementWrite, - { - action: newActionNameCustom, - }, - ); - }); - - it('should call updateModdleProperties on modeling with new action name and deleted action type when changing a custom action to a predefined', async () => { - const user = userEvent.setup(); - const newActionNameReject = 'reject'; - renderEditAction({ - ...defaultEditActionProps, - actionElementToEdit: { - action: mockActionElementCustomServer.action, - type: ActionType.Server, - }, - }); - const openEditModeButton = screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex: defaultEditActionProps.index + 1, - actionName: mockActionElementCustomServer.action, - }), - }); - await user.click(openEditModeButton); - const combobox = screen.getByTitle(`combobox_${mockActionElementCustomServer.action}`); - await user.click(combobox); - await user.clear(combobox); - const actionOption = screen.getByRole('option', { name: newActionNameReject }); - await user.click(actionOption); - const saveButton = await screen.findByRole('button', { name: textMock('general.save') }); - await user.click(saveButton); - expect(updateModdlePropertiesMock).toHaveBeenCalledTimes(1); - expect(updateModdlePropertiesMock).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ action: mockActionElementCustomServer.action }) && - expect.not.objectContaining({ type: mockActionElementCustomServer.type }), - { action: newActionNameReject }, - ); - }); - - it('should call updateModdleProperties on modeling without specific action when deleting an action', async () => { - const user = userEvent.setup(); - renderEditAction(); - const openEditModeButton = screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex: defaultEditActionProps.index + 1, - actionName: mockActionElementWrite.action, - }), - }); - await user.click(openEditModeButton); - const deleteButton = screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_delete_action', { - actionName: mockActionElementWrite.action, - }), - }); - await user.click(deleteButton); - expect(updateModdlePropertiesMock).toHaveBeenCalledTimes(1); - expect(updateModdlePropertiesMock).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - actions: - expect.objectContaining({ mockActionElementCustomServer }) && - expect.objectContaining({ mockActionElementCustomProcess }) && - expect.not.objectContaining({ mockActionElementWrite }), - }), - ); - }); - - it('should call updateModdleProperties on modeling without any actions when deleting the only existing one', async () => { - const user = userEvent.setup(); - renderEditAction({ - ...defaultEditActionProps, - bpmnDetails: { - ...mockBpmnDetails, - element: { - businessObject: { - extensionElements: { - values: [ - { - actions: { - action: [mockActionElementWrite], - }, - }, - ], - }, - }, - }, - }, - }); - const openEditModeButton = screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex: defaultEditActionProps.index + 1, - actionName: mockActionElementWrite.action, - }), - }); - await user.click(openEditModeButton); - const deleteButton = screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_delete_action', { - actionName: mockActionElementWrite.action, - }), - }); - await user.click(deleteButton); - expect(updateModdlePropertiesMock).toHaveBeenCalledTimes(1); - expect(updateModdlePropertiesMock).toHaveBeenCalledWith( - expect.anything(), - expect.anything(), - expect.objectContaining({ - actions: undefined, - }), - ); - }); - - it('should not call updateModdleProperties on modeling when selecting the current action', async () => { - const user = userEvent.setup(); - const newActionNameWrite = 'write'; - renderEditAction(); - const openEditModeButton = screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex: defaultEditActionProps.index + 1, - actionName: mockActionElementWrite.action, - }), - }); - await user.click(openEditModeButton); - const combobox = screen.getByTitle(`combobox_${mockActionElementWrite.action}`); - await user.clear(combobox); - const actionOption = screen.getByRole('option', { name: newActionNameWrite }); - await user.click(actionOption); - expect(updateModdlePropertiesMock).not.toHaveBeenCalled(); - }); -}); - -const renderEditAction = (editActionProps: EditActionProps = defaultEditActionProps) => { - return render(); -}; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditAction.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditAction.tsx deleted file mode 100644 index 19f8d075ebc..00000000000 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditAction.tsx +++ /dev/null @@ -1,133 +0,0 @@ -import type { ChangeEvent } from 'react'; -import React, { useState } from 'react'; -import classes from './EditAction.module.css'; -import { - deleteActionFromTask, - getPredefinedActions, - getTypeForAction, - isActionRequiredForTask, - setActionTypeOnAction, - updateActionNameOnActionElement, -} from './ActionsUtils'; -import type Modeling from 'bpmn-js/lib/features/modeling/Modeling'; -import type { ModdleElement } from 'bpmn-js/lib/BaseModeler'; -import { StudioButton, StudioDeleteButton, StudioProperty } from '@studio/components'; -import { CheckmarkIcon } from '@studio/icons'; -import { HelpText, Switch } from '@digdir/design-system-react'; -import type { BpmnDetails } from '../../../../types/BpmnDetails'; -import { useTranslation } from 'react-i18next'; -import { SelectAction } from './SelectAction'; - -export enum ActionType { - Server = 'serverAction', - Process = 'processAction', -} - -export interface EditActionProps { - actionElementToEdit: ModdleElement; - availablePredefinedActions: string[]; - bpmnDetails: BpmnDetails; - index: number; - modeling: Modeling; -} - -export const EditAction = ({ - actionElementToEdit, - availablePredefinedActions, - bpmnDetails, - index, - modeling, -}: EditActionProps) => { - const { t } = useTranslation(); - const [editMode, setEditMode] = useState(actionElementToEdit.action === undefined); - // Ideally this state is not needed. The call to update action should be triggered on direct change of the value in the combobox. - // When this triggering can happen from both option selection and custom writing we need to keep the value in a state and pass this - // value when clicking save-button. - const [currentActionName, setCurrentActionName] = useState( - actionElementToEdit.action ?? '', - ); - - const setActionType = (actionElement: ModdleElement, checked: ChangeEvent) => { - const actionType = checked.target.checked ? ActionType.Server : ActionType.Process; - setActionTypeOnAction(actionType, bpmnDetails, actionElement, modeling); - }; - - const handleUpdateAction = (actionElement: ModdleElement, newAction: string) => { - updateActionNameOnActionElement(actionElement, newAction, modeling, bpmnDetails); - setEditMode(false); - }; - - const handleDeleteAction = (actionElement: ModdleElement) => { - deleteActionFromTask(bpmnDetails, actionElement, modeling); - }; - - const allowSettingServerAction = (actionName: string): boolean => { - if (actionName === '') return false; // Ensure that default is not allowing - return ( - !isActionRequiredForTask(actionName, bpmnDetails.taskType) && - !getPredefinedActions(bpmnDetails.taskType).includes(actionName) - ); - }; - - const actionLabel = (actionName = actionElementToEdit.action) => - t('process_editor.configuration_panel_actions_action_label', { - actionIndex: index + 1, - actionName: actionName, - }); - - return editMode ? ( -
-
- setCurrentActionName(actionName)} - /> - } - onClick={() => handleUpdateAction(actionElementToEdit, currentActionName)} - disabled={currentActionName === ''} - size='small' - title={t('general.save')} - variant='secondary' - /> - handleDeleteAction(actionElementToEdit)} - size='small' - title={t('process_editor.configuration_panel_actions_delete_action', { - actionName: actionElementToEdit.action, - })} - /> -
- {allowSettingServerAction(currentActionName) && ( -
- setActionType(actionElementToEdit, checked)} - size='small' - value={getTypeForAction(actionElementToEdit) ?? ActionType.Process} - checked={getTypeForAction(actionElementToEdit) === ActionType.Server} - > - {t('process_editor.configuration_panel_actions_set_server_action_label')} - - - {t('process_editor.configuration_panel_actions_set_server_action_info')} - -
- )} -
- ) : ( - setEditMode(true)} - property={actionLabel(null)} - title={actionLabel()} - value={actionElementToEdit.action} - className={classes.action} - /> - ); -}; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditActions.module.css b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditActions.module.css new file mode 100644 index 00000000000..6fdbe9e7fc5 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditActions.module.css @@ -0,0 +1,3 @@ +.container { + margin: 0 0 var(--fds-spacing-3) var(--fds-spacing-3); +} diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditActions.test.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditActions.test.tsx index 2272dbc5efd..8fc7a0e539d 100644 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditActions.test.tsx +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditActions.test.tsx @@ -1,148 +1,162 @@ import React from 'react'; -import { render, screen } from '@testing-library/react'; -import { EditActions } from './EditActions'; -import { textMock } from '@studio/testing/mocks/i18nMock'; -import { - confirmationActions, - mockBpmnDetails, - paymentActions, - signingActions, -} from '../../../../../test/mocks/bpmnDetailsMock'; -import { - createMock, - mockModelerRef, - updateModdlePropertiesMock, -} from '../../../../../test/mocks/bpmnModelerMock'; -import { useBpmnContext } from '../../../../contexts/BpmnContext'; -import type { BpmnDetails } from '../../../../types/BpmnDetails'; -import type { BpmnTaskType } from '../../../../types/BpmnTaskType'; import userEvent from '@testing-library/user-event'; -import { ObjectUtils } from '@studio/pure-functions'; - -const actionsForTaskTypes = { - confirmation: confirmationActions, - signing: signingActions, - payment: paymentActions, -}; // add payment: paymentActions - -const setBpmnDetailsMock = jest.fn(); -jest.mock('../../../../contexts/BpmnContext', () => ({ - useBpmnContext: jest.fn(() => ({ - modelerRef: mockModelerRef, - setBpmnDetails: setBpmnDetailsMock, - bpmnDetails: mockBpmnDetails, - })), -})); - -jest.mock('bpmn-js/lib/features/modeling/BpmnFactory', () => ({ - BpmnFactory: jest.fn(() => ({ - create: jest.fn(), - })), -})); - -const overrideBpmnDetailsMock = (bpmnDetailsToOverride: BpmnDetails) => { - (useBpmnContext as jest.Mock).mockReturnValue({ - modelerRef: mockModelerRef, - setBpmnDetails: setBpmnDetailsMock, - bpmnDetails: bpmnDetailsToOverride, - }); +import { textMock } from '@studio/testing/mocks/i18nMock'; +import { render, screen, waitFor } from '@testing-library/react'; +import { EditActions } from './EditActions'; +import { BpmnContext } from '../../../../contexts/BpmnContext'; +import { mockBpmnContextValue } from '../../../../../test/mocks/bpmnContextMock'; +import { type Action, BpmnActionModeler } from '../../../../utils/bpmn/BpmnActionModeler'; +import { BpmnConfigPanelFormContextProvider } from '../../../../contexts/BpmnConfigPanelContext'; + +jest.mock('../../../../utils/bpmn/BpmnActionModeler'); + +const actionElementDefaultMock: Action = { + $type: 'altinn:Action', }; describe('EditActions', () => { - afterEach(() => { + beforeEach(() => { jest.clearAllMocks(); }); - it('should render only "add new action" button when task have no actions', () => { - renderEditActions({ - ...mockBpmnDetails, - element: { businessObject: { extensionElements: { values: [{}] } } }, + it('should add new action if actions not already exists', async () => { + const user = userEvent.setup(); + + const addNewActionToTaskMock = jest.fn(); + const updateTypeForActionMock = jest.fn(); + const updateActionNameOnActionElementMock = jest.fn(); + setupBpmnActionModelerMock({ + addNewActionToTaskMock, + updateTypeForActionMock, + updateActionNameOnActionElementMock, }); - const addNewActionButton = screen.getAllByRole('button', { + + renderEditActions(); + const addButton = screen.getByRole('button', { name: textMock('process_editor.configuration_panel_actions_add_new'), }); - expect(addNewActionButton).toHaveLength(1); - }); - it('should render existing actions when task have predefined actions', () => { - const predefinedAction = - mockBpmnDetails.element.businessObject.extensionElements.values[0].actions.action[0].action; - const customServerAction = - mockBpmnDetails.element.businessObject.extensionElements.values[0].actions.action[1].action; - const customProcessAction = - mockBpmnDetails.element.businessObject.extensionElements.values[0].actions.action[2].action; - renderEditActions(); + await user.click(addButton); + await waitFor(() => expect(addNewActionToTaskMock).toHaveBeenCalledTimes(1)); expect( - screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_label', { + screen.getByText( + textMock('process_editor.configuration_panel_actions_action_card_title', { actionIndex: 1, - actionName: predefinedAction, - }), - }), - ).toBeInTheDocument(); - expect( - screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex: 2, - actionName: customServerAction, }), - }), - ).toBeInTheDocument(); - expect( - screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex: 3, - actionName: customProcessAction, - }), - }), - ).toBeInTheDocument(); + ), + ); }); - it.each(['confirmation', 'signing', 'payment'])( - 'should render readOnly non-clickable defined action button for actions that are required for task type: %s', - (taskType: BpmnTaskType) => { - const actions = actionsForTaskTypes[taskType]; - const element = { businessObject: { extensionElements: { values: [actions] } } }; - renderEditActions({ ...mockBpmnDetails, taskType: taskType, element }); - actions.actions.action.forEach((action, index) => { - const definedActionButton = screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex: index + 1, - actionName: action.action, - }), - }); - expect(definedActionButton).toHaveAttribute('aria-readonly'); - }); - }, - ); + it('should append new action if actions already exists', async () => { + const user = userEvent.setup(); + + const addNewActionToTaskMock = jest.fn(); + const updateTypeForActionMock = jest.fn(); + const updateActionNameOnActionElementMock = jest.fn(); + const createActionElementMock = jest.fn(); + const getExtensionElementsMock = jest.fn(); + setupBpmnActionModelerMock({ + addNewActionToTaskMock, + updateTypeForActionMock, + updateActionNameOnActionElementMock, + getExtensionElementsMock, + createActionElementMock: createActionElementMock, + hasActionsAlready: true, + }); + + renderEditActions(); + const addButton = screen.getByRole('button', { + name: textMock('process_editor.configuration_panel_actions_add_new'), + }); + + await user.click(addButton); + + await waitFor(() => expect(updateActionNameOnActionElementMock).toHaveBeenCalledTimes(1)); + }); + + it('should list existing actions in view mode', () => { + setupBpmnActionModelerMock({ + addNewActionToTaskMock: jest.fn(), + updateTypeForActionMock: jest.fn(), + updateActionNameOnActionElementMock: jest.fn(), + hasActionsAlready: true, + actionElementMock: { + ...actionElementDefaultMock, + action: 'reject', + }, + }); - it('should not render optional action for task as read only', () => { - const predefinedAction = - mockBpmnDetails.element.businessObject.extensionElements.values[0].actions.action[0].action; renderEditActions(); - const definedActionButton = screen.getByRole('button', { - name: textMock('process_editor.configuration_panel_actions_action_label', { + + const viewModeElement = screen.getByText( + textMock('process_editor.configuration_panel_actions_action_label', { actionIndex: 1, - actionName: predefinedAction, + actionName: 'reject', }), - }); - expect(definedActionButton).not.toHaveAttribute('aria-readonly'); + ); + expect(viewModeElement).toBeInTheDocument(); }); - it('should call "create" and "updateModdleProperties" when clicking "add new action"', async () => { + it('should display in edit mode when adding new action', async () => { const user = userEvent.setup(); + setupBpmnActionModelerMock({ + addNewActionToTaskMock: jest.fn(), + createActionElementMock: jest.fn(), + getExtensionElementsMock: jest.fn(), + updateActionNameOnActionElementMock: jest.fn(), + hasActionsAlready: true, + }); renderEditActions(); - const addNewActionButton = screen.getByRole('button', { + + const addButton = screen.getByRole('button', { name: textMock('process_editor.configuration_panel_actions_add_new'), }); - await user.click(addNewActionButton); - expect(createMock).toHaveBeenCalledTimes(1); - expect(updateModdlePropertiesMock).toHaveBeenCalledTimes(1); + await user.click(addButton); + + const predefinedActionSelector = screen.getByLabelText( + textMock('process_editor.configuration_panel_actions_action_selector_label'), + ); + await waitFor(() => expect(predefinedActionSelector).toBeInTheDocument()); }); }); -const renderEditActions = (bpmnDetails = mockBpmnDetails) => { - const bpmnDetailsCopy = ObjectUtils.deepCopy(bpmnDetails); - overrideBpmnDetailsMock(bpmnDetailsCopy); - return render(); +const renderEditActions = () => { + return render( + + + + + , + ); +}; + +type BpmnActionModelerMock = { + addNewActionToTaskMock: jest.Mock; + updateTypeForActionMock: jest.Mock; + updateActionNameOnActionElementMock: jest.Mock; + hasActionsAlready: boolean; + createActionElementMock: jest.Mock; + getExtensionElementsMock: jest.Mock; }; + +const setupBpmnActionModelerMock = ({ + addNewActionToTaskMock, + updateTypeForActionMock, + updateActionNameOnActionElementMock, + hasActionsAlready, + createActionElementMock, + getExtensionElementsMock, + actionElementMock, +}: Partial) => + (BpmnActionModeler as jest.Mock).mockImplementation(() => ({ + addNewActionToTask: addNewActionToTaskMock, + updateTypeForAction: updateTypeForActionMock, + updateActionNameOnActionElement: updateActionNameOnActionElementMock, + createActionElement: createActionElementMock, + getExtensionElements: getExtensionElementsMock, + hasActionsAlready, + getTypeForAction: jest.fn().mockReturnValue('Process'), + actionElements: { + action: [actionElementMock || actionElementDefaultMock], + }, + })); diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditActions.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditActions.tsx index 7364d1e84d5..7031e221490 100644 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditActions.tsx +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/EditActions.tsx @@ -1,45 +1,55 @@ import React from 'react'; -import { useBpmnContext } from '../../../../contexts/BpmnContext'; import { useTranslation } from 'react-i18next'; import { StudioProperty } from '@studio/components'; -import type Modeling from 'bpmn-js/lib/features/modeling/Modeling'; import type { ModdleElement } from 'bpmn-js/lib/BaseModeler'; -import type BpmnFactory from 'bpmn-js/lib/features/modeling/BpmnFactory'; -import { addNewActionToTask, getAvailablePredefinedActions } from './ActionsUtils'; -import { EditAction } from './EditAction'; +import { useChecksum } from './useChecksum'; +import { ActionsEditor } from './ActionsEditor'; +import { useBpmnContext } from '../../../../contexts/BpmnContext'; +import { type Action, BpmnActionModeler } from '../../../../utils/bpmn/BpmnActionModeler'; + +import classes from './EditActions.module.css'; -export const EditActions = () => { +export const EditActions = (): React.ReactElement => { const { t } = useTranslation(); - const { bpmnDetails, modelerRef } = useBpmnContext(); - const actionElements: ModdleElement[] = - bpmnDetails?.element?.businessObject?.extensionElements?.values[0]?.actions?.action ?? []; - const modelerInstance = modelerRef.current; - const modeling: Modeling = modelerInstance.get('modeling'); - const bpmnFactory: BpmnFactory = modelerInstance.get('bpmnFactory'); + const { bpmnDetails } = useBpmnContext(); + const bpmnActionModeler = new BpmnActionModeler(bpmnDetails.element); + // This is a custom hook that is used to force re-render the component, since the actions from bpmnjs are not reactive + const { updateChecksum: forceReRenderComponent } = useChecksum(); + const actions: Action[] = bpmnActionModeler.actionElements?.action || []; - const availablePredefinedActions = getAvailablePredefinedActions( - bpmnDetails.taskType, - actionElements, - ); + const onNewActionAddClicked = (): void => { + const shouldUpdateExistingActions = bpmnActionModeler.hasActionsAlready; + if (shouldUpdateExistingActions) { + const existingActionElement = bpmnActionModeler.actionElements; + + const newActionElement = bpmnActionModeler.createActionElement(undefined); + existingActionElement?.action.push(newActionElement); - const handleAddNewAction = () => { - addNewActionToTask(bpmnFactory, modeling, undefined, bpmnDetails); + bpmnActionModeler.updateActionNameOnActionElement( + bpmnActionModeler.getExtensionElements(), + undefined, + ); + forceReRenderComponent(); + return; + } + bpmnActionModeler.addNewActionToTask(undefined); + forceReRenderComponent(); }; return ( <> - {actionElements.map((actionElement: ModdleElement, index: number) => ( - + {actions.map((actionElement: ModdleElement, index: number) => ( + // Using the index as key, since we do not have a unique identifier for the action elements +
+ +
))} diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/SelectAction.module.css b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/SelectAction.module.css deleted file mode 100644 index 268e9930368..00000000000 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/SelectAction.module.css +++ /dev/null @@ -1,3 +0,0 @@ -.actionCombobox { - flex: 1; -} diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/SelectAction.test.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/SelectAction.test.tsx deleted file mode 100644 index 70aa243ab47..00000000000 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/SelectAction.test.tsx +++ /dev/null @@ -1,61 +0,0 @@ -import React from 'react'; -import userEvent from '@testing-library/user-event'; -import { render, screen, waitFor } from '@testing-library/react'; -import type { SelectActionProps } from './SelectAction'; -import { SelectAction } from './SelectAction'; - -describe('SelectAction', () => { - afterEach(() => { - jest.clearAllMocks(); - }); - it('should show combobox with all predefined actions as options', async () => { - const user = userEvent.setup(); - renderSelectAction(); - const combobox = screen.getByTitle(`combobox_${mockActionNameWrite}`); - await user.click(combobox); - mockAvailablePredefinedActions.forEach((action) => - expect(screen.getByRole('option', { name: action })).toBeInTheDocument(), - ); - }); - - it('should call onSetCurrentActionName when writing a custom action and bluring combobox', async () => { - const user = userEvent.setup(); - const myCustomActionName = 'MyCustomAction'; - const onSetCurrentActionNameMock = jest.fn(); - renderSelectAction({ onSetCurrentActionName: onSetCurrentActionNameMock }); - const combobox = screen.getByTitle(`combobox_${mockActionNameWrite}`); - await user.click(combobox); - await user.clear(combobox); - await user.type(combobox, myCustomActionName); - await user.tab(); - expect(onSetCurrentActionNameMock).toHaveBeenCalledTimes(1); - expect(onSetCurrentActionNameMock).toHaveBeenCalledWith(myCustomActionName); - }); - - it('should change displayValue when selecting an option', async () => { - const user = userEvent.setup(); - const rejectActionName = 'reject'; - const onSetCurrentActionNameMock = jest.fn(); - renderSelectAction({ onSetCurrentActionName: onSetCurrentActionNameMock }); - const combobox = screen.getByTitle(`combobox_${mockActionNameWrite}`); - expect(combobox).toHaveDisplayValue(mockActionNameWrite); - await user.click(combobox); - const rejectOption = screen.getByRole('option', { name: rejectActionName }); - await user.click(rejectOption); - await waitFor(() => expect(combobox).toHaveDisplayValue(rejectActionName)); - }); -}); - -const mockActionNameWrite = 'write'; -const mockAvailablePredefinedActions = ['reject', 'confirm']; -const defaultSelectActionProps: SelectActionProps = { - actionName: mockActionNameWrite, - availablePredefinedActions: mockAvailablePredefinedActions, - comboboxLabel: '', - currentActionName: mockActionNameWrite, - onSetCurrentActionName: jest.fn(), -}; - -const renderSelectAction = (selectActionProps?: Partial) => { - return render(); -}; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/SelectAction.tsx b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/SelectAction.tsx deleted file mode 100644 index 5f53c699e76..00000000000 --- a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/SelectAction.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import React from 'react'; -import { Combobox } from '@digdir/design-system-react'; -import classes from './SelectAction.module.css'; -import { useTranslation } from 'react-i18next'; - -export interface SelectActionProps { - actionName: string; - availablePredefinedActions: string[]; - comboboxLabel: string; - currentActionName: string; - onSetCurrentActionName: (actionName: string) => void; -} -export const SelectAction = ({ - actionName, - availablePredefinedActions, - comboboxLabel, - currentActionName, - onSetCurrentActionName, -}: SelectActionProps) => { - const { t } = useTranslation(); - - const allPredefinedActions = ['write', 'reject', 'confirm', 'sign']; - - return ( - onSetCurrentActionName(target.value)} - > - - {t('process_editor.configuration_panel_actions_custom_action')} - - {allPredefinedActions.includes(actionName) && ( - - {actionName} - - )} - {availablePredefinedActions.map((predefinedAction: string) => ( - - {predefinedAction} - - ))} - - ); -}; diff --git a/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/useChecksum.ts b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/useChecksum.ts new file mode 100644 index 00000000000..4efbd2207d6 --- /dev/null +++ b/frontend/packages/process-editor/src/components/ConfigPanel/ConfigContent/EditActions/useChecksum.ts @@ -0,0 +1,15 @@ +import { useState } from 'react'; + +type UseChecksumResult = { + checksum: number; + updateChecksum: () => void; +}; +export const useChecksum = (): UseChecksumResult => { + const [checksum, setChecksum] = useState(0); + + const updateChecksum = (): void => { + setChecksum((v) => v + 1); + }; + + return { checksum, updateChecksum }; +}; diff --git a/frontend/packages/process-editor/src/components/ConfigSurface/ConfigSurface.module.css b/frontend/packages/process-editor/src/components/ConfigSurface/ConfigSurface.module.css index d7c1edc7b30..4012624c787 100644 --- a/frontend/packages/process-editor/src/components/ConfigSurface/ConfigSurface.module.css +++ b/frontend/packages/process-editor/src/components/ConfigSurface/ConfigSurface.module.css @@ -3,4 +3,5 @@ background-color: var(--fds-semantic-surface-neutral-default); border-left: 1px solid var(--fds-semantic-border-neutral-subtle); overflow-y: scroll; + min-width: 500px; } diff --git a/frontend/packages/process-editor/src/utils/bpmn/BpmnActionModeler.ts b/frontend/packages/process-editor/src/utils/bpmn/BpmnActionModeler.ts new file mode 100644 index 00000000000..66bc65e6322 --- /dev/null +++ b/frontend/packages/process-editor/src/utils/bpmn/BpmnActionModeler.ts @@ -0,0 +1,114 @@ +import type { ModdleElement } from 'bpmn-js/lib/BaseModeler'; +import { StudioModeler } from './StudioModeler'; +import { getPredefinedActions } from '../processActions'; + +export type Action = ModdleElement; +export type ActionsElement = { + action: Action[]; +}; + +export enum ActionType { + Server = 'serverAction', + Process = 'processAction', +} + +export enum ActionTagType { + Action = 'altinn:Action', + Actions = 'altinn:Actions', +} + +/* + * Not all lines in this file are covered by tests because it would require extensive mocking of methods and classes from the bpmn-js library. + * This effort might not be worthwhile since the package is not very type-safe, meaning our tests might not fail even if the package's API changes. + */ + +export class BpmnActionModeler extends StudioModeler { + constructor(element?: Element) { + super(element); + } + + public get actionElements(): ActionsElement | undefined { + return this.getElement()?.businessObject.extensionElements?.values[0]?.actions; + } + + public getExtensionElements(): Action | undefined { + return this.getElement()?.businessObject.extensionElements?.values[0]; + } + + public get hasActionsAlready(): boolean { + return this.actionsElements?.length > 0; + } + + public getTypeForAction(actionElement: Action): ActionType | undefined { + return actionElement.type || actionElement.$attrs?.type; + } + + public addNewActionToTask(generatedActionName: string | undefined): void { + const actionElement = this.createActionElement(generatedActionName); + const actionsElement = this.createActionsElement(actionElement); + + this.updateActionsProperties(actionsElement); + } + + public deleteActionFromTask(actionElement: Action): void { + const actionsElement = this.actionElements; + const index = actionsElement.action.indexOf(actionElement); + actionsElement.action.splice(index, 1); + + const hasActions = actionsElement?.action.length > 0; + this.updateActionsProperties(hasActions ? actionsElement : undefined); + } + + public updateActionNameOnActionElement(actionElement: ModdleElement, newAction: string): void { + if (actionElement?.action === newAction || newAction === '') return; + + if (getPredefinedActions(this.getCurrentTaskType).includes(newAction)) { + delete actionElement.type; + } + this.updateActionProperties(newAction, actionElement); + } + + public updateTypeForAction(actionElement: Action, actionType: ActionType): void { + const actionsElement = this.actionElements; + if (!actionsElement) throw new Error('No actions element found, cannot update type for action'); + + const actionIndex = actionsElement.action.indexOf(actionElement); + actionsElement.action[actionIndex].type = actionType; + + this.updateActionsProperties(actionsElement); + } + + public createActionElement(actionName: string | undefined): ModdleElement { + return this.bpmnFactory.create(ActionTagType.Action, { + action: actionName, + }); + } + + private get actionsElements(): Action[] | undefined { + return this.actionElements?.action; + } + + private createActionsElement(actionElement: ModdleElement): ModdleElement { + return this.bpmnFactory.create(ActionTagType.Actions, { + action: [actionElement], + }); + } + + private updateActionsProperties(actionsElement: ActionsElement): void { + this.updateModdleProperties( + { + actions: actionsElement, + }, + this.getExtensionElements(), + ); + } + + private updateActionProperties(actionName: string, actionElement: Action): void { + this.updateModdleProperties( + { + action: actionName, + }, + actionElement, + ); + } +} diff --git a/frontend/packages/process-editor/src/utils/bpmn/StudioModeler.ts b/frontend/packages/process-editor/src/utils/bpmn/StudioModeler.ts index 54ef067d68f..509976183d4 100644 --- a/frontend/packages/process-editor/src/utils/bpmn/StudioModeler.ts +++ b/frontend/packages/process-editor/src/utils/bpmn/StudioModeler.ts @@ -1,25 +1,39 @@ +import type { Element } from 'bpmn-moddle'; import type Modeler from 'bpmn-js/lib/Modeler'; +import { type Moddle } from 'bpmn-js/lib/model/Types'; +import type { ModdleElement } from 'bpmn-js/lib/BaseModeler'; import type Modeling from 'bpmn-js/lib/features/modeling/Modeling'; -import type { Element } from 'bpmn-moddle'; import type ElementRegistry from 'diagram-js/lib/core/ElementRegistry'; -import { type Moddle } from 'bpmn-js/lib/model/Types'; +import type BpmnFactory from 'bpmn-js/lib/features/modeling/BpmnFactory'; import { BpmnModelerInstance } from './BpmnModelerInstance'; +import type { BpmnTaskType } from '../../types/BpmnTaskType'; // Short description: This class is used to interact with the bpmn-js modeler instance to create, update and delete elements in the bpmn diagram. // We have not written test for this class then we need to mock the BpmnModelerInstance and its methods. -enum AvailableInstanceGetters { + +/* + * Not all lines in this file are covered by tests because it would require extensive mocking of methods and classes from the bpmn-js library. + * This effort might not be worthwhile since the package is not very type-safe, meaning our tests might not fail even if the package's API changes. + */ + +enum AvailableBpmnInstances { Modeling = 'modeling', Moddle = 'moddle', ElementRegistry = 'elementRegistry', + BpmnFactory = 'bpmnFactory', } export class StudioModeler { - private readonly modelerInstance: Modeler = BpmnModelerInstance.getInstance(); - private readonly modeling: Modeling = this.modelerInstance.get(AvailableInstanceGetters.Modeling); + public readonly modelerInstance: Modeler = BpmnModelerInstance.getInstance(); + public readonly bpmnFactory: BpmnFactory = this.modelerInstance.get( + AvailableBpmnInstances.BpmnFactory, + ); + + private readonly modeling: Modeling = this.modelerInstance.get(AvailableBpmnInstances.Modeling); - private readonly moddle: Moddle = this.modelerInstance.get(AvailableInstanceGetters.Moddle); + private readonly moddle: Moddle = this.modelerInstance.get(AvailableBpmnInstances.Moddle); private readonly elementRegistry: ElementRegistry = this.modelerInstance.get( - AvailableInstanceGetters.ElementRegistry, + AvailableBpmnInstances.ElementRegistry, ); private element: Element; @@ -36,6 +50,12 @@ export class StudioModeler { return this.elementRegistry.get(id || this.getElementId()); } + public get getCurrentTaskType(): BpmnTaskType { + const element = this.getElement(); + const bpmnAttrs = element.businessObject?.$attrs; + return bpmnAttrs ? bpmnAttrs['altinn:tasktype'] : null; + } + public createElement(elementType: string, options: T): Element { return this.moddle.create(elementType, { ...options }); } @@ -44,6 +64,10 @@ export class StudioModeler { this.modeling.updateProperties(this.getElement(), { ...properties }); } + public updateModdleProperties(properties: T, element: ModdleElement): void { + this.modeling.updateModdleProperties(this.getElement(), element, { ...properties }); + } + public getAllTasksByType(elementType: string): Element[] { return this.elementRegistry.filter((element) => element.type === elementType); } diff --git a/frontend/packages/process-editor/src/utils/processActions/index.ts b/frontend/packages/process-editor/src/utils/processActions/index.ts new file mode 100644 index 00000000000..2f5d9eb20cc --- /dev/null +++ b/frontend/packages/process-editor/src/utils/processActions/index.ts @@ -0,0 +1 @@ +export { getPredefinedActions, isActionRequiredForTask, isActionAvailable } from './processActions'; diff --git a/frontend/packages/process-editor/src/utils/processActions/processActions.test.ts b/frontend/packages/process-editor/src/utils/processActions/processActions.test.ts new file mode 100644 index 00000000000..71636d4a67e --- /dev/null +++ b/frontend/packages/process-editor/src/utils/processActions/processActions.test.ts @@ -0,0 +1,71 @@ +import { getPredefinedActions, isActionAvailable, isActionRequiredForTask } from './processActions'; + +describe('processActionsUtils', () => { + describe('getPredefinedActions', () => { + it('should return predefined actions for signing', () => { + const result = getPredefinedActions('signing'); + expect(result).toEqual(['write', 'reject', 'confirm', 'sign']); + }); + + it('should return predefined actions for payment', () => { + const result = getPredefinedActions('payment'); + expect(result).toEqual(['write', 'reject', 'confirm', 'pay']); + }); + + it('should return predefined actions for confirmation', () => { + const result = getPredefinedActions('confirmation'); + expect(result).toEqual(['write', 'reject', 'confirm']); + }); + + it('should return predefined actions for task', () => { + const result = getPredefinedActions('data'); + expect(result).toEqual(['write', 'reject', 'confirm']); + }); + }); + + describe('isActionRequiredForTask', () => { + it('should return true for sign action for signing task', () => { + const result = isActionRequiredForTask('sign', 'signing'); + expect(result).toEqual(true); + }); + + it('should return true for reject action for signing task', () => { + const result = isActionRequiredForTask('reject', 'signing'); + expect(result).toEqual(true); + }); + + it('should return true for pay action for payment task', () => { + const result = isActionRequiredForTask('pay', 'payment'); + expect(result).toEqual(true); + }); + + it('should return true for confirm action for payment task', () => { + const result = isActionRequiredForTask('confirm', 'payment'); + expect(result).toEqual(true); + }); + + it('should return true for reject action for payment task', () => { + const result = isActionRequiredForTask('reject', 'payment'); + expect(result).toEqual(true); + }); + + it('should return true for confirm action for confirmation task', () => { + const result = isActionRequiredForTask('confirm', 'confirmation'); + expect(result).toEqual(true); + }); + }); + + describe('isActionAvailable', () => { + it('should return true if action is not available', () => { + const existingActionElements = [{ action: 'write' }]; + const result = isActionAvailable('reject', existingActionElements); + expect(result).toEqual(true); + }); + + it('should return false if action is available', () => { + const existingActionElements = [{ action: 'write' }]; + const result = isActionAvailable('write', existingActionElements); + expect(result).toEqual(false); + }); + }); +}); diff --git a/frontend/packages/process-editor/src/utils/processActions/processActions.ts b/frontend/packages/process-editor/src/utils/processActions/processActions.ts new file mode 100644 index 00000000000..5ca490afdc3 --- /dev/null +++ b/frontend/packages/process-editor/src/utils/processActions/processActions.ts @@ -0,0 +1,27 @@ +import type { ModdleElement } from 'bpmn-js/lib/BaseModeler'; +import type { BpmnTaskType } from '../../types/BpmnTaskType'; + +export const getPredefinedActions = (bpmnTaskType: BpmnTaskType): string[] => { + const allPredefinedActions = ['write', 'reject', 'confirm']; + if (bpmnTaskType === 'signing') allPredefinedActions.push('sign'); + if (bpmnTaskType === 'payment') allPredefinedActions.push('pay'); + return allPredefinedActions; +}; + +export const isActionRequiredForTask = (action: string, bpmnTaskType: BpmnTaskType): boolean => { + if (bpmnTaskType === 'signing' && action === 'sign') return true; + if (bpmnTaskType === 'signing' && action === 'reject') return true; + if (bpmnTaskType === 'payment' && action === 'pay') return true; + if (bpmnTaskType === 'payment' && action === 'confirm') return true; + if (bpmnTaskType === 'payment' && action === 'reject') return true; + return bpmnTaskType === 'confirmation' && action === 'confirm'; +}; + +export const isActionAvailable = ( + actionName: string, + existingActionElements: ModdleElement[], +): boolean => { + return !existingActionElements.some( + (actionElement: ModdleElement) => actionElement.action === actionName, + ); +}; diff --git a/frontend/packages/shared/src/hooks/useDebounce.ts b/frontend/packages/shared/src/hooks/useDebounce.ts index dc8a8b8356f..afc8e1baa25 100644 --- a/frontend/packages/shared/src/hooks/useDebounce.ts +++ b/frontend/packages/shared/src/hooks/useDebounce.ts @@ -5,7 +5,7 @@ type UseDebounceOptions = { }; export const useDebounce = ({ debounceTimeInMs }: UseDebounceOptions) => { const debounceRef = useRef(undefined); - const debounce = (callback: Function) => { + const debounce = (callback: Function): void => { clearTimeout(debounceRef.current); debounceRef.current = setTimeout(() => { callback(); diff --git a/frontend/testing/playwright/pages/GiteaPage.ts b/frontend/testing/playwright/pages/GiteaPage.ts index 9fb1d4500b8..e233e914c20 100644 --- a/frontend/testing/playwright/pages/GiteaPage.ts +++ b/frontend/testing/playwright/pages/GiteaPage.ts @@ -23,10 +23,6 @@ export class GiteaPage extends BasePage { super(page, environment); } - public async loadGiteaPage(): Promise { - await this.page.goto(this.getRoute('gitea')); - } - public async verifyGiteaPage(): Promise { await this.page.waitForURL(this.getRoute('gitea')); } @@ -170,6 +166,16 @@ export class GiteaPage extends BasePage { await this.page.getByText(`${action}`).isVisible(); } + public async verifyThatActionIsCustomServerAction(action: string): Promise { + await this.page + .getByText(`${action}`) + .isVisible(); + } + + public async verifyThatActionIsHidden(action: string): Promise { + await this.page.getByText(`${action}`).isHidden(); + } + public async verifyThatTaskIsVisible(task: string): Promise { await this.page.getByText(`${task}`).isVisible(); } diff --git a/frontend/testing/playwright/pages/ProcessEditorPage/ActionsConfig.ts b/frontend/testing/playwright/pages/ProcessEditorPage/ActionsConfig.ts new file mode 100644 index 00000000000..19cfec3395d --- /dev/null +++ b/frontend/testing/playwright/pages/ProcessEditorPage/ActionsConfig.ts @@ -0,0 +1,84 @@ +import { BasePage } from '@studio/testing/playwright/helpers/BasePage'; +import { expect, type Page } from '@playwright/test'; + +export class ActionsConfig extends BasePage { + constructor(public page: Page) { + super(page); + } + + public async clickOnActionsAccordion(): Promise { + await this.page + .getByRole('button', { + name: this.textMock('process_editor.configuration_panel_actions_title'), + }) + .click(); + } + + public async waitForAddActionsButtonToBeVisible(): Promise { + const button = this.page.getByRole('button', { + name: this.textMock('process_editor.configuration_panel_actions_add_new'), + }); + await expect(button).toBeVisible(); + } + + public async clickAddActionsButton(): Promise { + await this.page + .getByRole('button', { + name: this.textMock('process_editor.configuration_panel_actions_add_new'), + }) + .click(); + } + + public async choosePredefinedAction(action: string): Promise { + const predefinedActionsSelect = this.page + .getByLabel(this.textMock('process_editor.configuration_panel_actions_action_selector_label')) + .first(); + await expect(predefinedActionsSelect).toBeVisible(); + await predefinedActionsSelect.selectOption({ label: action }); + } + + public async clickOnCustomActionTab(): Promise { + await this.page.getByRole('tab', { name: 'Lag egendefinert handling' }).first().click(); + + const customActionTextfield = this.page.getByLabel( + this.textMock('process_editor.configuration_panel_actions_action_card_custom_label'), + ); + + await expect(customActionTextfield).toBeVisible(); + } + + public async writeCustomAction(customAction: string): Promise { + await this.page + .getByRole('textbox', { + name: this.textMock('process_editor.configuration_panel_actions_action_card_custom_label'), + }) + .fill(customAction); + } + + public async makeCustomActionToServerAction(): Promise { + await this.page + .getByRole('checkbox', { + name: this.textMock('process_editor.configuration_panel_actions_set_server_action_label'), + }) + .click(); + } + + public async editAction(action: string): Promise { + await this.page + .getByRole('button', { + name: this.textMock('process_editor.configuration_panel_actions_action_label', { + actionIndex: '1', + actionName: action, + }), + }) + .click(); + } + + public async deleteAction(action: string): Promise { + await this.page + .getByRole('button', { + name: this.textMock('general.delete', { name: action }), + }) + .click(); + } +} diff --git a/frontend/testing/playwright/pages/ProcessEditorPage/CustomReceiptConfig.ts b/frontend/testing/playwright/pages/ProcessEditorPage/CustomReceiptConfig.ts new file mode 100644 index 00000000000..81bb80b41b1 --- /dev/null +++ b/frontend/testing/playwright/pages/ProcessEditorPage/CustomReceiptConfig.ts @@ -0,0 +1,72 @@ +import { expect, type Page } from '@playwright/test'; +import { BasePage } from '../../helpers/BasePage'; + +export class CustomReceiptConfig extends BasePage { + constructor(page: Page) { + super(page); + } + + public async clickOnReceiptAccordion(): Promise { + await this.page + .getByRole('button', { + name: this.textMock('process_editor.configuration_panel_custom_receipt_accordion_header'), + }) + .click(); + } + + public async waitForCreateCustomReceiptButtonToBeVisible(): Promise { + const text = this.page.getByRole('button', { + name: this.textMock( + 'process_editor.configuration_panel_custom_receipt_create_your_own_button', + ), + }); + await expect(text).toBeVisible(); + } + + public async clickOnCreateCustomReceipt(): Promise { + await this.page + .getByRole('button', { + name: this.textMock( + 'process_editor.configuration_panel_custom_receipt_create_your_own_button', + ), + }) + .click(); + } + + public async waitForLayoutTextfieldToBeVisible(): Promise { + const textbox = this.page.getByRole('textbox', { + name: this.textMock('process_editor.configuration_panel_custom_receipt_textfield_label'), + }); + await expect(textbox).toBeVisible(); + } + + public async waitForSaveNewCustomReceiptButtonToBeVisible(): Promise { + const button = this.page.getByRole('button', { + name: this.textMock('process_editor.configuration_panel_custom_receipt_create_button'), + }); + await expect(button).toBeVisible(); + } + + public async clickOnSaveNewCustomReceiptButton(): Promise { + await this.page + .getByRole('button', { + name: this.textMock('process_editor.configuration_panel_custom_receipt_create_button'), + }) + .click(); + } + + public async waitForEditLayoutSetIdButtonToBeVisible(): Promise { + const button = this.page.getByRole('button', { + name: this.textMock('process_editor.configuration_panel_custom_receipt_textfield_label'), + }); + await expect(button).toBeVisible(); + } + + public async writeLayoutSetId(layoutSetId: string): Promise { + await this.page + .getByRole('textbox', { + name: this.textMock('process_editor.configuration_panel_custom_receipt_textfield_label'), + }) + .fill(layoutSetId); + } +} diff --git a/frontend/testing/playwright/pages/ProcessEditorPage/PolicyConfig.ts b/frontend/testing/playwright/pages/ProcessEditorPage/PolicyConfig.ts new file mode 100644 index 00000000000..b88512114e9 --- /dev/null +++ b/frontend/testing/playwright/pages/ProcessEditorPage/PolicyConfig.ts @@ -0,0 +1,59 @@ +import { expect, type Page } from '@playwright/test'; +import { BasePage } from '../../helpers/BasePage'; + +export class PolicyConfig extends BasePage { + constructor(page: Page) { + super(page); + } + + public async clickOnPolicyAccordion(): Promise { + await this.page + .getByRole('button', { + name: this.textMock('process_editor.configuration_panel_policy_title'), + }) + .click(); + } + + public async waitForNavigateToPolicyButtonIsVisible(): Promise { + const button = this.page.getByRole('button', { + name: this.textMock( + 'process_editor.configuration_panel.edit_policy_open_policy_editor_button', + ), + }); + await expect(button).toBeVisible(); + } + + public async clickOnNavigateToPolicyEditorButton(): Promise { + await this.page + .getByRole('button', { + name: this.textMock( + 'process_editor.configuration_panel.edit_policy_open_policy_editor_button', + ), + }) + .click(); + } + + public async verifyThatPolicyEditorIsOpen(): Promise { + const heading = this.page.getByRole('heading', { + name: this.textMock('policy_editor.rules'), + level: 2, + }); + await expect(heading).toBeVisible(); + } + + public async closePolicyEditor(): Promise { + await this.page + .getByRole('button', { + name: this.textMock('settings_modal.close_button_label'), + }) + .click(); + } + + public async verifyThatPolicyEditorIsClosed(): Promise { + const heading = this.page.getByRole('heading', { + name: this.textMock('policy_editor.rules'), + level: 2, + }); + await expect(heading).toBeHidden(); + } +} diff --git a/frontend/testing/playwright/pages/ProcessEditorPage/ProcessEditorPage.ts b/frontend/testing/playwright/pages/ProcessEditorPage/ProcessEditorPage.ts index 59445e2f787..b2a09f19180 100644 --- a/frontend/testing/playwright/pages/ProcessEditorPage/ProcessEditorPage.ts +++ b/frontend/testing/playwright/pages/ProcessEditorPage/ProcessEditorPage.ts @@ -1,7 +1,11 @@ import { expect } from '@playwright/test'; import type { Page } from '@playwright/test'; +import { ActionsConfig } from './ActionsConfig'; +import { PolicyConfig } from './PolicyConfig'; import { BasePage } from '../../helpers/BasePage'; import { DataModelConfig } from './DataModelConfig'; +import { SigningTaskConfig } from './SigningTaskConfig'; +import { CustomReceiptConfig } from './CustomReceiptConfig'; import { type BpmnTaskType } from '../../types/BpmnTaskType'; import type { Environment } from '../../helpers/StudioEnvironment'; @@ -9,10 +13,18 @@ const connectionArrowText: string = 'Connect using Sequence/MessageFlow or Assoc export class ProcessEditorPage extends BasePage { public readonly dataModelConfig: DataModelConfig; + public readonly actionsConfig: ActionsConfig; + public readonly policyConfig: PolicyConfig; + public readonly customReceiptConfig: CustomReceiptConfig; + public readonly signingTaskConfig: SigningTaskConfig; constructor(page: Page, environment?: Environment) { super(page, environment); this.dataModelConfig = new DataModelConfig(page); + this.actionsConfig = new ActionsConfig(page); + this.policyConfig = new PolicyConfig(page); + this.customReceiptConfig = new CustomReceiptConfig(page); + this.signingTaskConfig = new SigningTaskConfig(page); } public async loadProcessEditorPage(): Promise { @@ -35,119 +47,6 @@ export class ProcessEditorPage extends BasePage { await expect(heading).toBeVisible(); } - public async clickOnActionsAccordion(): Promise { - await this.page - .getByRole('button', { - name: this.textMock('process_editor.configuration_panel_actions_title'), - }) - .click(); - } - - public async waitForAddActionsButtonToBeVisible(): Promise { - const button = this.page.getByRole('button', { - name: this.textMock('process_editor.configuration_panel_actions_add_new'), - }); - await expect(button).toBeVisible(); - } - - public async clickAddActionsButton(): Promise { - await this.page - .getByRole('button', { - name: this.textMock('process_editor.configuration_panel_actions_add_new'), - }) - .click(); - } - - public async waitForActionComboboxTitleToBeVisible( - actionIndex: string, - actionName?: string, - ): Promise { - const combobox = this.page.getByRole('combobox', { - name: this.textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex, - actionName: actionName ?? '', - }), - }); - await expect(combobox).toBeVisible(); - } - - public async clickOnActionCombobox(actionIndex: string, actionName?: string): Promise { - await this.page - .getByRole('combobox', { - name: this.textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex, - actionName: actionName ?? '', - }), - }) - .click(); - } - - public async clickOnActionOption(action: string): Promise { - await this.page.getByRole('option', { name: action }).click(); - } - - public async removeFocusFromActionCombobox( - actionIndex: string, - actionName?: string, - ): Promise { - await this.page - .getByRole('combobox', { - name: this.textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex, - actionName: actionName ?? '', - }), - }) - .blur(); - } - - public async clickOnSaveActionButton(): Promise { - await this.page - .getByRole('button', { - name: this.textMock('general.save'), - }) - .click(); - } - - public async waitForActionButtonToBeVisible( - actionIndex: string, - actionName?: string, - ): Promise { - const button = this.page.getByRole('button', { - name: this.textMock('process_editor.configuration_panel_actions_action_label', { - actionIndex, - actionName: actionName ?? '', - }), - }); - await expect(button).toBeVisible(); - } - - public async clickOnPolicyAccordion(): Promise { - await this.page - .getByRole('button', { - name: this.textMock('process_editor.configuration_panel_policy_title'), - }) - .click(); - } - - public async waitForNavigateToPolicyButtonIsVisible(): Promise { - const button = this.page.getByRole('button', { - name: this.textMock( - 'process_editor.configuration_panel.edit_policy_open_policy_editor_button', - ), - }); - await expect(button).toBeVisible(); - } - - public async clickOnNavigateToPolicyEditorButton(): Promise { - await this.page - .getByRole('button', { - name: this.textMock( - 'process_editor.configuration_panel.edit_policy_open_policy_editor_button', - ), - }) - .click(); - } - public async dragTaskInToBpmnEditor( task: BpmnTaskType, dropElementSelector: string, @@ -187,7 +86,7 @@ export class ProcessEditorPage extends BasePage { await expect(inputField).toBeVisible(); } - public async emptyIdInputfield(): Promise { + public async emptyIdTextfield(): Promise { await this.page .getByRole('textbox', { name: this.textMock('process_editor.configuration_panel_change_task_id'), @@ -233,47 +132,6 @@ export class ProcessEditorPage extends BasePage { await this.page.getByTitle(connectionArrowText).click(); } - public async verifyThatPolicyEditorIsOpen(): Promise { - const heading = this.page.getByRole('heading', { - name: this.textMock('policy_editor.rules'), - level: 2, - }); - await expect(heading).toBeVisible(); - } - - public async closePolicyEditor(): Promise { - await this.page - .getByRole('button', { - name: this.textMock('settings_modal.close_button_label'), - }) - .click(); - } - - public async verifyThatPolicyEditorIsClosed(): Promise { - const heading = this.page.getByRole('heading', { - name: this.textMock('policy_editor.rules'), - level: 2, - }); - await expect(heading).toBeHidden(); - } - - public async clickDataTypesToSignCombobox(): Promise { - await this.page - .getByRole('combobox', { - name: this.textMock('process_editor.configuration_panel_set_data_types_to_sign'), - }) - .click(); - } - - public async clickOnDataTypesToSignOption(option: string): Promise { - await this.page.getByRole('option', { name: option }).click(); - } - - public async waitForDataTypeToSignButtonToBeVisible(option: string): Promise { - const button = this.page.getByLabel(this.textMock('general.delete_item', { item: option })); - await expect(button).toBeVisible(); - } - public async waitForEndEventHeaderToBeVisible(): Promise { const heading = this.page.getByRole('heading', { name: this.textMock('process_editor.configuration_panel_end_event'), @@ -282,70 +140,6 @@ export class ProcessEditorPage extends BasePage { await expect(heading).toBeVisible(); } - public async clickOnReceiptAccordion(): Promise { - await this.page - .getByRole('button', { - name: this.textMock('process_editor.configuration_panel_custom_receipt_accordion_header'), - }) - .click(); - } - - public async waitForCreateCustomReceiptButtonToBeVisible(): Promise { - const text = this.page.getByRole('button', { - name: this.textMock( - 'process_editor.configuration_panel_custom_receipt_create_your_own_button', - ), - }); - await expect(text).toBeVisible(); - } - - public async clickOnCreateCustomReceipt(): Promise { - await this.page - .getByRole('button', { - name: this.textMock( - 'process_editor.configuration_panel_custom_receipt_create_your_own_button', - ), - }) - .click(); - } - - public async waitForLayoutTextfieldToBeVisible(): Promise { - const textbox = this.page.getByRole('textbox', { - name: this.textMock('process_editor.configuration_panel_custom_receipt_textfield_label'), - }); - await expect(textbox).toBeVisible(); - } - - public async writeLayoutSetId(layoutSetId: string): Promise { - await this.page - .getByRole('textbox', { - name: this.textMock('process_editor.configuration_panel_custom_receipt_textfield_label'), - }) - .fill(layoutSetId); - } - - public async waitForSaveNewCustomReceiptButtonToBeVisible(): Promise { - const button = this.page.getByRole('button', { - name: this.textMock('process_editor.configuration_panel_custom_receipt_create_button'), - }); - await expect(button).toBeVisible(); - } - - public async clickOnSaveNewCustomReceiptButton(): Promise { - await this.page - .getByRole('button', { - name: this.textMock('process_editor.configuration_panel_custom_receipt_create_button'), - }) - .click(); - } - - public async waitForEditLayoutSetIdButtonToBeVisible(): Promise { - const button = this.page.getByRole('button', { - name: this.textMock('process_editor.configuration_panel_custom_receipt_textfield_label'), - }); - await expect(button).toBeVisible(); - } - /** * * Helper methods below this @@ -366,7 +160,6 @@ export class ProcessEditorPage extends BasePage { const button = this.page.locator(selector); const fullText = await button.textContent(); const extractedText = fullText.match(/ID: (Activity_\w+)/); - const fullId: string = extractedText[1]; - return fullId; + return extractedText[1]; } } diff --git a/frontend/testing/playwright/pages/ProcessEditorPage/SigningTaskConfig.ts b/frontend/testing/playwright/pages/ProcessEditorPage/SigningTaskConfig.ts new file mode 100644 index 00000000000..ab19561e759 --- /dev/null +++ b/frontend/testing/playwright/pages/ProcessEditorPage/SigningTaskConfig.ts @@ -0,0 +1,25 @@ +import { expect, type Page } from '@playwright/test'; +import { BasePage } from '../../helpers/BasePage'; + +export class SigningTaskConfig extends BasePage { + constructor(page: Page) { + super(page); + } + + public async clickDataTypesToSignCombobox(): Promise { + await this.page + .getByRole('combobox', { + name: this.textMock('process_editor.configuration_panel_set_data_types_to_sign'), + }) + .click(); + } + + public async clickOnDataTypesToSignOption(option: string): Promise { + await this.page.getByRole('option', { name: option }).click(); + } + + public async waitForDataTypeToSignButtonToBeVisible(option: string): Promise { + const button = this.page.getByLabel(this.textMock('general.delete_item', { item: option })); + await expect(button).toBeVisible(); + } +} diff --git a/frontend/testing/playwright/tests/process-editor/process-editor.spec.ts b/frontend/testing/playwright/tests/process-editor/process-editor.spec.ts index 16d2a051822..a96c9eebe7c 100644 --- a/frontend/testing/playwright/tests/process-editor/process-editor.spec.ts +++ b/frontend/testing/playwright/tests/process-editor/process-editor.spec.ts @@ -69,40 +69,6 @@ test('that the user is able to add and delete data model', async ({ page, testAp await processEditorPage.dataModelConfig.verifyThatAddNewDataModelLinkButtonIsHidden(); }); -test('that the user is able to add actions', async ({ page, testAppName }) => { - const processEditorPage = await setupAndVerifyProcessEditorPage(page, testAppName); - const bpmnJSQuery = new BpmnJSQuery(page); - const header = new Header(page, { app: testAppName }); - const giteaPage = new GiteaPage(page, { app: testAppName }); - - const initialTaskDataElementIdSelector: string = await bpmnJSQuery.getTaskByIdAndType( - 'Task_1', - 'g', - ); - - await processEditorPage.clickOnTaskInBpmnEditor(initialTaskDataElementIdSelector); - await processEditorPage.waitForInitialTaskHeaderToBeVisible(); - - await processEditorPage.clickOnActionsAccordion(); - await processEditorPage.waitForAddActionsButtonToBeVisible(); - - const actionIndex1: string = '1'; - await processEditorPage.clickAddActionsButton(); - await processEditorPage.waitForActionComboboxTitleToBeVisible(actionIndex1); - - const actionOptionWrite: string = 'write'; - await processEditorPage.clickOnActionCombobox(actionIndex1); - await processEditorPage.clickOnActionOption(actionOptionWrite); - await processEditorPage.removeFocusFromActionCombobox(actionIndex1); - await processEditorPage.clickOnSaveActionButton(); - await processEditorPage.waitForActionButtonToBeVisible(actionIndex1, actionOptionWrite); - - await commitAndPushToGitea(header); - - await goToGiteaAndNavigateToProcessBpmnFile(header, giteaPage); - await giteaPage.verifyThatActionIsVisible(actionOptionWrite); -}); - test('that the user able to open policy editor', async ({ page, testAppName }) => { const processEditorPage = await setupAndVerifyProcessEditorPage(page, testAppName); const bpmnJSQuery = new BpmnJSQuery(page); @@ -114,13 +80,13 @@ test('that the user able to open policy editor', async ({ page, testAppName }) = await processEditorPage.clickOnTaskInBpmnEditor(initialTaskDataElementIdSelector); await processEditorPage.waitForInitialTaskHeaderToBeVisible(); - await processEditorPage.clickOnPolicyAccordion(); - await processEditorPage.waitForNavigateToPolicyButtonIsVisible(); - await processEditorPage.clickOnNavigateToPolicyEditorButton(); + await processEditorPage.policyConfig.clickOnPolicyAccordion(); + await processEditorPage.policyConfig.waitForNavigateToPolicyButtonIsVisible(); + await processEditorPage.policyConfig.clickOnNavigateToPolicyEditorButton(); - await processEditorPage.verifyThatPolicyEditorIsOpen(); - await processEditorPage.closePolicyEditor(); - await processEditorPage.verifyThatPolicyEditorIsClosed(); + await processEditorPage.policyConfig.verifyThatPolicyEditorIsOpen(); + await processEditorPage.policyConfig.closePolicyEditor(); + await processEditorPage.policyConfig.verifyThatPolicyEditorIsClosed(); }); test('that the user can add a new data model, assign it to a task, and create a sequence between tasks.', async ({ @@ -202,10 +168,10 @@ test('that the user can edit the id of a task and add data-types to sign', async const newId: string = 'signing_id'; await editRandomGeneratedId(processEditorPage, randomGeneratedId, newId); - await processEditorPage.clickDataTypesToSignCombobox(); + await processEditorPage.signingTaskConfig.clickDataTypesToSignCombobox(); const dataTypeToSign: string = 'ref-data-as-pdf'; - await processEditorPage.clickOnDataTypesToSignOption(dataTypeToSign); - await processEditorPage.waitForDataTypeToSignButtonToBeVisible(dataTypeToSign); + await processEditorPage.signingTaskConfig.clickOnDataTypesToSignOption(dataTypeToSign); + await processEditorPage.signingTaskConfig.waitForDataTypeToSignButtonToBeVisible(dataTypeToSign); await processEditorPage.pressEscapeOnKeyboard(); await commitAndPushToGitea(header); @@ -235,21 +201,21 @@ test('That it is possible to create a custom receipt', async ({ page, testAppNam await processEditorPage.clickOnTaskInBpmnEditor(endEvent); await processEditorPage.waitForEndEventHeaderToBeVisible(); - await processEditorPage.clickOnReceiptAccordion(); - await processEditorPage.waitForCreateCustomReceiptButtonToBeVisible(); + await processEditorPage.customReceiptConfig.clickOnReceiptAccordion(); + await processEditorPage.customReceiptConfig.waitForCreateCustomReceiptButtonToBeVisible(); - await processEditorPage.clickOnCreateCustomReceipt(); - await processEditorPage.waitForLayoutTextfieldToBeVisible(); + await processEditorPage.customReceiptConfig.clickOnCreateCustomReceipt(); + await processEditorPage.customReceiptConfig.waitForLayoutTextfieldToBeVisible(); const newLayoutSetId: string = 'layoutSetId'; - await processEditorPage.writeLayoutSetId(newLayoutSetId); + await processEditorPage.customReceiptConfig.writeLayoutSetId(newLayoutSetId); await processEditorPage.dataModelConfig.clickOnAddDataModelCombobox(); await processEditorPage.dataModelConfig.chooseOption(newDataModel); await processEditorPage.pressEscapeOnKeyboard(); - await processEditorPage.waitForSaveNewCustomReceiptButtonToBeVisible(); - await processEditorPage.clickOnSaveNewCustomReceiptButton(); - await processEditorPage.waitForEditLayoutSetIdButtonToBeVisible(); + await processEditorPage.customReceiptConfig.waitForSaveNewCustomReceiptButtonToBeVisible(); + await processEditorPage.customReceiptConfig.clickOnSaveNewCustomReceiptButton(); + await processEditorPage.customReceiptConfig.waitForEditLayoutSetIdButtonToBeVisible(); // --------------------- Check that files are uploaded to Gitea --------------------- await goToGiteaAndNavigateToApplicationMetadataFile(header, giteaPage); @@ -292,7 +258,7 @@ const editRandomGeneratedId = async ( ): Promise => { await processEditorPage.clickOnTaskIdEditButton(randomGeneratedId); await processEditorPage.waitForEditIdInputFieldToBeVisible(); - await processEditorPage.emptyIdInputfield(); + await processEditorPage.emptyIdTextfield(); await processEditorPage.writeNewId(newId); await processEditorPage.waitForTextBoxToHaveValue(newId); await processEditorPage.saveNewId(); diff --git a/frontend/testing/playwright/tests/process-editor/task-actions.spec.ts b/frontend/testing/playwright/tests/process-editor/task-actions.spec.ts new file mode 100644 index 00000000000..6c4a298fd7c --- /dev/null +++ b/frontend/testing/playwright/tests/process-editor/task-actions.spec.ts @@ -0,0 +1,111 @@ +import { test } from '../../extenders/testExtend'; +import { ProcessEditorPage } from '../../pages/ProcessEditorPage'; +import { BpmnJSQuery } from '@studio/testing/playwright/helpers/BpmnJSQuery'; +import { expect, type Page } from '@playwright/test'; +import { Header } from '@studio/testing/playwright/components/Header'; +import { DesignerApi } from '@studio/testing/playwright/helpers/DesignerApi'; +import type { StorageState } from '@studio/testing/playwright/types/StorageState'; +import { Gitea } from '@studio/testing/playwright/helpers/Gitea'; +import { GiteaPage } from '@studio/testing/playwright/pages/GiteaPage'; + +test.describe.configure({ mode: 'serial' }); + +test.beforeAll(async ({ testAppName, request, storageState }) => { + const designerApi = new DesignerApi({ app: testAppName }); + const response = await designerApi.createApp(request, storageState as StorageState); + expect(response.ok()).toBeTruthy(); +}); + +test.afterAll(async ({ request, testAppName }) => { + const gitea = new Gitea(); + const response = await request.delete(gitea.getDeleteAppEndpoint({ app: testAppName })); + expect(response.ok()).toBeTruthy(); +}); + +test('should be possible to add predefined actions to task', async ({ + page, + testAppName, +}): Promise => { + const giteaPage = new GiteaPage(page, { app: testAppName }); + const processEditorPage = await setupProcessEditorActionConfigPanel(page, testAppName); + + await processEditorPage.actionsConfig.clickAddActionsButton(); + await processEditorPage.actionsConfig.choosePredefinedAction('write'); + + await commitAndPushToGitea(page, testAppName); + await goToProcessFileInGitea(page, testAppName); + await giteaPage.verifyThatActionIsVisible('write'); +}); + +test('should be possible to add custom actions to task and set them as serverAction', async ({ + page, + testAppName, +}): Promise => { + const giteaPage = new GiteaPage(page, { app: testAppName }); + const processEditorPage = await setupProcessEditorActionConfigPanel(page, testAppName); + await processEditorPage.actionsConfig.clickAddActionsButton(); + await processEditorPage.actionsConfig.clickOnCustomActionTab(); + await processEditorPage.actionsConfig.writeCustomAction('myCustomAction'); + await processEditorPage.actionsConfig.makeCustomActionToServerAction(); + + await commitAndPushToGitea(page, testAppName); + await goToProcessFileInGitea(page, testAppName); + await giteaPage.verifyThatActionIsCustomServerAction('myCustomAction'); +}); + +test('should be possible to remove action from task', async ({ + page, + testAppName, +}): Promise => { + const giteaPage = new GiteaPage(page, { app: testAppName }); + const processEditorPage = await setupProcessEditorActionConfigPanel(page, testAppName); + + await processEditorPage.actionsConfig.editAction('write'); + + await processEditorPage.actionsConfig.deleteAction('write'); + await commitAndPushToGitea(page, testAppName); + + await goToProcessFileInGitea(page, testAppName); + await giteaPage.verifyThatActionIsHidden('write'); +}); + +// Methods below is helpers to make the test shorter and more readable. +const setupProcessEditorActionConfigPanel = async ( + page: Page, + testAppName: string, +): Promise => { + const bpmnJSQuery = new BpmnJSQuery(page); + const processEditorPage = new ProcessEditorPage(page, { app: testAppName }); + await processEditorPage.loadProcessEditorPage(); + await processEditorPage.verifyProcessEditorPage(); + + const task = await bpmnJSQuery.getTaskByIdAndType('Task_1', 'g'); + await processEditorPage.clickOnTaskInBpmnEditor(task); + + await processEditorPage.waitForInitialTaskHeaderToBeVisible(); + await processEditorPage.actionsConfig.clickOnActionsAccordion(); + await processEditorPage.actionsConfig.waitForAddActionsButtonToBeVisible(); + + return processEditorPage; +}; + +const commitAndPushToGitea = async (page: Page, testAppName: string): Promise => { + const header = new Header(page, { app: testAppName }); + await header.clickOnUploadLocalChangesButton(); + await header.clickOnValidateChanges(); + await header.checkThatUploadSuccessMessageIsVisible(); +}; + +const goToProcessFileInGitea = async (page: Page, testAppName: string) => { + const header = new Header(page, { app: testAppName }); + const giteaPage = new GiteaPage(page, { app: testAppName }); + + await header.clickOnThreeDotsMenu(); + await header.clickOnGoToGiteaRepository(); + + await giteaPage.verifyGiteaPage(); + await giteaPage.clickOnAppFilesButton(); + await giteaPage.clickOnConfigFilesButton(); + await giteaPage.clickOnProcessFilesButton(); + await giteaPage.clickOnProcessBpmnFile(); +};