Skip to content

Commit

Permalink
create new modeler instance always, but only call getModeler once in …
Browse files Browse the repository at this point in the history
…useBpmnEditor hook
  • Loading branch information
standeren committed Apr 19, 2024
1 parent 43a6c92 commit c4e3e1c
Show file tree
Hide file tree
Showing 5 changed files with 164 additions and 91 deletions.
78 changes: 57 additions & 21 deletions frontend/packages/process-editor/src/hooks/useBpmnEditor.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,26 @@ import { getBpmnEditorDetailsFromBusinessObject } from '../utils/hookUtils';
import type { BpmnDetails } from '../types/BpmnDetails';
import { BpmnTypeEnum } from '../enum/BpmnTypeEnum';
import type { BpmnTaskType } from '../types/BpmnTaskType';
import type { LayoutSets } from 'app-shared/types/api/LayoutSetsResponse';

const taskId = 'testId';
const layoutSetId = 'someLayoutSetId';
const bpmnDetailsMock: BpmnDetails = {
id: 'testId',
id: taskId,
name: 'mockName',
type: BpmnTypeEnum.Task,
taskType: 'data',
};

const layoutSetsMock: LayoutSets = {
sets: [
{
id: layoutSetId,
tasks: [taskId],
},
],
};

class BpmnModelerMockImpl {
public readonly _currentEventName: string;
private readonly eventBus: any;
Expand All @@ -40,12 +52,7 @@ class BpmnModelerMockImpl {
}

jest.mock('../utils/hookUtils', () => ({
getBpmnEditorDetailsFromBusinessObject: jest.fn().mockReturnValue({
id: 'testId',
name: 'mockName',
type: 'bpmn:Task',
taskType: 'data',
}),
getBpmnEditorDetailsFromBusinessObject: jest.fn().mockReturnValue({}),
}));

jest.mock('../contexts/BpmnConfigPanelContext', () => ({
Expand Down Expand Up @@ -74,9 +81,7 @@ const overrideUseBpmnContext = () => {
};

jest.mock('./useBpmnModeler', () => ({
useBpmnModeler: jest.fn().mockReturnValue({
getModeler: jest.fn(),
}),
useBpmnModeler: jest.fn().mockReturnValue({}),
}));

const overrideUseBpmnModeler = (currentEventName: string) => {
Expand All @@ -85,12 +90,17 @@ const overrideUseBpmnModeler = (currentEventName: string) => {
});
};

const overrideGetBpmnEditorDetailsFromBusinessObject = (bpmnDetails: BpmnDetails) => {
(getBpmnEditorDetailsFromBusinessObject as jest.Mock).mockReturnValue(bpmnDetails);
};

const wrapper = ({ children }) => (
<BpmnContextProvider appLibVersion={'8.0.0'}>
<BpmnApiContextProvider
addLayoutSet={addLayoutSetMock}
deleteLayoutSet={deleteLayoutSetMock}
saveBpmn={saveBpmnMock}
layoutSets={layoutSetsMock}
>
{children}
</BpmnApiContextProvider>
Expand Down Expand Up @@ -129,35 +139,56 @@ describe('useBpmnEditor', () => {
it.each(['confirmation', 'signing', 'feedback', 'endEvent'])(
'should not call saveBpmn when "shape.add" event is triggered on modelerInstance when taskType is not data',
(taskType: BpmnTaskType) => {
const mockBpmnDetailsConfirm: BpmnDetails = {
const mockBpmnDetailsNotData: BpmnDetails = {
id: 'otherTestId',
name: 'mockName',
type: BpmnTypeEnum.Task,
taskType: taskType,
};
(getBpmnEditorDetailsFromBusinessObject as jest.Mock).mockReturnValue(mockBpmnDetailsConfirm);
const currentEventName = 'shape.add';
renderUseBpmnEditor(true, currentEventName);
renderUseBpmnEditor(true, currentEventName, mockBpmnDetailsNotData);

expect(addLayoutSetMock).not.toHaveBeenCalled();
expect(setBpmnDetailsMock).toHaveBeenCalledTimes(1);
expect(setBpmnDetailsMock).toHaveBeenCalledWith(mockBpmnDetailsConfirm);
expect(setBpmnDetailsMock).toHaveBeenCalledWith(mockBpmnDetailsNotData);
},
);

it.each(['data', 'confirmation', 'signing', 'feedback', 'endEvent'])(
'should call deleteLayoutSet when "shape.remove" event is triggered on modelerInstance and the task has a connected layout set',
(taskType: BpmnTaskType) => {
const mockBpmnDetailsWithConnectedLayoutSet: BpmnDetails = {
id: taskId,
name: 'mockName',
type: BpmnTypeEnum.Task,
taskType: taskType,
};
const currentEventName = 'shape.remove';
renderUseBpmnEditor(true, currentEventName, mockBpmnDetailsWithConnectedLayoutSet);

expect(deleteLayoutSetMock).toHaveBeenCalledTimes(1);
expect(deleteLayoutSetMock).toHaveBeenCalledWith({ layoutSetIdToUpdate: layoutSetId });
expect(setBpmnDetailsMock).toHaveBeenCalled();
expect(setBpmnDetailsMock).toHaveBeenCalledWith(null);
},
);

it('should call deleteLayoutSet when "shape.remove" event is triggered on modelerInstance', () => {
(getBpmnEditorDetailsFromBusinessObject as jest.Mock).mockReturnValue(bpmnDetailsMock);
it('should not call deleteLayoutSet when "shape.remove" event is triggered on modelerInstance and deleted task has no layoutSet', () => {
const mockBpmnDetailsWithoutConnectedLayoutSet: BpmnDetails = {
id: 'TaskIdNotPresetInLayoutSets',
name: 'mockName',
type: BpmnTypeEnum.Task,
taskType: 'data',
};
const currentEventName = 'shape.remove';
renderUseBpmnEditor(true, currentEventName);
renderUseBpmnEditor(true, currentEventName, mockBpmnDetailsWithoutConnectedLayoutSet);

expect(deleteLayoutSetMock).toHaveBeenCalledTimes(1);
expect(deleteLayoutSetMock).toHaveBeenCalledWith({ layoutSetIdToUpdate: bpmnDetailsMock.id });
expect(deleteLayoutSetMock).not.toHaveBeenCalled();
expect(setBpmnDetailsMock).toHaveBeenCalled();
expect(setBpmnDetailsMock).toHaveBeenCalledWith(null);
});

it('should call setBpmnDetails when "element.click" event is triggered on eventBus', () => {
(getBpmnEditorDetailsFromBusinessObject as jest.Mock).mockReturnValue(bpmnDetailsMock);
const currentEventName = 'element.click';
renderUseBpmnEditor(true, currentEventName);

Expand All @@ -169,8 +200,13 @@ describe('useBpmnEditor', () => {
});
});

const renderUseBpmnEditor = (overrideBpmnContext: boolean, currentEventName: string) => {
const renderUseBpmnEditor = (
overrideBpmnContext: boolean,
currentEventName: string,
bpmnDetails = bpmnDetailsMock,
) => {
overrideBpmnContext && overrideUseBpmnContext();
overrideGetBpmnEditorDetailsFromBusinessObject(bpmnDetails);
overrideUseBpmnModeler(currentEventName);
renderHook(() => useBpmnEditor(), { wrapper });
};
112 changes: 55 additions & 57 deletions frontend/packages/process-editor/src/hooks/useBpmnEditor.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import type { MutableRefObject } from 'react';
import { useEffect, useRef } from 'react';
import { useCallback, useEffect, useRef } from 'react';
import type BpmnModeler from 'bpmn-js/lib/Modeler';
import { useBpmnContext } from '../contexts/BpmnContext';
import { useBpmnModeler } from './useBpmnModeler';
import { getBpmnEditorDetailsFromBusinessObject } from '../utils/hookUtils';
import { useBpmnConfigPanelFormContext } from '../contexts/BpmnConfigPanelContext';
import { useBpmnApiContext } from '../contexts/BpmnApiContext';
import { BpmnTypeEnum } from '../enum/BpmnTypeEnum';
import { getLayoutSetIdFromTaskId } from '../utils/hookUtils/hookUtils';

// Wrapper around bpmn-js to Reactify it

Expand All @@ -20,23 +21,14 @@ export const useBpmnEditor = (): UseBpmnViewerResult => {
const canvasRef = useRef<HTMLDivElement | null>(null);
const { metaDataFormRef, resetForm } = useBpmnConfigPanelFormContext();
const { getModeler } = useBpmnModeler();
const { addLayoutSet, deleteLayoutSet, saveBpmn } = useBpmnApiContext();
const { addLayoutSet, deleteLayoutSet, saveBpmn, layoutSets } = useBpmnApiContext();
//let modelerRef: BpmnModeler | null = null;

const handleCommandStackChanged = async () => {
saveBpmn(await getUpdatedXml(), metaDataFormRef.current || null);
resetForm();
};

const handleShapeRemove = (e) => {
const bpmnDetails = getBpmnEditorDetailsFromBusinessObject(e?.element?.businessObject);
if (bpmnDetails.type === BpmnTypeEnum.Task) {
deleteLayoutSet({
layoutSetIdToUpdate: bpmnDetails.id,
});
}
setBpmnDetails(null);
};

const handleShapeAdd = (e) => {
const bpmnDetails = getBpmnEditorDetailsFromBusinessObject(e?.element?.businessObject);
setBpmnDetails(bpmnDetails);
Expand All @@ -48,62 +40,68 @@ export const useBpmnEditor = (): UseBpmnViewerResult => {
}
};

const handleSetBpmnDetails = (e) => {
const bpmnDetails = {
...getBpmnEditorDetailsFromBusinessObject(e.element?.businessObject),
element: e.element,
};
setBpmnDetails(bpmnDetails);
const handleShapeRemove = (e) => {
const bpmnDetails = getBpmnEditorDetailsFromBusinessObject(e?.element?.businessObject);
if (bpmnDetails.type === BpmnTypeEnum.Task) {
const layoutSetId = getLayoutSetIdFromTaskId(bpmnDetails, layoutSets);
if (layoutSetId) {
deleteLayoutSet({
layoutSetIdToUpdate: layoutSetId,
});
}
}
setBpmnDetails(null);
};

const handleSetBpmnDetails = useCallback(
(e) => {
const bpmnDetails = {
...getBpmnEditorDetailsFromBusinessObject(e.element?.businessObject),
element: e.element,
};
setBpmnDetails(bpmnDetails);
},
[setBpmnDetails],
);

const initializeEditor = async () => {
try {
await modelerRef.current.importXML(bpmnXml);
const canvas: any = modelerRef.current.get('canvas');
canvas.zoom('fit-viewport');
} catch (exception) {
console.log('An error occurred while rendering the viewer:', exception);
}
};

const initializeBpmnChanges = () => {
modelerRef.current.on('commandStack.changed', async () => {
await handleCommandStackChanged();
});
modelerRef.current.on('shape.add', (e) => {
handleShapeAdd(e);
});
modelerRef.current.on('shape.remove', (e) => {
handleShapeRemove(e);
});
};

useEffect(() => {
if (!canvasRef.current) {
console.log('Canvas reference is not yet available in the DOM.');
return;
}
const modelerInstance: BpmnModeler = getModeler(canvasRef.current);

// set modelerRef.current to the Context so that it can be used in other components
modelerRef.current = modelerInstance;

const initializeEditor = async () => {
try {
await modelerInstance.importXML(bpmnXml);
const canvas: any = modelerInstance.get('canvas');
canvas.zoom('fit-viewport');
} catch (exception) {
console.log('An error occurred while rendering the viewer:', exception);
}
};

// GetModeler can only be fetched from this hook once since the modeler creates a
// new instance and will attach the same canvasRef container to all instances it fetches
modelerRef.current = getModeler(canvasRef.current);
initializeEditor();
}, [modelerRef]); // Missing dependencies are not added due to resulting wierd behaviour in the process editor

useEffect(() => {
const initializeBpmnChanges = () => {
const modelerInstance = getModeler(canvasRef.current);

modelerInstance.on('commandStack.changed', async () => {
await handleCommandStackChanged();
});

modelerInstance.on('shape.add', (e) => {
handleShapeAdd(e);
});

modelerInstance.on('shape.remove', (e) => {
handleShapeRemove(e);
});
};

initializeBpmnChanges();
}, []); // Missing dependencies are not added due to resulting wierd behaviour in the process editor
// set modelerRef.current to the Context so that it can be used in other components
}, []); // Missing dependencies are not added to avoid getModeler to be called multiple times

Check warning on line 99 in frontend/packages/process-editor/src/hooks/useBpmnEditor.ts

View workflow job for this annotation

GitHub Actions / Typechecking and linting

React Hook useEffect has missing dependencies: 'getModeler', 'initializeBpmnChanges', 'initializeEditor', and 'modelerRef'. Either include them or remove the dependency array

useEffect(() => {
const modelerInstance: BpmnModeler = getModeler(canvasRef.current);
const eventBus: BpmnModeler = modelerInstance.get('eventBus');
const eventBus: BpmnModeler = modelerRef.current.get('eventBus');
eventBus.on('element.click', handleSetBpmnDetails);
}, [getModeler, handleSetBpmnDetails]);
}, [modelerRef, handleSetBpmnDetails]);

return { canvasRef, modelerRef };
};
25 changes: 12 additions & 13 deletions frontend/packages/process-editor/src/utils/StudioBpmnModeler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,20 +6,19 @@ import { altinnCustomTasks } from '../extensions/altinnCustomTasks';
export class StudioBpmnModeler {
private static instance: BpmnModeler | null = null;

// Singleton pattern to ensure only one instance of the StudioBpmnModeler is created
// Need to create a new modelerInstance in order to get the updated
// canvasContainer when switching between tabs in Studio
public static getInstance(canvasContainer: HTMLDivElement): BpmnModeler {
if (!StudioBpmnModeler.instance) {
StudioBpmnModeler.instance = new BpmnModeler({
container: canvasContainer,
keyboard: {
bindTo: document,
},
additionalModules: [SupportedPaletteProvider, SupportedContextPadProvider],
moddleExtensions: {
altinn: altinnCustomTasks,
},
});
}
StudioBpmnModeler.instance = new BpmnModeler({
container: canvasContainer,
keyboard: {
bindTo: document,
},
additionalModules: [SupportedPaletteProvider, SupportedContextPadProvider],
moddleExtensions: {
altinn: altinnCustomTasks,
},
});
return StudioBpmnModeler.instance;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { BpmnTaskType } from '../../types/BpmnTaskType';
import {
getBpmnEditorDetailsFromBusinessObject,
getBpmnViewerDetailsFromBusinessObject,
getLayoutSetIdFromTaskId,
} from './hookUtils';

describe('hookUtils', () => {
Expand Down Expand Up @@ -109,4 +110,37 @@ describe('hookUtils', () => {
expect(bpmnDetails.taskType).toBeNull();
});
});

const mockTaskId = 'mockId';
const bpmnDetailsMock: BpmnDetails = {
id: mockTaskId,
name: 'mockName',
type: BpmnTypeEnum.Task,
taskType: 'data',
};

const layoutSets = {
sets: [
{ id: 'layoutSet1', tasks: ['task1'] },
{ id: 'layoutSet2', tasks: ['task2'] },
],
};

describe('getLayoutSetIdFromTaskId', () => {
it('should return the layout set id corresponding to the task id', () => {
const result = getLayoutSetIdFromTaskId({ ...bpmnDetailsMock, id: 'task1' }, layoutSets);
expect(result).toBe('layoutSet1');
});

it('should return undefined if task id does not exist in any layout set', () => {
const result = getLayoutSetIdFromTaskId(bpmnDetailsMock, layoutSets);
expect(result).toBeUndefined();
});

it('should return undefined if layout sets are empty', () => {
const layoutSets = { sets: [] };
const result = getLayoutSetIdFromTaskId(bpmnDetailsMock, layoutSets);
expect(result).toBeUndefined();
});
});
});
Loading

0 comments on commit c4e3e1c

Please sign in to comment.