From 6748b9b4a556efb5bd83e6d35d2432092fdca01a Mon Sep 17 00:00:00 2001 From: Janis Joderi Shoferi Date: Mon, 1 Jul 2024 22:49:31 +0200 Subject: [PATCH 01/14] Added e2e tests for the documentation page --- .../app/shared-viewer/documentation-page.tsx | 3 +- .../documentation-page.spec.ts | 512 ++++++++++++++++++ tests/ms2/processes/fixtures/import1.bpmn | 111 ++++ tests/ms2/processes/fixtures/import2.bpmn | 65 +++ tests/ms2/processes/fixtures/importer.bpmn | 53 ++ tests/ms2/processes/process-list.page.ts | 22 +- tests/ms2/processes/process-list.spec.ts | 2 + tests/ms2/testUtils/index.ts | 2 +- 8 files changed, 760 insertions(+), 10 deletions(-) create mode 100644 tests/ms2/processes/documentation-page/documentation-page.spec.ts create mode 100644 tests/ms2/processes/fixtures/import1.bpmn create mode 100644 tests/ms2/processes/fixtures/import2.bpmn create mode 100644 tests/ms2/processes/fixtures/importer.bpmn diff --git a/src/management-system-v2/app/shared-viewer/documentation-page.tsx b/src/management-system-v2/app/shared-viewer/documentation-page.tsx index d0cf5756b..d65225355 100644 --- a/src/management-system-v2/app/shared-viewer/documentation-page.tsx +++ b/src/management-system-v2/app/shared-viewer/documentation-page.tsx @@ -120,6 +120,7 @@ const BPMNSharedViewer = ({ currentRootId?: string, // the layer the current element is in (e.g. the root process/collaboration or a collapsed sub-process) ): Promise { let svg; + const name = getTitle(el); let nestedSubprocess; let importedProcess; @@ -168,7 +169,7 @@ const BPMNSharedViewer = ({ return { svg, id: el.id, - name: getTitle(el), + name, description, meta, milestones, diff --git a/tests/ms2/processes/documentation-page/documentation-page.spec.ts b/tests/ms2/processes/documentation-page/documentation-page.spec.ts new file mode 100644 index 000000000..cd0761675 --- /dev/null +++ b/tests/ms2/processes/documentation-page/documentation-page.spec.ts @@ -0,0 +1,512 @@ +import { Browser, Page, chromium, firefox } from '@playwright/test'; +import { test, expect } from '../processes.fixtures'; +import { closeModal, openModal } from '../../testUtils'; + +test('documentation page functionality', async ({ page, ms2Page, processListPage }) => { + /*********************** Setup **************************/ + + // import the first process imported by the importer process and create a version to reference + const { definitionId: import1Id } = await processListPage.importProcess('import1.bpmn'); + await page.locator(`tr[data-row-key="${import1Id}"]`).dblclick(); + await page.waitForURL(/processes\/[a-z0-9-_]+/); + let versionModal = await openModal(page, () => + page.getByLabel('general-modeler-toolbar').getByRole('button', { name: 'plus' }).click(), + ); + await versionModal.getByPlaceholder('Version Name').fill('Version 1'); + await versionModal.getByPlaceholder('Version Description').fill('First Version'); + await closeModal(versionModal, () => + versionModal.getByRole('button', { name: 'Create Version' }).click(), + ); + await page.getByText('Latest Version').click(); + await page.getByText('Version 1').click(); + await page.waitForURL(/\?version=/); + const import1Version = page.url().split('?version=').pop(); + + await processListPage.goto(); + // import the second process imported by the importer process and create a version to reference + const { definitionId: import2Id } = await processListPage.importProcess('import2.bpmn'); + await page.locator(`tr[data-row-key="${import2Id}"]`).dblclick(); + await page.waitForURL(/processes\/[a-z0-9-_]+/); + versionModal = await openModal(page, () => + page.getByLabel('general-modeler-toolbar').getByRole('button', { name: 'plus' }).click(), + ); + await versionModal.getByPlaceholder('Version Name').fill('Version 2'); + await versionModal.getByPlaceholder('Version Description').fill('Second Version'); + await closeModal(versionModal, () => + versionModal.getByRole('button', { name: 'Create Version' }).click(), + ); + await page.getByText('Latest Version').click(); + await page.getByText('Version 2').click(); + await page.waitForURL(/\?version=/); + const import2Version = page.url().split('?version=').pop(); + + // share this process so it is visible in the documentation for other users + const shareModal = await openModal(page, () => + page.getByRole('button', { name: 'share-alt' }).click(), + ); + await shareModal.getByText('Share Process with Public Link').click(); + await page + .locator('.ant-message') + .filter({ hasText: 'Process shared' }) + .waitFor({ state: 'visible' }); + + await processListPage.goto(); + // import the process that imports the other two and set the correct versions in its bpmn + const { definitionId: importerId } = await processListPage.importProcess( + 'importer.bpmn', + undefined, + async (bpmn) => { + bpmn = bpmn.replace(/insert-import1-version-here/g, import1Version); + bpmn = bpmn.replace(/insert-import1-definitionId-here/g, import1Id); + bpmn = bpmn.replace(/insert-import2-version-here/g, import2Version); + bpmn = bpmn.replace(/insert-import2-definitionId-here/g, import2Id); + return bpmn; + }, + ); + await page.locator(`tr[data-row-key="${importerId}"]`).dblclick(); + await page.waitForURL(/processes\/[a-z0-9-_]+/); + + /************************* Testing as the user owning the process ***************************/ + + // go to the documentation page + const documentationPagePromise = page.waitForEvent('popup'); + await page.getByRole('button', { name: 'file-pdf' }).click(); + const documentationPage = await documentationPagePromise; + + // check if the process on the page is the correct one + await expect(documentationPage.getByRole('heading', { name: 'Importer' })).toBeVisible(); + const infoSection = documentationPage.locator('css=[class^=process-document_TitleInfos]'); + await expect(infoSection).toBeVisible(); + await expect(infoSection.getByText('Owner:')).toBeVisible(); + await expect(infoSection.getByText('Version: Latest')).toBeVisible(); + await expect(infoSection.getByText(/^Last Edit: .+$/)).toBeVisible(); + + let elementSections = await documentationPage + .locator('css=[class^=process-document_ElementPage]') + .all(); + + // check that the elements that should be visible are visible + expect(elementSections.length).toBe(5); + + // check if the process overview is shown and the bpmn is correct + const processOverview = elementSections[0]; + expect(processOverview.getByText('Process Diagram')).toBeVisible(); + await expect( + processOverview.locator('.djs-shape[data-element-id="StartEvent_060jvsw"]'), + ).toBeVisible(); + await expect( + processOverview.locator('.djs-connection[data-element-id="Flow_11v1suu"]'), + ).toBeVisible(); + await expect( + processOverview.locator('.djs-shape[data-element-id="Activity_0ehc3tb"]'), + ).toBeVisible(); + await expect( + processOverview.locator('.djs-connection[data-element-id="Flow_11dnio8"]'), + ).toBeVisible(); + await expect( + processOverview.locator('.djs-shape[data-element-id="Activity_0ahspz3"]'), + ).toBeVisible(); + await expect( + processOverview.locator('.djs-connection[data-element-id="Flow_0aa5vf1"]'), + ).toBeVisible(); + await expect( + processOverview.locator('.djs-shape[data-element-id="Event_05hheu3"]'), + ).toBeVisible(); + + // check if the meta data of the process is shown + let metaInformation = processOverview.locator('css=[class^=process-document_MetaInformation]'); + expect(metaInformation.getByText('General Description')).toBeVisible(); + expect(metaInformation.getByText('A process importing two other processes')).toBeVisible(); + + // check if the overview of the first import is shown and the bpmn is correct + const import1Overview = elementSections[1]; + expect(import1Overview.getByText('Imported Process: Import 1')).toBeVisible(); + await expect( + import1Overview.locator('.djs-shape[data-element-id="StartEvent_0lu383t"]'), + ).toBeVisible(); + await expect( + import1Overview.locator('.djs-connection[data-element-id="Flow_0khcvxi"]'), + ).toBeVisible(); + await expect( + import1Overview.locator('.djs-shape[data-element-id="Activity_1qnnqlx"]'), + ).toBeVisible(); + await expect( + import1Overview.locator('.djs-connection[data-element-id="Flow_11ramgm"]'), + ).toBeVisible(); + await expect( + import1Overview.locator('.djs-shape[data-element-id="Activity_0h021fd"]'), + ).toBeVisible(); + await expect( + import1Overview.locator('.djs-connection[data-element-id="Flow_07y98js"]'), + ).toBeVisible(); + await expect( + import1Overview.locator('.djs-shape[data-element-id="Event_1dwf3dy"]'), + ).toBeVisible(); + + // check if the meta data of the import 1 process is shown + metaInformation = import1Overview.locator('css=[class^=process-document_MetaInformation]'); + await expect(metaInformation.getByText('General Description')).toBeHidden(); + await expect(metaInformation.getByText('Version Information')).toBeVisible(); + await expect(metaInformation.getByText('Version: Version 1')).toBeVisible(); + await expect(metaInformation.getByText('Version Description: First Version')).toBeVisible(); + await expect(metaInformation.getByText(/^Creation Time: .+$/)).toBeVisible(); + + // check if the overview of the subprocess in the first import is shown and the bpmn is correct + const import1SubprocessOverview = elementSections[2]; + expect(import1SubprocessOverview.getByText('Subprocess: A')).toBeVisible(); + await expect( + import1SubprocessOverview.locator('.djs-shape[data-element-id="Event_0dadznn"]'), + ).toBeVisible(); + await expect( + import1SubprocessOverview.locator('.djs-connection[data-element-id="Flow_1uie0do"]'), + ).toBeVisible(); + await expect( + import1SubprocessOverview.locator('.djs-shape[data-element-id="Activity_1vyrvs7"]'), + ).toBeVisible(); + await expect( + import1SubprocessOverview.locator('.djs-connection[data-element-id="Flow_16djkfc"]'), + ).toBeVisible(); + await expect( + import1SubprocessOverview.locator('.djs-shape[data-element-id="Activity_0d80j9z"]'), + ).toBeVisible(); + await expect( + import1SubprocessOverview.locator('.djs-connection[data-element-id="Flow_1olixk3"]'), + ).toBeVisible(); + await expect( + import1SubprocessOverview.locator('.djs-shape[data-element-id="Event_1ney7ih"]'), + ).toBeVisible(); + + // check that there is no meta data for the subprocess + await expect( + import1SubprocessOverview.locator('css=[class^=process-document_MetaInformation]'), + ).toBeHidden(); + + // check milestones and meta data on an element in the subprocess + const subprocessMilestoneTask = elementSections[3]; + await expect(subprocessMilestoneTask.getByRole('heading', { name: 'A.A' })).toBeVisible(); + // the elements of the subprocess that contains the task which are not related to the task should not be visible + await expect( + subprocessMilestoneTask + .locator('.djs-shape[data-element-id="Event_0dadznn"]') + .getAttribute('style'), + ).resolves.toMatch(/display: none/); + await expect( + subprocessMilestoneTask + .locator('.djs-shape[data-element-id="Activity_0d80j9z"]') + .getAttribute('style'), + ).resolves.toMatch(/display: none/); + await expect( + subprocessMilestoneTask + .locator('.djs-connection[data-element-id="Flow_1olixk3"]') + .getAttribute('style'), + ).resolves.toMatch(/display: none/); + await expect( + subprocessMilestoneTask + .locator('.djs-shape[data-element-id="Event_1ney7ih"]') + .getAttribute('style'), + ).resolves.toMatch(/display: none/); + // instead the task and its incoming and outgoing sequence flows should be visible + await expect( + subprocessMilestoneTask + .locator('.djs-connection[data-element-id="Flow_1uie0do"]') + .getAttribute('style'), + ).resolves.not.toMatch(/display: none/); + await expect( + subprocessMilestoneTask + .locator('.djs-shape[data-element-id="Activity_1vyrvs7"]') + .getAttribute('style'), + ).resolves.not.toMatch(/display: none/); + await expect( + subprocessMilestoneTask + .locator('.djs-connection[data-element-id="Flow_16djkfc"]') + .getAttribute('style'), + ).resolves.not.toMatch(/display: none/); + + // check if the meta data of the import 1 process is shown + metaInformation = subprocessMilestoneTask.locator( + 'css=[class^=process-document_MetaInformation]', + ); + await expect(metaInformation.getByText('General Description')).toBeHidden(); + await expect(metaInformation.getByText('Version Information')).toBeHidden(); + + await expect(metaInformation.getByText('Meta Data')).toBeVisible(); + await expect(metaInformation.getByRole('row', { name: 'costsPlanned' })).toBeVisible(); + await expect( + metaInformation + .getByRole('row', { name: 'costsPlanned' }) + .getByRole('cell', { name: '123,00 €' }), + ).toBeVisible(); + await expect(metaInformation.getByRole('row', { name: 'prop1' })).toBeVisible(); + await expect( + metaInformation.getByRole('row', { name: 'prop1' }).getByRole('cell', { name: 'test123' }), + ).toBeVisible(); + + await expect(metaInformation.getByText('Milestones')).toBeVisible(); + await expect(metaInformation.getByRole('row', { name: 'MS-1' })).toBeVisible(); + await expect( + metaInformation.getByRole('row', { name: 'MS-1' }).getByRole('cell', { name: 'Milestone 1' }), + ).toBeVisible(); + await expect( + metaInformation + .getByRole('row', { name: 'MS-1' }) + .getByRole('cell', { name: 'First Milestone' }), + ).toBeVisible(); + + // check if the overview of the first import is shown and the bpmn is correct + const import2Overview = elementSections[4]; + expect(import2Overview.getByText('Imported Process: Import 2')).toBeVisible(); + await expect( + import2Overview.locator('.djs-shape[data-element-id="StartEvent_11c5e5n"]'), + ).toBeVisible(); + await expect( + import2Overview.locator('.djs-connection[data-element-id="Flow_14wofxg"]'), + ).toBeVisible(); + await expect( + import2Overview.locator('.djs-shape[data-element-id="Activity_0car07j"]'), + ).toBeVisible(); + await expect( + import2Overview.locator('.djs-connection[data-element-id="Flow_1rm9mx3"]'), + ).toBeVisible(); + await expect( + import2Overview.locator('.djs-shape[data-element-id="Activity_013wagm"]'), + ).toBeVisible(); + await expect( + import2Overview.locator('.djs-connection[data-element-id="Flow_1j401tl"]'), + ).toBeVisible(); + await expect( + import2Overview.locator('.djs-shape[data-element-id="Activity_1r1rpgl"]'), + ).toBeVisible(); + await expect( + import2Overview.locator('.djs-connection[data-element-id="Flow_0s9fl5z"]'), + ).toBeVisible(); + await expect( + import2Overview.locator('.djs-shape[data-element-id="Event_0gy99s6"]'), + ).toBeVisible(); + + // check if the meta data of the import 1 process is shown + metaInformation = import2Overview.locator('css=[class^=process-document_MetaInformation]'); + await expect(metaInformation.getByText('General Description')).toBeHidden(); + await expect(metaInformation.getByText('Version Information')).toBeVisible(); + await expect(metaInformation.getByText('Version: Version 2')).toBeVisible(); + await expect(metaInformation.getByText('Version Description: Second Version')).toBeVisible(); + await expect(metaInformation.getByText(/^Creation Time: .+$/)).toBeVisible(); + + /******* Test Page Options ********/ + + // check that the visualisation of a subprocess can be set to show the subprocess element instead of the nested subprocess + // which also removes all contained elements from the page + let settingsModal = await openModal(documentationPage, () => + documentationPage.getByRole('button', { name: 'setting' }).click(), + ); + await settingsModal.getByLabel('Nested Subprocesses').uncheck(); + await closeModal(settingsModal, () => settingsModal.getByRole('button', { name: 'OK' }).click()); + + elementSections = await documentationPage + .locator('css=[class^=process-document_ElementPage]') + .all(); + + expect(elementSections.length).toBe(4); + + // check if the bpmn shown for the (collapsed) subprocess is the subprocess element instead of the subrocess content + const import1SubprocessElementView = elementSections[2]; + expect(import1SubprocessElementView.getByText('Subprocess: A')).toBeVisible(); + + // the start event inside the subprocess should not be visible + await expect( + import1SubprocessElementView.locator('.djs-shape[data-element-id="Event_0dadznn"]'), + ).not.toBeVisible(); + // the elements of the imported process that contains the subprocess which are not related to the subprocess should not be visible + await expect( + import1SubprocessElementView + .locator('.djs-shape[data-element-id="StartEvent_0lu383t"]') + .getAttribute('style'), + ).resolves.toMatch(/display: none/); + await expect( + import1SubprocessElementView + .locator('.djs-shape[data-element-id="Activity_0h021fd"]') + .getAttribute('style'), + ).resolves.toMatch(/display: none/); + await expect( + import1SubprocessElementView + .locator('.djs-connection[data-element-id="Flow_07y98js"]') + .getAttribute('style'), + ).resolves.toMatch(/display: none/); + await expect( + import1SubprocessElementView + .locator('.djs-shape[data-element-id="Event_1dwf3dy"]') + .getAttribute('style'), + ).resolves.toMatch(/display: none/); + // instead the subprocess element and its incoming and outgoing sequence flow should be visible + await expect( + import1SubprocessElementView + .locator('.djs-connection[data-element-id="Flow_0khcvxi"]') + .getAttribute('style'), + ).resolves.not.toMatch(/display: none/); + await expect( + import1SubprocessElementView + .locator('.djs-shape[data-element-id="Activity_1qnnqlx"]') + .getAttribute('style'), + ).resolves.not.toMatch(/display: none/); + await expect( + import1SubprocessElementView + .locator('.djs-connection[data-element-id="Flow_11ramgm"]') + .getAttribute('style'), + ).resolves.not.toMatch(/display: none/); + + // check that the visualisation of a call activity can be set to show the call activity element instead of the imported process + // which also removes all contained elements from the page + settingsModal = await openModal(documentationPage, () => + documentationPage.getByRole('button', { name: 'setting' }).click(), + ); + await settingsModal.getByLabel('Imported Processes').uncheck(); + await closeModal(settingsModal, () => settingsModal.getByRole('button', { name: 'OK' }).click()); + + elementSections = await documentationPage + .locator('css=[class^=process-document_ElementPage]') + .all(); + + expect(elementSections.length).toBe(3); + + // check if the bpmn shown for the call activities is the call activity elements instead of the imported process + const callActivity1View = elementSections[1]; + expect(callActivity1View.getByText('Call Activity: Import 1')).toBeVisible(); + + // the start event of the imported process should not be visible + await expect( + callActivity1View.locator('.djs-shape[data-element-id="StartEvent_0lu383t"]'), + ).not.toBeVisible(); + // the elements of the main process that are not related to the call activity element should not be visible + await expect( + callActivity1View + .locator('.djs-shape[data-element-id="StartEvent_060jvsw"]') + .getAttribute('style'), + ).resolves.toMatch(/display: none/); + await expect( + callActivity1View + .locator('.djs-shape[data-element-id="Activity_0ahspz3"]') + .getAttribute('style'), + ).resolves.toMatch(/display: none/); + await expect( + callActivity1View + .locator('.djs-connection[data-element-id="Flow_0aa5vf1"]') + .getAttribute('style'), + ).resolves.toMatch(/display: none/); + await expect( + callActivity1View.locator('.djs-shape[data-element-id="Event_05hheu3"]').getAttribute('style'), + ).resolves.toMatch(/display: none/); + // instead the subprocess element and its incoming and outgoing sequence flow should be visible + await expect( + callActivity1View + .locator('.djs-connection[data-element-id="Flow_11v1suu"]') + .getAttribute('style'), + ).resolves.not.toMatch(/display: none/); + await expect( + callActivity1View + .locator('.djs-shape[data-element-id="Activity_0ehc3tb"]') + .getAttribute('style'), + ).resolves.not.toMatch(/display: none/); + await expect( + callActivity1View + .locator('.djs-connection[data-element-id="Flow_11dnio8"]') + .getAttribute('style'), + ).resolves.not.toMatch(/display: none/); + + // check that the option to show elements that have no meta data and contain no other elements works as well (here in combination with the previously deselected options) + settingsModal = await openModal(documentationPage, () => + documentationPage.getByRole('button', { name: 'setting' }).click(), + ); + await settingsModal.getByLabel('Exclude Empty Elements').uncheck(); + + // prevent the tooltip of the unchecked checkbox from overlapping the confirmation button when we try to click it next + let tooltips = await documentationPage.getByRole('tooltip').all(); + await documentationPage.mouse.move(0, 0); + await Promise.all(tooltips.map((tooltip) => tooltip.waitFor({ state: 'hidden' }))); + + await closeModal(settingsModal, () => settingsModal.getByRole('button', { name: 'OK' }).click()); + + const elementSection = await documentationPage.locator( + 'css=[class^=process-document_ElementPage]', + ); + + expect(elementSection).toHaveCount(5); + // there should be sections for all the elements in the root process including the start and end event + // but there should not be sections for sequence flows + expect(elementSection.first().getByText('Process Diagram')).toBeVisible(); + expect(elementSection.getByText('')).toBeVisible(); + expect(elementSection.getByText('')).toBeVisible(); + expect(elementSection.getByText('Call Activity: Import 1')).toBeVisible(); + expect(elementSection.getByText('Call Activity: Import 2')).toBeVisible(); + + // check that the "Add to your workspace option is not shown to a user that already owns the process" + await expect( + documentationPage.getByRole('button', { name: 'Add to your workspace' }), + ).toBeHidden(); + + /************************* Testing as a guest user opening the share link ***************************/ + await openModal(page, () => page.getByRole('button', { name: 'share-alt' }).click()); + + // share process with link + await page.getByRole('button', { name: 'Share Public Link' }).click(); + await page.getByText('Share Process with Public Link').click(); + + await expect(page.locator('input[name="generated share link"]')).toBeEnabled(); + await expect(page.getByRole('button', { name: 'Copy link' })).toBeEnabled(); + await expect(page.getByRole('button', { name: 'Save QR Code' })).toBeEnabled(); + await expect(page.getByRole('button', { name: 'Copy QR Code' })).toBeEnabled(); + + await page.getByRole('button', { name: 'Copy link' }).click(); + + const clipboardData = await ms2Page.readClipboard(true); + + // Visit the shared link + const browser: Browser = await chromium.launch(); + const newPage: Page = await browser.newPage(); + + await newPage.goto(`${clipboardData}`); + await newPage.waitForURL(`${clipboardData}`); + + // check that the process imported by the "Import 1" Call-Activity is not visible since it is not owned by the user and also not shared + await expect(newPage.locator('css=[class^=process-document_ElementPage]')).toHaveCount(2); + await expect( + newPage + .locator('css=[class^=process-document_ElementPage]') + .first() + .getByText('Process Diagram'), + ).toBeVisible(); + // the process imported by the "Import 2" Call-Activity should be visible since it is shared + await expect( + newPage + .locator('css=[class^=process-document_ElementPage]') + .nth(1) + .getByText('Imported Process: Import 2'), + ).toBeVisible(); + await expect(newPage.getByText('Imported Process: Import 1')).not.toBeVisible(); + + // check that the add to workspace button is visible since the user does not own the process + await expect(newPage.getByRole('button', { name: 'Add to your workspace' })).toBeVisible(); + + // Add the shared process to the workspace + await newPage.getByRole('button', { name: 'Add to your workspace' }).click(); + await newPage.waitForURL(/signin\?callbackUrl=([^]+)/); + + await newPage.getByRole('button', { name: 'Continue as a Guest' }).click(); + await newPage.waitForURL(/shared-viewer\?token=([^]+)/); + + await newPage.getByRole('button', { name: 'My Space' }).click(); + await newPage.waitForURL(/processes\/[a-z0-9-_]+/); + + const newProcessId = newPage.url().split('/processes/').pop(); + + await newPage.getByRole('link', { name: 'process list' }).click(); + await newPage.waitForURL(/processes/); + await expect(newPage.locator(`tr[data-row-key="${newProcessId}"]`)).toBeVisible(); + + // cleanup the process added by the guest user + const deleteModal = await openModal(newPage, () => + newPage + .locator(`tr[data-row-key="${newProcessId}"]`) + .getByRole('button', { name: 'delete' }) + .click(), + ); + await closeModal(deleteModal, () => deleteModal.getByRole('button', { name: 'OK' }).click()); +}); diff --git a/tests/ms2/processes/fixtures/import1.bpmn b/tests/ms2/processes/fixtures/import1.bpmn new file mode 100644 index 000000000..8da7e69c8 --- /dev/null +++ b/tests/ms2/processes/fixtures/import1.bpmn @@ -0,0 +1,111 @@ + + + + + + Flow_0khcvxi + + + + + Flow_07y98js + + + + Flow_0khcvxi + Flow_11ramgm + + Flow_1uie0do + + + + + Flow_1olixk3 + + + + + + + MS-1 + Milestone 1 + First Milestone + + + + 123 + test123 + + + Flow_1uie0do + Flow_16djkfc + + + Flow_16djkfc + Flow_1olixk3 + + + + Flow_11ramgm + Flow_07y98js + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/ms2/processes/fixtures/import2.bpmn b/tests/ms2/processes/fixtures/import2.bpmn new file mode 100644 index 000000000..7be7f1d78 --- /dev/null +++ b/tests/ms2/processes/fixtures/import2.bpmn @@ -0,0 +1,65 @@ + + + + + Flow_14wofxg + + + + + Flow_14wofxg + Flow_1rm9mx3 + + + Flow_1rm9mx3 + Flow_1j401tl + + + + Flow_0s9fl5z + + + + Flow_1j401tl + Flow_0s9fl5z + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/ms2/processes/fixtures/importer.bpmn b/tests/ms2/processes/fixtures/importer.bpmn new file mode 100644 index 000000000..ace2bb3f3 --- /dev/null +++ b/tests/ms2/processes/fixtures/importer.bpmn @@ -0,0 +1,53 @@ + + + + + + A process importing two other processes + + Flow_11v1suu + + + + + Flow_0aa5vf1 + + + + Flow_11v1suu + Flow_11dnio8 + + + Flow_11dnio8 + Flow_0aa5vf1 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/tests/ms2/processes/process-list.page.ts b/tests/ms2/processes/process-list.page.ts index 9106866cb..a8c6f7d72 100644 --- a/tests/ms2/processes/process-list.page.ts +++ b/tests/ms2/processes/process-list.page.ts @@ -16,9 +16,11 @@ export class ProcessListPage { } async goto() { - await this.page.goto('/processes'); - await this.page.waitForURL('**/processes'); - await waitForHydration(this.page); + if (!this.page.url().endsWith('processes')) { + await this.page.goto('/processes'); + await this.page.waitForURL('**/processes'); + await waitForHydration(this.page); + } } /** @@ -28,13 +30,20 @@ export class ProcessListPage { * @param definitionId will be used to identify the process in the MS (two imports with the same id will clash) * @returns meta data about the imported process (id and name) */ - async importProcess(filename: string, definitionId = `_${v4()}`) { + async importProcess( + filename: string, + definitionId = `_${v4()}`, + transformBpmn?: (bpmn: string) => Promise, + ) { const { page } = this; const importFilePath = path.join(__dirname, 'fixtures', filename); let bpmn = fs.readFileSync(importFilePath, 'utf-8'); bpmn = (await setDefinitionsId(bpmn, definitionId)) as string; bpmn = (await setTargetNamespace(bpmn, definitionId)) as string; + + if (transformBpmn) bpmn = await transformBpmn(bpmn); + const { name } = await getDefinitionsInfos(bpmn); // import the test process @@ -160,10 +169,7 @@ export class ProcessListPage { const { page } = this; if (this.processDefinitionIds.length) { - if (!page.url().endsWith('processes')) { - await this.goto(); - await page.waitForURL('**/processes'); - } + await this.goto(); // make sure that the list is fully loaded otherwise clicking the select all checkbox will not work as expected await page.getByRole('columnheader', { name: 'Name' }).waitFor({ state: 'visible' }); diff --git a/tests/ms2/processes/process-list.spec.ts b/tests/ms2/processes/process-list.spec.ts index 40b45b5ca..9b69c1048 100644 --- a/tests/ms2/processes/process-list.spec.ts +++ b/tests/ms2/processes/process-list.spec.ts @@ -679,6 +679,8 @@ test.describe('shortcuts in process-list', () => { page.locator('tbody>tr[class="ant-table-placeholder"]'), 'Only the ant-design-placeholder row should be visible, if the list is empty', ).toBeVisible(); + + processListPage.getDefinitionIds().splice(0, 1); }); /* Select all Processes - ctrl / meta + a */ diff --git a/tests/ms2/testUtils/index.ts b/tests/ms2/testUtils/index.ts index a2c73429b..eda8a4fe8 100644 --- a/tests/ms2/testUtils/index.ts +++ b/tests/ms2/testUtils/index.ts @@ -92,7 +92,7 @@ export async function closeModal(modal: Locator, triggerFunction: () => Promise< */ export async function waitForHydration(page: Page) { // this button should be in the header on every page - const accountButton = await page.getByRole('link', { name: 'user' }); + const accountButton = await page.locator('header').locator('a[href="/profile"]'); // the menu that open when hovering over the accountButton only works after the page has been fully hydrated await accountButton.hover(); await page From 252d5839a2ec9edfa153e29cb00e87270234e8aa Mon Sep 17 00:00:00 2001 From: Janis Joderi Shoferi Date: Wed, 3 Jul 2024 15:51:12 +0200 Subject: [PATCH 02/14] Fixed: some tests don't clean up the created processes correctly --- tests/ms2/processes/process-list.page.ts | 2 ++ tests/ms2/processes/process-list.spec.ts | 10 ++++++++++ 2 files changed, 12 insertions(+) diff --git a/tests/ms2/processes/process-list.page.ts b/tests/ms2/processes/process-list.page.ts index a8c6f7d72..059653acb 100644 --- a/tests/ms2/processes/process-list.page.ts +++ b/tests/ms2/processes/process-list.page.ts @@ -162,6 +162,8 @@ export class ProcessListPage { await waitForHydration(this.page); } + this.processDefinitionIds.push(id); + return id; } diff --git a/tests/ms2/processes/process-list.spec.ts b/tests/ms2/processes/process-list.spec.ts index 9b69c1048..136e83aec 100644 --- a/tests/ms2/processes/process-list.spec.ts +++ b/tests/ms2/processes/process-list.spec.ts @@ -652,6 +652,9 @@ test.describe('shortcuts in process-list', () => { page.locator(`tr[data-row-key="${processID}"]`), 'Couldnot find newly added process in list', ).toBeVisible(); + + // clean up the process + await processListPage.removeProcess(processID); }); /* Delete Process - del*/ test('delete a process with del', async ({ processListPage }) => { @@ -769,6 +772,10 @@ test.describe('shortcuts in process-list', () => { /* Check if both AAA are selected */ await expect(page.getByRole('note')).toContainText('2'); + + // clear the search bar for the process cleanup to get all processes (.fill('') and .clear() open the delete modal in chrome for some reason) + await inputSearch.getByPlaceholder(/search/i).focus(); + await inputSearch.getByPlaceholder(/search/i).press('Backspace'); }); /* Copy and Paste Processes - ctrl / meta + c -> ctrl / meta + v */ @@ -864,6 +871,9 @@ test.describe('shortcuts in process-list', () => { await expect(modalTitle, 'Could not ensure that the correct modal opened').toHaveText( /export/i, ); + + // close modal to allow cleanup to work as expected + await closeModal(modal, () => modal.getByRole('button', { name: 'Cancel' }).click()); }); /* TODO: */ From 4b3589d98e0852ea0680334ad51734d5a0e7c281 Mon Sep 17 00:00:00 2001 From: Janis Joderi Shoferi Date: Wed, 3 Jul 2024 15:54:23 +0200 Subject: [PATCH 03/14] Generalized version creation in the process modeler for tests and used that in the documentation page tests --- .../documentation-page.spec.ts | 33 +++++-------------- .../process-modeler/process-modeler.page.ts | 22 +++++++++++++ 2 files changed, 30 insertions(+), 25 deletions(-) diff --git a/tests/ms2/processes/documentation-page/documentation-page.spec.ts b/tests/ms2/processes/documentation-page/documentation-page.spec.ts index cd0761675..1ffeadbd9 100644 --- a/tests/ms2/processes/documentation-page/documentation-page.spec.ts +++ b/tests/ms2/processes/documentation-page/documentation-page.spec.ts @@ -2,43 +2,26 @@ import { Browser, Page, chromium, firefox } from '@playwright/test'; import { test, expect } from '../processes.fixtures'; import { closeModal, openModal } from '../../testUtils'; -test('documentation page functionality', async ({ page, ms2Page, processListPage }) => { +test('documentation page functionality', async ({ + page, + ms2Page, + processListPage, + processModelerPage, +}) => { /*********************** Setup **************************/ // import the first process imported by the importer process and create a version to reference const { definitionId: import1Id } = await processListPage.importProcess('import1.bpmn'); await page.locator(`tr[data-row-key="${import1Id}"]`).dblclick(); await page.waitForURL(/processes\/[a-z0-9-_]+/); - let versionModal = await openModal(page, () => - page.getByLabel('general-modeler-toolbar').getByRole('button', { name: 'plus' }).click(), - ); - await versionModal.getByPlaceholder('Version Name').fill('Version 1'); - await versionModal.getByPlaceholder('Version Description').fill('First Version'); - await closeModal(versionModal, () => - versionModal.getByRole('button', { name: 'Create Version' }).click(), - ); - await page.getByText('Latest Version').click(); - await page.getByText('Version 1').click(); - await page.waitForURL(/\?version=/); - const import1Version = page.url().split('?version=').pop(); + const import1Version = await processModelerPage.createVersion('Version 1', 'First Version'); await processListPage.goto(); // import the second process imported by the importer process and create a version to reference const { definitionId: import2Id } = await processListPage.importProcess('import2.bpmn'); await page.locator(`tr[data-row-key="${import2Id}"]`).dblclick(); await page.waitForURL(/processes\/[a-z0-9-_]+/); - versionModal = await openModal(page, () => - page.getByLabel('general-modeler-toolbar').getByRole('button', { name: 'plus' }).click(), - ); - await versionModal.getByPlaceholder('Version Name').fill('Version 2'); - await versionModal.getByPlaceholder('Version Description').fill('Second Version'); - await closeModal(versionModal, () => - versionModal.getByRole('button', { name: 'Create Version' }).click(), - ); - await page.getByText('Latest Version').click(); - await page.getByText('Version 2').click(); - await page.waitForURL(/\?version=/); - const import2Version = page.url().split('?version=').pop(); + const import2Version = await processModelerPage.createVersion('Version 2', 'Second Version'); // share this process so it is visible in the documentation for other users const shareModal = await openModal(page, () => diff --git a/tests/ms2/processes/process-modeler/process-modeler.page.ts b/tests/ms2/processes/process-modeler/process-modeler.page.ts index 355d20f4d..d43a35433 100644 --- a/tests/ms2/processes/process-modeler/process-modeler.page.ts +++ b/tests/ms2/processes/process-modeler/process-modeler.page.ts @@ -1,4 +1,5 @@ import { Page } from '@playwright/test'; +import { openModal, closeModal } from '../../testUtils'; export class ProcessModelerPage { readonly page: Page; @@ -20,4 +21,25 @@ export class ProcessModelerPage { .first() .click(); } + + async createVersion(name: string, description: string) { + const { page } = this; + + let versionModal = await openModal(page, () => + page.getByLabel('general-modeler-toolbar').getByRole('button', { name: 'plus' }).click(), + ); + await versionModal.getByPlaceholder('Version Name').fill(name); + await versionModal.getByPlaceholder('Version Description').fill(description); + await closeModal(versionModal, () => + versionModal.getByRole('button', { name: 'Create Version' }).click(), + ); + const url = page.url(); + await page.getByText('Latest Version').click(); + await page.getByText(name).click(); + await page.waitForURL(/\?version=/); + const versionId = page.url().split('?version=').pop(); + await page.goto(url); + await page.waitForURL(url); + return versionId; + } } From 6ddce00a12088ee4f1fa97789707e1b08196aee1 Mon Sep 17 00:00:00 2001 From: Janis Joderi Shoferi Date: Wed, 6 Nov 2024 17:38:28 +0100 Subject: [PATCH 04/14] Merged main into ms2/new-documentation-page-e2e --- .github/workflows/build_test_deploy.yml | 50 +- .gitignore | 1 + FeatureFlags.js | 4 + package.json | 13 +- playwright.config.ts | 4 +- src/management-system-v2/.gitignore | 2 + .../app/(auth)/signin/layout.tsx | 16 +- .../app/(auth)/signin/page.tsx | 2 +- .../app/(auth)/signin/signin.tsx | 2 +- .../environments/environemnts-page.tsx | 30 +- .../[environmentId]/environments/page.tsx | 17 +- .../deployment-selection-icon-view.tsx | 4 +- .../executions/deployments-hook.ts | 33 ++ .../executions/deployments-list.tsx | 41 +- .../executions/deployments-modal.tsx | 30 +- .../executions/deployments-view.tsx | 67 ++- .../[environmentId]/executions/page.tsx | 6 +- .../iam/roles/[roleId]/page.tsx | 25 +- .../iam/roles/[roleId]/role-members.tsx | 53 +- .../iam/roles/[roleId]/roleGeneralData.tsx | 19 +- .../iam/roles/header-actions.tsx | 26 +- .../[environmentId]/iam/roles/page.tsx | 6 +- .../[environmentId]/iam/roles/role-page.tsx | 24 +- .../iam/roles/role-side-panel.tsx | 4 +- .../iam/users/header-actions.tsx | 24 +- .../[environmentId]/iam/users/page.tsx | 24 +- .../[environmentId]/iam/users/users-page.tsx | 22 +- .../[environmentId]/layout-client.tsx | 6 +- .../(dashboard)/[environmentId]/layout.tsx | 19 +- .../[configId]/config-content.tsx | 1 + .../[configId]/config-editor.tsx | 52 +- .../[configId]/config-page-content.tsx | 12 +- .../[configId]/config-tree-view.tsx | 43 +- .../[configId]/custom-field.tsx | 101 +++- .../machine-config/[configId]/mach-config.tsx | 62 +- .../[configId]/page.module.scss | 6 +- .../machine-config/[configId]/parameter.tsx | 12 +- .../machine-config/configuration-helper.ts | 50 +- .../machine-config/parent-config-list.tsx | 95 +-- .../organization-settings/page.tsx | 8 +- .../_user-task-builder/DragAndDropHandler.tsx | 8 +- .../_user-task-builder/_sidebar/Settings.tsx | 4 +- .../_user-task-builder/_sidebar/Toolbox.tsx | 16 +- .../_utils/EditableText.tsx | 28 +- .../_utils/lexical-text-editor.tsx | 15 +- .../elements/CheckboxOrRadioGroup.tsx | 338 +++++++++-- .../_user-task-builder/elements/Column.tsx | 4 +- .../_user-task-builder/elements/Container.tsx | 18 +- .../_user-task-builder/elements/Image.tsx | 201 +++---- .../_user-task-builder/elements/Input.tsx | 150 +++-- .../_user-task-builder/elements/Table.tsx | 489 ++++++++++------ .../_user-task-builder/elements/Text.tsx | 50 +- .../_user-task-builder/elements/utils.tsx | 219 +++++++ .../[processId]/_user-task-builder/index.tsx | 7 +- .../use-builder-state-store.ts | 16 +- .../[processId]/_user-task-builder/utils.tsx | 85 ++- .../[processId]/modeler-share-modal.tsx | 11 +- .../processes/[processId]/modeler-toolbar.tsx | 51 +- .../processes/[processId]/page.tsx | 13 +- .../processes/[processId]/version-toolbar.tsx | 17 +- .../processes/folder/[folderId]/page.tsx | 8 +- .../[environmentId]/profile/page.tsx | 5 +- .../profile/user-data-modal.tsx | 29 +- .../[environmentId]/profile/user-profile.tsx | 73 ++- .../[environmentId]/projects/page.tsx | 4 +- .../app/accept-invitation/page.tsx | 12 +- src/management-system-v2/app/admin/page.tsx | 4 +- .../app/admin/spaces/page.tsx | 35 +- .../app/admin/spaces/space-representation.ts | 52 +- .../app/admin/spaces/spaces-table.tsx | 1 - .../app/admin/systemadmins/page.tsx | 51 +- .../admin/systemadmins/system-admin-form.tsx | 2 +- .../app/admin/users/page.tsx | 60 +- .../app/admin/users/user-table.tsx | 2 +- .../app/api/auth/[...nextauth]/adapter.ts | 28 +- .../api/auth/[...nextauth]/auth-options.ts | 69 ++- .../api/private/[environmentId]/logo/route.ts | 6 +- .../app/change-email/change-email-card.tsx | 75 +++ .../app/change-email/page.tsx | 58 ++ .../app/create-organization/page.tsx | 6 +- .../app/shared-viewer/documentation-page.tsx | 2 +- .../app/shared-viewer/page.tsx | 30 +- .../app/shared-viewer/process-document.tsx | 8 +- .../app/shared-viewer/workspace-selection.tsx | 6 +- src/management-system-v2/components/app.tsx | 1 + src/management-system-v2/components/auth.tsx | 6 +- .../components/bpmn-viewer.tsx | 2 +- .../components/favouriteStar.tsx | 7 +- .../components/folder-creation-button.tsx | 28 +- .../components/folder-modal.tsx | 30 + .../components/header-actions.tsx | 9 +- .../components/item-list-view.tsx | 35 +- .../machine-config-creation-button.tsx | 2 +- .../components/machine-config-modal.tsx | 53 +- .../components/process-list.tsx | 30 +- .../components/process-modal.tsx | 1 - .../components/processes/index.tsx | 93 +-- .../components/user-avatar.tsx | 4 +- src/management-system-v2/db-helper.sh | 269 +++++++++ ...ker-compose.yml => docker-compose-dev.yml} | 0 .../lib/authorization/authorization.ts | 21 +- .../lib/authorization/caslRules.ts | 4 +- .../organizationEnvironmentRolesHelper.ts | 38 +- .../lib/change-email/server-actions.ts | 73 +++ .../lib/change-email/utils.ts | 57 ++ src/management-system-v2/lib/data/DTOs.ts | 213 +++++++ .../lib/data/db/folders.ts | 313 ++++++++++ .../lib/data/db/iam/environments.ts | 211 +++++++ .../lib/data/db/iam/memberships.ts | 137 +++++ .../lib/data/db/iam/role-mappings.ts | 180 ++++++ .../lib/data/db/iam/roles.ts | 198 +++++++ .../lib/data/db/iam/system-admins.ts | 67 +++ .../lib/data/db/iam/users.ts | 196 +++++++ .../lib/data/db/machine-config.ts | 1 + .../lib/data/db/process.ts | 542 ++++++++++++++++++ .../lib/data/environment-memberships.ts | 30 +- .../lib/data/environment-schema.ts | 10 +- .../lib/data/environments.ts | 44 +- .../lib/data/folder-schema.ts | 4 +- src/management-system-v2/lib/data/folders.ts | 63 +- src/management-system-v2/lib/data/index.ts | 17 + .../lib/data/legacy/_process.ts | 4 +- .../lib/data/legacy/fileHandling.js | 2 +- .../lib/data/legacy/folders.ts | 46 +- .../lib/data/legacy/iam/environments.ts | 91 +-- .../lib/data/legacy/iam/memberships.ts | 30 +- .../lib/data/legacy/iam/role-mappings.ts | 31 +- .../lib/data/legacy/iam/roles.ts | 29 +- .../lib/data/legacy/iam/system-admins.ts | 62 +- .../lib/data/legacy/iam/users.ts | 108 ++-- .../lib/data/legacy/machine-config.ts | 118 ++-- .../lib/data/legacy/store.js | 1 + .../lib/data/legacy/verification-tokens.ts | 68 +++ .../lib/data/machine-config-schema.ts | 4 +- .../lib/data/module-import-types-temp.ts | 20 + .../lib/data/process-schema.ts | 2 +- .../lib/data/processes.tsx | 134 +++-- .../lib/data/role-mappings.ts | 28 +- .../lib/data/role-schema.ts | 17 +- src/management-system-v2/lib/data/roles.ts | 37 +- .../lib/data/system-admin-schema.ts | 4 +- .../lib/data/user-schema.ts | 6 +- src/management-system-v2/lib/data/users.tsx | 48 +- .../lib/data/versioned-object-schema.ts | 16 +- .../email}/signin-link-email.tsx | 33 +- .../lib/engines/deployment.ts | 224 ++++++++ .../lib/engines/endpoints.ts | 61 ++ .../lib/engines/machines.ts | 19 + src/management-system-v2/lib/env-vars.ts | 1 + .../lib/helpers/processHelpers.ts | 10 +- .../lib/helpers/processVersioning.ts | 24 +- .../lib/process-export/export-preparation.ts | 2 +- .../lib/sharing/process-sharing.ts | 19 +- .../lib/useFavouriteProcesses.ts | 16 +- src/management-system-v2/lib/user-error.ts | 14 + src/management-system-v2/lib/utils.ts | 376 ++++++++++++ .../lib/wrap-server-call.ts | 84 +++ src/management-system-v2/package.json | 2 + .../20240930001555_init/migration.sql | 218 +++++++ .../prisma/migrations/migration_lock.toml | 3 + src/management-system-v2/prisma/schema.prisma | 169 ++++++ .../tests/unit/folders.ts | 50 +- src/management-system-v2/tests/unit/roles.ts | 18 +- .../src/backend/server/rest-api/machine.js | 2 +- tests/ms2/processes/process-list.page.ts | 70 ++- tests/ms2/processes/process-list.spec.ts | 229 ++++++-- .../process-modeler/process-modeler.spec.ts | 47 +- .../properties-panel/properties-panel.spec.ts | 4 +- tests/ms2/processes/processes.fixtures.ts | 1 + tests/ms2/testUtils/index.ts | 4 +- yarn.lock | 151 +++-- 171 files changed, 7377 insertions(+), 1751 deletions(-) create mode 100644 src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployments-hook.ts create mode 100644 src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/elements/utils.tsx create mode 100644 src/management-system-v2/app/change-email/change-email-card.tsx create mode 100644 src/management-system-v2/app/change-email/page.tsx create mode 100755 src/management-system-v2/db-helper.sh rename src/management-system-v2/{docker-compose.yml => docker-compose-dev.yml} (100%) create mode 100644 src/management-system-v2/lib/change-email/server-actions.ts create mode 100644 src/management-system-v2/lib/change-email/utils.ts create mode 100644 src/management-system-v2/lib/data/DTOs.ts create mode 100644 src/management-system-v2/lib/data/db/folders.ts create mode 100644 src/management-system-v2/lib/data/db/iam/environments.ts create mode 100644 src/management-system-v2/lib/data/db/iam/memberships.ts create mode 100644 src/management-system-v2/lib/data/db/iam/role-mappings.ts create mode 100644 src/management-system-v2/lib/data/db/iam/roles.ts create mode 100644 src/management-system-v2/lib/data/db/iam/system-admins.ts create mode 100644 src/management-system-v2/lib/data/db/iam/users.ts create mode 100644 src/management-system-v2/lib/data/db/machine-config.ts create mode 100644 src/management-system-v2/lib/data/db/process.ts create mode 100644 src/management-system-v2/lib/data/index.ts create mode 100644 src/management-system-v2/lib/data/legacy/verification-tokens.ts create mode 100644 src/management-system-v2/lib/data/module-import-types-temp.ts rename src/management-system-v2/{app/api/auth/[...nextauth] => lib/email}/signin-link-email.tsx (83%) create mode 100644 src/management-system-v2/lib/engines/deployment.ts create mode 100644 src/management-system-v2/lib/engines/endpoints.ts create mode 100644 src/management-system-v2/lib/engines/machines.ts create mode 100644 src/management-system-v2/lib/wrap-server-call.ts create mode 100644 src/management-system-v2/prisma/migrations/20240930001555_init/migration.sql create mode 100644 src/management-system-v2/prisma/migrations/migration_lock.toml create mode 100644 src/management-system-v2/prisma/schema.prisma diff --git a/.github/workflows/build_test_deploy.yml b/.github/workflows/build_test_deploy.yml index c0a27411a..c8214d472 100644 --- a/.github/workflows/build_test_deploy.yml +++ b/.github/workflows/build_test_deploy.yml @@ -323,6 +323,9 @@ jobs: timeout-minutes: 60 runs-on: ubuntu-latest if: ${{ inputs.environment != 'Production' }} + strategy: + matrix: + shard: [1, 2, 3, 4] steps: - uses: actions/cache@v4 timeout-minutes: 2 @@ -338,17 +341,56 @@ jobs: check-latest: true cache: 'yarn' + # TODO: cache these? - name: Install Playwright Browsers run: yarn playwright install --with-deps - name: Run Playwright tests - run: yarn playwright test + run: yarn playwright test --shard=${{ matrix.shard }}/${{ strategy.job-total }} env: PLAYWRIGHT_TEST_BASE_URL: ${{ github.event_name == 'pull_request' && format('https://pr-{0}---ms-server-staging-c4f6qdpj7q-ew.a.run.app', github.event.number) || 'https://staging.proceed-labs.org' }} - uses: actions/upload-artifact@v4 if: ${{ !cancelled() }} with: - name: playwright-report - path: playwright-report/ - retention-days: 30 + name: all-blob-reports-${{ matrix.shard }} + path: blob-report + retention-days: 1 + + create-report: + name: 📔 Create test report + if: always() + needs: [testE2E] + + runs-on: ubuntu-latest + steps: + - uses: actions/cache@v4 + timeout-minutes: 2 + id: restore-install + with: + path: ./* + key: ${{ github.sha }}-${{ github.run_number }} + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: 20 + check-latest: true + cache: 'yarn' + + - name: Download blob reports from GitHub Actions Artifacts + uses: actions/download-artifact@v4 + with: + pattern: all-blob-reports-* + merge-multiple: true + path: all-blob-reports + + - name: Merge into HTML Report + run: npx playwright merge-reports --reporter html ./all-blob-reports + + - name: Upload HTML report + uses: actions/upload-artifact@v4 + with: + name: html-report--attempt-${{ github.run_attempt }} + path: playwright-report + retention-days: 7 diff --git a/.gitignore b/.gitignore index a7bcceada..87d43c3e8 100644 --- a/.gitignore +++ b/.gitignore @@ -53,6 +53,7 @@ package-lock.json /playwright-report/ /playwright/.cache/ dataEval.json +/blob-report/ # Ignore generated credentials from google-github-actions/auth gha-creds-*.json \ No newline at end of file diff --git a/FeatureFlags.js b/FeatureFlags.js index 5b46c9633..d4926a374 100644 --- a/FeatureFlags.js +++ b/FeatureFlags.js @@ -45,6 +45,10 @@ module.exports = { // Whether the Chatbot UserInterface and its functionality should be enabled enableChatbot: false, + //feature to switch to prisma from fs + enableUseDB: false, + enableUseFileManager: false, + // ----------------------------------------------------------------------------- // Chopping Block // diff --git a/package.json b/package.json index c93466e08..af7b5c9c9 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,12 @@ }, "scripts": { "dev": "ts-node src/engine/native/node/index.ts --trace-warnings", + "dev-ms-db": "cd src/management-system-v2 && docker compose -f docker-compose-dev.yml up -d && ./db-helper.sh --init", + "dev-ms-db-create": "cd src/management-system-v2 && ./db-helper.sh --new", + "dev-ms-db-deploy": "cd src/management-system-v2 && yarn prisma migrate deploy", + "dev-ms-db-migrate": "cd src/management-system-v2 && yarn prisma migrate dev", + "dev-ms-db-use": "cd src/management-system-v2 && ./db-helper.sh --use", + "dev-ms-db-delete": "cd src/management-system-v2 && ./db-helper.sh --delete", "dev-ms": "cd src/management-system-v2 && yarn dev", "dev-ms-old": "cd src/management-system && yarn web:dev", "dev-ms-old-iam": "cd src/management-system && yarn web:dev-iam", @@ -57,7 +63,8 @@ "test-all": "yarn test-engine && yarn test-ms && yarn test-e2e", "jsdoc": "jsdoc -c ./jsdoc.config.json --pedantic", "android-prepare": "cd src/engine/universal && webpack --output-path ../native/android/app/src/main/assets --config webpack.universal.config.js", - "build-android": "yarn android-prepare && cd src/engine/native/android && ./gradlew assembleDebug" + "build-android": "yarn android-prepare && cd src/engine/native/android && ./gradlew assembleDebug", + "prepare": "husky" }, "lint-staged": { "*": "prettier --check --ignore-path .eslintignore" @@ -74,7 +81,7 @@ "@babel/plugin-proposal-class-properties": "^7.5.0", "@babel/preset-env": "^7.5.2", "@open-wc/webpack-import-meta-loader": "0.4.7", - "@playwright/test": "^1.30.0", + "@playwright/test": "1.48.1", "@types/jest": "^29.5.1", "@userfrosting/merge-package-dependencies": "^1.2.0", "@vue/cli-plugin-typescript": "5.0.8", @@ -91,7 +98,7 @@ "fs-extra": "^7.0.1", "html-webpack-inline-source-plugin": "^0.0.10", "html-webpack-plugin": "^3.2.0", - "husky": "^4.2.3", + "husky": "^9.1.6", "jest": "29.7.0", "jest-when": "3.6.0", "jsdoc": "^3.6.6", diff --git a/playwright.config.ts b/playwright.config.ts index e24510321..a7cdfb70c 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -23,7 +23,7 @@ export default defineConfig({ timeout: 7000, }, /* Run tests in files in parallel */ - fullyParallel: true, + // fullyParallel: true, /* Fail the build on CI if you accidentally left test.only in the source code. */ forbidOnly: !!process.env.CI, /* Retry on CI only */ @@ -31,7 +31,7 @@ export default defineConfig({ /* Opt out of parallel tests on CI. */ workers: process.env.CI ? 1 : undefined, /* Reporter to use. See https://playwright.dev/docs/test-reporters */ - reporter: 'html', + reporter: 'blob', /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ use: { /* Maximum time each action such as `click()` can take. Defaults to 0 (no limit). */ diff --git a/src/management-system-v2/.gitignore b/src/management-system-v2/.gitignore index 392b51234..30dd0ae0b 100644 --- a/src/management-system-v2/.gitignore +++ b/src/management-system-v2/.gitignore @@ -41,5 +41,7 @@ next-env.d.ts # Build storage directory for running the production build locally (in docker) /volume +*_key.json + # Partykit /.partykit/ diff --git a/src/management-system-v2/app/(auth)/signin/layout.tsx b/src/management-system-v2/app/(auth)/signin/layout.tsx index 9063b6b56..7c34ceccc 100644 --- a/src/management-system-v2/app/(auth)/signin/layout.tsx +++ b/src/management-system-v2/app/(auth)/signin/layout.tsx @@ -13,7 +13,7 @@ const SigninLayout: FC = ({ children }) => { = ({ children }) => { id: '', folderId: '', environmentId: '', - owner: '', + creatorId: '', name: 'How to PROCEED', type: 'process', versions: [], - createdOn: '', - lastEdited: '', - variables: [], - departments: [], + createdOn: new Date(), + lastEditedOn: new Date(), + //variables: [], + //departments: [], processIds: [], description: 'How to PROCEED', sharedAs: 'protected', @@ -64,9 +64,9 @@ const SigninLayout: FC = ({ children }) => { id: '', name: '', parentId: null, - createdOn: '', + createdOn: new Date(), createdBy: '', - lastEdited: '', + lastEditedOn: new Date(), environmentId: '', }} /> diff --git a/src/management-system-v2/app/(auth)/signin/page.tsx b/src/management-system-v2/app/(auth)/signin/page.tsx index b6173372a..0ede1380d 100644 --- a/src/management-system-v2/app/(auth)/signin/page.tsx +++ b/src/management-system-v2/app/(auth)/signin/page.tsx @@ -6,7 +6,7 @@ import SignIn from './signin'; // take in search query const SignInPage = async ({ searchParams }: { searchParams: { callbackUrl: string } }) => { const { session } = await getCurrentUser(); - const isGuest = session?.user.guest; + const isGuest = session?.user.isGuest; if (session?.user && !isGuest) { const callbackUrl = searchParams.callbackUrl ?? `/${session.user.id}/processes`; diff --git a/src/management-system-v2/app/(auth)/signin/signin.tsx b/src/management-system-v2/app/(auth)/signin/signin.tsx index b80dc6932..af30cc870 100644 --- a/src/management-system-v2/app/(auth)/signin/signin.tsx +++ b/src/management-system-v2/app/(auth)/signin/signin.tsx @@ -198,7 +198,7 @@ const SignIn: FC<{ {oauthProviders.map((provider, idx) => { if (provider.type !== 'oauth') return null; return ( - + = ({ organizationEnvironments, }) => { - const { message } = App.useApp(); + const app = App.useApp(); const router = useRouter(); const { searchQuery, filteredData, setSearchQuery } = useFuzySearch({ @@ -42,22 +43,15 @@ const EnvironmentsPage: FC<{ organizationEnvironments: OrganizationEnvironment[] const userId = session.data?.user?.id || ''; async function deleteEnvironments(environmentIds: string[]) { - try { - const result = await deleteOrganizationEnvironments(environmentIds); - if (result && 'error' in result) throw result.error; - - setSelectedRows([]); - router.refresh(); - message.open({ - content: `Environment${environmentIds.length > 1 ? 's' : ''} deleted`, - type: 'success', - }); - } catch (e) { - console.log(e); - //@ts-ignore - const content = (e && e?.message) || 'Something went wrong'; - message.open({ content, type: 'error' }); - } + await wrapServerCall({ + fn: () => deleteOrganizationEnvironments(environmentIds), + onSuccess: () => { + setSelectedRows([]); + router.refresh(); + app.message.success(`Environment${environmentIds.length > 1 ? 's' : ''} deleted`); + }, + app, + }); } return ( diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/environments/page.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/environments/page.tsx index d6b278286..1642eae2a 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/environments/page.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/environments/page.tsx @@ -1,21 +1,22 @@ import { getCurrentUser } from '@/components/auth'; import Content from '@/components/content'; -import { getEnvironmentById } from '@/lib/data/legacy/iam/environments'; -import { getUserOrganizationEnvironments } from '@/lib/data/legacy/iam/memberships'; +import { getEnvironmentById } from '@/lib/data/DTOs'; +import { getUserOrganizationEnvironments } from '@/lib/data/DTOs'; import { OrganizationEnvironment } from '@/lib/data/environment-schema'; import EnvironmentsPage from './environemnts-page'; -import { getUserById } from '@/lib/data/legacy/iam/users'; +import { getUserById } from '@/lib/data/DTOs'; import UnauthorizedFallback from '@/components/unauthorized-fallback'; const Page = async () => { const { userId } = await getCurrentUser(); - const user = getUserById(userId); - if (user.guest) return ; + const user = await getUserById(userId); + if (user?.isGuest) return ; - const organizationEnvironments = getUserOrganizationEnvironments(userId).map((environmentId) => - getEnvironmentById(environmentId), - ) as OrganizationEnvironment[]; + const environmentIds = await getUserOrganizationEnvironments(userId); + const organizationEnvironments = (await Promise.all( + environmentIds.map((environmentId: string) => getEnvironmentById(environmentId)), + )) as OrganizationEnvironment[]; return ( diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployment-selection-icon-view.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployment-selection-icon-view.tsx index e0ab14202..4266e1f23 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployment-selection-icon-view.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployment-selection-icon-view.tsx @@ -12,7 +12,7 @@ const ProcessIconView = ({ }: { data: ProcessListProcess[]; openFolder: (id: string) => void; - selectProcess: (id: string) => void; + selectProcess: (process: ProcessListProcess) => void; }) => { const folders = filteredData.filter((item) => item.type === 'folder'); const processesData = filteredData.filter((item) => item.type !== 'folder'); @@ -30,7 +30,7 @@ const ProcessIconView = ({ {item?.name.highlighted} {item.type !== 'folder' && ( - )} diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployments-hook.ts b/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployments-hook.ts new file mode 100644 index 000000000..e48438d8b --- /dev/null +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployments-hook.ts @@ -0,0 +1,33 @@ +import { useEnvironment } from '@/components/auth-can'; +import { getDeployments, DeployedProcessInfo } from '@/lib/engines/deployment'; +import { QueryOptions, useQuery } from '@tanstack/react-query'; + +async function queryFn() { + const res = await getDeployments(); + + for (const deployment of res) { + let latestVesrionIdx = deployment.versions.length - 1; + for (let i = deployment.versions.length - 2; i >= 0; i--) { + if (deployment.versions[i].version > deployment.versions[latestVesrionIdx].version) + latestVesrionIdx = i; + } + const latestVersion = deployment.versions[latestVesrionIdx]; + + // @ts-ignore + deployment.name = latestVersion.versionName || latestVersion.definitionName; + } + + return res as (DeployedProcessInfo & { name: string })[]; +} + +export default function useDeployments( + queryOptions: QueryOptions>> = {}, +) { + const space = useEnvironment(); + + return useQuery({ + queryFn, + queryKey: ['processDeployments', space.spaceId], + ...queryOptions, + }); +} diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployments-list.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployments-list.tsx index 2efa1ef19..4ae04c9da 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployments-list.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployments-list.tsx @@ -1,23 +1,24 @@ 'use client'; import styles from '@/components/item-list-view.module.scss'; -import { Button, Grid, TableColumnsType } from 'antd'; +import { Button, Grid, TableColumnsType, TableProps, Tooltip } from 'antd'; import { ReplaceKeysWithHighlighted } from '@/lib/useFuzySearch'; import ElementList from '@/components/item-list-view'; import { DeleteOutlined } from '@ant-design/icons'; import { useState } from 'react'; +import { DeployedProcessInfo } from '@/lib/engines/deployment'; -type InputItem = { - id: string; - name: string; - versions: number; - runningInstances: number; - endedInstances: number; -}; +type InputItem = DeployedProcessInfo & { name: string }; export type DeployedProcessListProcess = ReplaceKeysWithHighlighted; -const DeploymentsList = ({ processes }: { processes: DeployedProcessListProcess[] }) => { +const DeploymentsList = ({ + processes, + tableProps, +}: { + processes: DeployedProcessListProcess[]; + tableProps?: TableProps; +}) => { const breakpoint = Grid.useBreakpoint(); const columns: TableColumnsType = [ @@ -46,7 +47,15 @@ const DeploymentsList = ({ processes }: { processes: DeployedProcessListProcess[ title: 'Versions', dataIndex: 'description', key: 'Versions', - render: (_, record) => {record.versions}, + render: (_, { versions }) => ( + 1 && versions.map((v) => v.versionName || v.definitionName).join(', ') + } + > + {versions.length} + + ), sorter: (a, b) => (a < b ? -1 : 1), responsive: ['sm'], }, @@ -54,7 +63,7 @@ const DeploymentsList = ({ processes }: { processes: DeployedProcessListProcess[ title: 'Running Instances', dataIndex: 'runningInstances', key: 'Running Instances', - render: (_, record) => {record.runningInstances}, + render: (_, record) => {record.instances.length}, sorter: (a, b) => (a < b ? -1 : 1), responsive: ['md'], }, @@ -62,6 +71,8 @@ const DeploymentsList = ({ processes }: { processes: DeployedProcessListProcess[ title: 'Ended Instances', dataIndex: 'endedInstances', key: 'Ended Instances', + // TODO: remove ts-ignore + // @ts-ignore render: (_, record) => {record.endedInstances}, sorter: (a, b) => (a < b ? -1 : 1), responsive: ['md'], @@ -96,10 +107,13 @@ const DeploymentsList = ({ processes }: { processes: DeployedProcessListProcess[ } selectableColumns={{ setColumnTitles: (cols) => { + let newCols: string[]; if (typeof cols === 'function') { - cols = cols(selectedColumns.map((col: any) => col.name) as string[]); + newCols = cols(selectedColumns.map((col: any) => col.name) as string[]); + } else { + newCols = cols; } - setSelectedColumns(columns.filter((column) => cols.includes(column.key as string))); + setSelectedColumns(columns.filter((column) => newCols.includes(column.key as string))); }, selectedColumnTitles: selectedColumns.map((c) => c.title) as string[], allColumnTitles: ['Versions', 'Running Instances', 'Ended Instances'], @@ -115,6 +129,7 @@ const DeploymentsList = ({ processes }: { processes: DeployedProcessListProcess[ }, }, }} + tableProps={tableProps} > ); diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployments-modal.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployments-modal.tsx index 6a2858a83..536a46b1a 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployments-modal.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployments-modal.tsx @@ -22,12 +22,12 @@ export type ProcessListProcess = ReplaceKeysWithHighlighted void; + process: ProcessListProcess; + onDeploy: (process: ProcessListProcess, method?: 'static' | 'dynamic') => void; }) => { return isAdvancedView ? ( @@ -35,7 +35,7 @@ const DeploymentButtons = ({ type="primary" size="small" onClick={() => { - onDeploy(processId); + onDeploy(process); }} > Normal @@ -44,7 +44,7 @@ const DeploymentButtons = ({ type="primary" size="small" onClick={() => { - onDeploy(processId, 'static'); + onDeploy(process, 'static'); }} > Static @@ -53,7 +53,7 @@ const DeploymentButtons = ({ type="primary" size="small" onClick={() => { - onDeploy(processId, 'dynamic'); + onDeploy(process, 'dynamic'); }} > Dynamic @@ -65,7 +65,7 @@ const DeploymentButtons = ({ type="primary" size="small" onClick={() => { - onDeploy(processId); + onDeploy(process); }} > Deploy Process @@ -86,7 +86,7 @@ const DeploymentsModal = ({ processes: InputItem[]; favourites?: string[]; folder: Folder; - selectProcess: (id: string) => void; + selectProcess: (process: ProcessListProcess) => void; }) => { const [isAdvancedView, setIsAdvancedView] = useState(false); const [isDropdownOpen, setIsDropdownOpen] = useState(false); @@ -100,9 +100,9 @@ const DeploymentsModal = ({ parentId: null, type: 'folder', id: initialFolder.parentId, - createdOn: '', + createdOn: null, createdBy: '', - lastEdited: '', + lastEditedOn: null, environmentId: '', }, ...initialProcesses, @@ -154,9 +154,9 @@ const DeploymentsModal = ({ parentId: null, type: 'folder', id: folder.parentId, - createdOn: '', + createdOn: new Date(), createdBy: '', - lastEdited: '', + lastEditedOn: new Date(), environmentId: '', }, ...folderContents, @@ -271,13 +271,13 @@ const DeploymentsModal = ({ openFolder={(id) => { openFolder(id); }} - deploymentButtons={({ processId }: { processId: string }) => ( + deploymentButtons={({ process }) => ( { - console.log('deploy', processId, method); + selectProcess(processId); }} - processId={processId} + process={process} > )} > diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployments-view.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployments-view.tsx index 62177ebd3..45c8323a6 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployments-view.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/executions/deployments-view.tsx @@ -1,13 +1,18 @@ 'use client'; -import { Button } from 'antd'; -import { useState } from 'react'; +import { App, Button } from 'antd'; +import { useState, useTransition } from 'react'; import DeploymentsModal from './deployments-modal'; import Bar from '@/components/bar'; import useFuzySearch from '@/lib/useFuzySearch'; import DeploymentsList from './deployments-list'; import { Folder } from '@/lib/data/folder-schema'; -import { ProcessMetadata } from '@/lib/data/process-schema'; +import { Process, ProcessMetadata } from '@/lib/data/process-schema'; +import { useQuery } from '@tanstack/react-query'; +import { useEnvironment } from '@/components/auth-can'; +import { processHasChangesSinceLastVersion } from '@/lib/data/processes'; +import { DeployedProcessInfo, deployProcess, getDeployments } from '@/lib/engines/deployment'; +import useDeployments from './deployments-hook'; type InputItem = ProcessMetadata | (Folder & { type: 'folder' }); @@ -21,18 +26,14 @@ const DeploymentsView = ({ favourites: any; }) => { const [modalIsOpen, setModalIsOpen] = useState(false); + const { message } = App.useApp(); + const space = useEnvironment(); - const deployedProcesses = processes - .filter((process) => process.type !== 'folder') - .map((process) => { - return { - id: process.id, - name: process.name, - versions: 4, - runningInstances: 4, - endedInstances: 2, - }; - }); + const { + data: deployedProcesses, + isLoading, + refetch: refetchDeployedProcesses, + } = useDeployments(); const { filteredData, setSearchQuery: setSearchTerm } = useFuzySearch({ data: deployedProcesses ?? [], @@ -41,6 +42,36 @@ const DeploymentsView = ({ transformData: (matches) => matches.map((match) => match.item), }); + const [checkingProcessVersion, startCheckingProcessVersion] = useTransition(); + function checkProcessVersion(process: Pick) { + startCheckingProcessVersion(async () => { + try { + const processChangedSinceLastVersion = await processHasChangesSinceLastVersion( + process.id, + space.spaceId, + ); + if (typeof processChangedSinceLastVersion === 'object') + throw processChangedSinceLastVersion; + + if (processChangedSinceLastVersion) { + alert('Process has changed since last version'); + } + + const v = process.versions + .map((v) => v.version) + .sort() + .at(-1); + + await deployProcess(process.id, v as number, space.spaceId, 'dynamic'); + refetchDeployedProcesses(); + } catch (e) { + message.error("Something wen't wrong"); + } + }); + } + + const loading = isLoading || checkingProcessVersion; + return (
@@ -62,7 +93,7 @@ const DeploymentsView = ({ }} /> - + {}} + selectProcess={(process) => { + if (process.type === 'folder') return; + // console.log(process); + checkProcessVersion(process); + }} >
); diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/executions/page.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/executions/page.tsx index 3a74e8162..0e6892471 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/executions/page.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/executions/page.tsx @@ -3,7 +3,7 @@ import { getCurrentEnvironment } from '@/components/auth'; import { notFound } from 'next/navigation'; import { env } from '@/lib/env-vars'; import DeploymentsView from './deployments-view'; -import { getRootFolder, getFolderById, getFolderContents } from '@/lib/data/legacy/folders'; +import { getRootFolder, getFolderById, getFolderContents } from '@/lib/data/DTOs'; import { getUsersFavourites } from '@/lib/data/users'; const ExecutionsPage = async ({ params }: { params: { environmentId: string } }) => { @@ -15,9 +15,9 @@ const ExecutionsPage = async ({ params }: { params: { environmentId: string } }) const favs = await getUsersFavourites(); - const rootFolder = getRootFolder(activeEnvironment.spaceId, ability); + const rootFolder = await getRootFolder(activeEnvironment.spaceId, ability); - const folder = getFolderById(rootFolder.id); + const folder = await getFolderById(rootFolder.id); const folderContents = await getFolderContents(folder.id, ability); diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/[roleId]/page.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/[roleId]/page.tsx index 8545cfcd8..bb15a82ec 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/[roleId]/page.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/[roleId]/page.tsx @@ -1,12 +1,11 @@ import { getCurrentEnvironment } from '@/components/auth'; import Content from '@/components/content'; -import { getRoleById } from '@/lib/data/legacy/iam/roles'; -import { Result } from 'antd'; +import { getRoleById } from '@/lib/data/DTOs'; import UnauthorizedFallback from '@/components/unauthorized-fallback'; import { toCaslResource } from '@/lib/ability/caslAbility'; import RoleId from './role-id-page'; -import { getMemebers } from '@/lib/data/legacy/iam/memberships'; -import { getUserById } from '@/lib/data/legacy/iam/users'; +import { getMembers } from '@/lib/data/DTOs'; +import { getUserById } from '@/lib/data/DTOs'; import { AuthenticatedUser } from '@/lib/data/user-schema'; const Page = async ({ @@ -15,7 +14,7 @@ const Page = async ({ params: { roleId: string; environmentId: string }; }) => { const { ability, activeEnvironment } = await getCurrentEnvironment(environmentId); - const role = getRoleById(roleId, ability); + const role = await getRoleById(roleId, ability); if (!role) return ( @@ -24,15 +23,17 @@ const Page = async ({ ); - const usersInRole = role.members.map((member) => - getUserById(member.userId), - ) as AuthenticatedUser[]; + const usersInRole = (await Promise.all( + role.members.map((member) => getUserById(member.userId)), + )) as AuthenticatedUser[]; const roleUserSet = new Set(usersInRole.map((member) => member.id)); - const memberships = getMemebers(activeEnvironment.spaceId, ability); - const usersNotInRole = memberships - .filter(({ userId }) => !roleUserSet.has(userId)) - .map((user) => getUserById(user.userId)) as AuthenticatedUser[]; + const memberships = await getMembers(activeEnvironment.spaceId, ability); + const usersNotInRole = (await Promise.all( + memberships + .filter(({ userId }) => !roleUserSet.has(userId)) + .map((user) => getUserById(user.userId)), + )) as AuthenticatedUser[]; if (!ability.can('manage', toCaslResource('Role', role))) return ; diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/[roleId]/role-members.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/[roleId]/role-members.tsx index 4be83e779..0c797c569 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/[roleId]/role-members.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/[roleId]/role-members.tsx @@ -3,13 +3,14 @@ import { FC, useState, useTransition } from 'react'; import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; import UserList, { UserListProps } from '@/components/user-list'; -import { Button, Modal, Tooltip } from 'antd'; +import { App, Button, Modal, Tooltip } from 'antd'; import ConfirmationButton from '@/components/confirmation-button'; import { addRoleMappings, deleteRoleMappings } from '@/lib/data/role-mappings'; import { useRouter } from 'next/navigation'; import { Role } from '@/lib/data/role-schema'; import { AuthenticatedUser } from '@/lib/data/user-schema'; import { useEnvironment } from '@/components/auth-can'; +import { wrapServerCall } from '@/lib/wrap-server-call'; const AddUserModal: FC<{ role: Role; @@ -20,20 +21,27 @@ const AddUserModal: FC<{ const [loading, startTransition] = useTransition(); const router = useRouter(); const environment = useEnvironment(); + const app = App.useApp(); type AddUserParams = Parameters>; const addUsers = (users: AddUserParams[2], clearIds?: AddUserParams[1]) => { startTransition(async () => { - await addRoleMappings( - environment.spaceId, - users.map((user) => ({ - userId: user.id, - roleId: role.id, - })), - ); - if (clearIds) clearIds(); - router.refresh(); + await wrapServerCall({ + fn: () => + addRoleMappings( + environment.spaceId, + users.map((user) => ({ + userId: user.id, + roleId: role.id, + })), + ), + onSuccess: () => { + if (clearIds) clearIds(); + router.refresh(); + }, + app, + }); }); }; @@ -88,21 +96,28 @@ const RoleMembers: FC<{ const [loading, setLoading] = useState(false); const router = useRouter(); const environment = useEnvironment(); + const app = App.useApp(); async function deleteMembers(userIds: string[], clearIds: () => void) { setLoading(true); - await deleteRoleMappings( - environment.spaceId, - userIds.map((userId) => ({ - roleId: role.id, - userId: userId, - })), - ); + await wrapServerCall({ + fn: () => + deleteRoleMappings( + environment.spaceId, + userIds.map((userId) => ({ + roleId: role.id, + userId: userId, + })), + ), + onSuccess: () => { + router.refresh(); + clearIds(); + }, + app, + }); - clearIds(); setLoading(false); - router.refresh(); } return ( diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/[roleId]/roleGeneralData.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/[roleId]/roleGeneralData.tsx index 1a32431b1..b02cb9513 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/[roleId]/roleGeneralData.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/[roleId]/roleGeneralData.tsx @@ -10,9 +10,10 @@ import { updateRole } from '@/lib/data/roles'; import { useRouter } from 'next/navigation'; import { Role } from '@/lib/data/role-schema'; import { useEnvironment } from '@/components/auth-can'; +import { wrapServerCall } from '@/lib/wrap-server-call'; const RoleGeneralData: FC<{ role: Role }> = ({ role: _role }) => { - const { message } = App.useApp(); + const app = App.useApp(); const ability = useAbilityStore((store) => store.ability); const [form] = Form.useForm(); const router = useRouter(); @@ -40,14 +41,14 @@ const RoleGeneralData: FC<{ role: Role }> = ({ role: _role }) => { delete values.expirationDayJs; } - try { - const result = await updateRole(environment.spaceId, role.id, values); - if (result && 'error' in result) throw new Error(); - router.refresh(); - message.open({ type: 'success', content: 'Role updated' }); - } catch (_) { - message.open({ type: 'error', content: 'Something went wrong' }); - } + await wrapServerCall({ + fn: () => updateRole(environment.spaceId, role.id, values), + onSuccess: () => { + router.refresh(); + app.message.open({ type: 'success', content: 'Role updated' }); + }, + app, + }); } return ( diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/header-actions.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/header-actions.tsx index c99128aac..55b3c2030 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/header-actions.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/header-actions.tsx @@ -1,12 +1,13 @@ 'use client'; import { PlusOutlined } from '@ant-design/icons'; -import { Button, Form, App, Input, Modal, DatePicker } from 'antd'; +import { Button, Form, Input, Modal, DatePicker, App } from 'antd'; import { FC, ReactNode, useEffect, useState } from 'react'; import dayjs from 'dayjs'; import germanLocale from 'antd/es/date-picker/locale/de_DE'; import { AuthCan, useEnvironment } from '@/components/auth-can'; import { addRole as serverAddRoles } from '@/lib/data/roles'; +import { wrapServerCall } from '@/lib/wrap-server-call'; type PostRoleKeys = 'name' | 'description' | 'expiration'; @@ -15,7 +16,8 @@ const CreateRoleModal: FC<{ close: () => void; }> = ({ modalOpen, close }) => { const [form] = Form.useForm(); - const { message: messageApi } = App.useApp(); + const app = App.useApp(); + type ErrorsObject = { [field in PostRoleKeys]?: ReactNode[] }; const [formatError, setFormatError] = useState({}); const environment = useEnvironment(); @@ -44,16 +46,16 @@ const CreateRoleModal: FC<{ if (typeof values.expirationDayJs === 'object') expiration = (values.expirationDayJs as dayjs.Dayjs).toISOString(); - try { - const result = await serverAddRoles(environment.spaceId, { - ...values, - permissions: {}, - environmentId: environment.spaceId, - }); - if (result && 'error' in result) throw new Error(); - } catch (e) { - messageApi.error({ content: 'Something went wrong' }); - } + await wrapServerCall({ + fn: () => + serverAddRoles(environment.spaceId, { + ...values, + permissions: {}, + environmentId: environment.spaceId, + }), + onSuccess: false, + app, + }); }; return ( diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/page.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/page.tsx index 96812c0d6..2d7f3459d 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/page.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/page.tsx @@ -1,17 +1,17 @@ import { getCurrentEnvironment } from '@/components/auth'; import Content from '@/components/content'; -import { getRoles } from '@/lib/data/legacy/iam/roles'; +import { getRoles } from '@/lib/data/DTOs'; import RolesPage from './role-page'; import UnauthorizedFallback from '@/components/unauthorized-fallback'; import { ComponentProps } from 'react'; -import { getUserById } from '@/lib/data/legacy/iam/users'; +import { getUserById } from '@/lib/data/DTOs'; const Page = async ({ params }: { params: { environmentId: string } }) => { const { ability, activeEnvironment } = await getCurrentEnvironment(params.environmentId); if (!ability.can('manage', 'Role')) return ; - const roles = getRoles(activeEnvironment.spaceId, ability); + const roles = await getRoles(activeEnvironment.spaceId, ability); for (let i = 0; i < roles.length; i++) { // @ts-ignore diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/role-page.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/role-page.tsx index 127c4355a..b4e1588b6 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/role-page.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/role-page.tsx @@ -8,7 +8,7 @@ import { AppstoreOutlined, PlusOutlined, } from '@ant-design/icons'; -import { Space, Button, Table, App, Breakpoint, Grid, FloatButton, Tooltip } from 'antd'; +import { Space, Button, Table, Breakpoint, Grid, FloatButton, Tooltip, App } from 'antd'; import { CloseOutlined } from '@ant-design/icons'; import HeaderActions from './header-actions'; import useFuzySearch, { ReplaceKeysWithHighlighted } from '@/lib/useFuzySearch'; @@ -34,6 +34,7 @@ function getMembersRepresentation(members: Role['members']) { const numberOfRows = typeof window !== 'undefined' ? Math.floor((window?.innerHeight - 410) / 47) : 10; +import { wrapServerCall } from '@/lib/wrap-server-call'; import SelectionActions from '@/components/selection-actions'; import { spaceURL, userRepresentation } from '@/lib/utils'; import { AuthenticatedUser } from '@/lib/data/user-schema'; @@ -42,7 +43,7 @@ type ModifiedRole = Role & { members: AuthenticatedUser[] }; export type FilteredRole = ReplaceKeysWithHighlighted; const RolesPage = ({ roles }: { roles: ModifiedRole[] }) => { - const { message: messageApi } = App.useApp(); + const app = App.useApp(); const ability = useAbilityStore((store) => store.ability); const router = useRouter(); const environment = useEnvironment(); @@ -76,16 +77,15 @@ const RolesPage = ({ roles }: { roles: ModifiedRole[] }) => { ); async function deleteRoles(roleIds: string[]) { - try { - const result = await serverDeleteRoles(environment.spaceId, roleIds); - if (result && 'error' in result) throw new Error(); - - setSelectedRowKeys([]); - setSelectedRows([]); - router.refresh(); - } catch (e) { - messageApi.error({ content: 'Something went wrong' }); - } + await wrapServerCall({ + fn: () => serverDeleteRoles(environment.spaceId, roleIds), + onSuccess: () => { + setSelectedRowKeys([]); + setSelectedRows([]); + router.refresh(); + }, + app, + }); } const columns = [ diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/role-side-panel.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/role-side-panel.tsx index 558813fe6..b995c49ec 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/role-side-panel.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/roles/role-side-panel.tsx @@ -37,10 +37,10 @@ const RoleContent: FC<{ {role.members.length} Last Edited - {role.lastEdited} + {role.lastEditedOn.toUTCString()} Created On - {role.createdOn} + {role.createdOn.toUTCString()} {role.members.length > 0 && ( <> diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/users/header-actions.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/users/header-actions.tsx index a28e3dcd3..5658de2ba 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/users/header-actions.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/users/header-actions.tsx @@ -2,6 +2,7 @@ import { AuthCan, useEnvironment } from '@/components/auth-can'; import { inviteUsersToEnvironment } from '@/lib/data/environment-memberships'; +import { wrapServerCall } from '@/lib/wrap-server-call'; import { getRoles } from '@/lib/data/roles'; import { PlusOutlined } from '@ant-design/icons'; import { useQuery } from '@tanstack/react-query'; @@ -26,7 +27,7 @@ const AddUsersModal: FC<{ modalOpen: boolean; close: () => void; }> = ({ modalOpen, close }) => { - const { message: messageApi } = App.useApp(); + const app = App.useApp(); const router = useRouter(); const environment = useEnvironment(); const [form] = Form.useForm(); @@ -60,16 +61,17 @@ const AddUsersModal: FC<{ try { const roleIds = selectedRoles.map((role) => role.value as string); - const result = inviteUsersToEnvironment(environment.spaceId, users, roleIds); - - if (result && 'error' in result) throw new Error(); - - messageApi.success({ content: `User${users.length > 1 ? 's' : ''} invited` }); - closeModal(); - router.refresh(); - } catch (e) { - messageApi.error({ content: 'An error ocurred' }); - } + await wrapServerCall({ + fn: () => inviteUsersToEnvironment(environment.spaceId, users, roleIds), + onSuccess: () => { + app.message.success({ content: `User${users.length > 1 ? 's' : ''} invited` }); + router.refresh(); + closeModal(); + }, + app, + }); + close(); + } catch (_) {} }); }; diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/users/page.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/users/page.tsx index 13b7c6c67..a75202b41 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/users/page.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/users/page.tsx @@ -2,27 +2,19 @@ import { getCurrentEnvironment } from '@/components/auth'; import UsersPage from './users-page'; import Content from '@/components/content'; import UnauthorizedFallback from '@/components/unauthorized-fallback'; -import { getMemebers } from '@/lib/data/legacy/iam/memberships'; -import { getUserById } from '@/lib/data/legacy/iam/users'; -import { AuthenticatedUser } from '@/lib/data/user-schema'; -import { ComponentProps } from 'react'; -import { getRoleMappingByUserId } from '@/lib/data/legacy/iam/role-mappings'; -import { getRoleById } from '@/lib/data/legacy/iam/roles'; +import { getMembers } from '@/lib/data/DTOs'; +import { getUserById } from '@/lib/data/DTOs'; +import { AuthenticatedUser, User } from '@/lib/data/user-schema'; +import { asyncMap } from '@/lib/helpers/javascriptHelpers'; const Page = async ({ params }: { params: { environmentId: string } }) => { const { ability, activeEnvironment } = await getCurrentEnvironment(params.environmentId); - if (!ability.can('manage', 'User')) return ; - const memberships = getMemebers(activeEnvironment.spaceId, ability); - const users: ComponentProps['users'] = memberships.map((user) => ({ - ...getUserById(user.userId), - })) as AuthenticatedUser[]; - - for (const user of users) { - const mappings = getRoleMappingByUserId(user.id, activeEnvironment.spaceId); - if (mappings.length > 0) user.roles = mappings.map((mapping) => getRoleById(mapping.roleId)); - } + const memberships = await getMembers(activeEnvironment.spaceId, ability); + const users = (await asyncMap<(typeof memberships)[0], User>(memberships, (user) => + getUserById(user.userId), + )) as AuthenticatedUser[]; return ( diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/users/users-page.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/users/users-page.tsx index b0db6e799..401fa000d 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/iam/users/users-page.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/iam/users/users-page.tsx @@ -11,10 +11,11 @@ import { useRouter } from 'next/navigation'; import { AuthenticatedUser } from '@/lib/data/user-schema'; import { removeUsersFromEnvironment } from '@/lib/data/environment-memberships'; import { useEnvironment } from '@/components/auth-can'; +import { wrapServerCall } from '@/lib/wrap-server-call'; import { Role } from '@/lib/data/role-schema'; const UsersPage: FC<{ users: (AuthenticatedUser & { roles?: Role[] })[] }> = ({ users }) => { - const { message: messageApi } = App.useApp(); + const app = App.useApp(); const breakpoint = Grid.useBreakpoint(); const [selectedUser, setSelectedUser] = useState(null); const [deletingUser, startTransition] = useTransition(); @@ -24,15 +25,16 @@ const UsersPage: FC<{ users: (AuthenticatedUser & { roles?: Role[] })[] }> = ({ const environment = useEnvironment(); async function removeUsers(ids: string[], unsetIds: () => void) { - startTransition(async () => { - const result = await removeUsersFromEnvironment(environment.spaceId, ids); - - if (result && 'error' in result) - messageApi.open({ type: 'error', content: 'Something went wrong' }); - - unsetIds(); - router.refresh(); - }); + startTransition(() => + wrapServerCall({ + fn: () => removeUsersFromEnvironment(environment.spaceId, ids), + onSuccess: () => { + router.refresh(); + unsetIds(); + }, + app, + }), + ); } return ( diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/layout-client.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/layout-client.tsx index 32fa40742..cea52e826 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/layout-client.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/layout-client.tsx @@ -69,7 +69,7 @@ const Layout: FC< (item) => !(item && 'type' in item && item.type === 'divider'), ); - if (userData && !userData.guest) { + if (userData && !userData.isGuest) { layoutMenuItems = [ { label: 'Profile', @@ -110,8 +110,8 @@ const Layout: FC< return ( - - {userData && !userData.guest ? ( + + {userData && !userData.isGuest ? ( getEnvironmentById(envId)); - const userEnvironments: Environment[] = [getEnvironmentById(userId)]; - userEnvironments.push( - ...getUserOrganizationEnvironments(userId).map((environmentId) => - getEnvironmentById(environmentId), - ), - ); + userEnvironments.push(...orgEnvironments); const userRules = systemAdmin ? (adminRules as RemoveReadOnly) @@ -202,7 +201,7 @@ const DashboardLayout = async ({ } let logo; - if (activeEnvironment.isOrganization && organizationHasLogo(activeEnvironment.spaceId)) + if (activeEnvironment.isOrganization && (await organizationHasLogo(activeEnvironment.spaceId))) logo = `/api/private/${activeEnvironment.spaceId}/logo`; return ( @@ -210,7 +209,7 @@ const DashboardLayout = async ({ = ({ )} + = ({ values: { name: string; description: string; + copyTarget: boolean; }[], ) => { - const { name, description } = values[0]; + const { name, description, copyTarget } = values[0]; if (createConfigType === 'target') { - await addTargetConfig(parentConfig.id, defaultTargetConfiguration(name, description)); + await addTargetConfig( + parentConfig.id, + defaultTargetConfiguration(parentConfig.environmentId, name, description), + ); } else { - await addMachineConfig(parentConfig.id, defaultMachineConfiguration(name, description)); + if (copyTarget && parentConfig.targetConfig) { + await addMachineConfig( + parentConfig.id, + customMachineConfiguration( + parentConfig.environmentId, + name, + description, + parentConfig.targetConfig, + ), + true, + ); + } else { + await addMachineConfig( + parentConfig.id, + defaultMachineConfiguration(parentConfig.environmentId, name, description), + ); + } } setCreateConfigType(''); router.refresh(); @@ -320,6 +344,7 @@ const ConfigEditor: React.FC = ({ )} */} + {editable && ( = ({ /> )} + onChangeEditable(e.target.value === 'edit')} > - View{' '} + View + - Edit{' '} + Edit + + = ({ )} + setCreateConfigType('')} onSubmit={handleCreateConfig} + configType={createConfigType} + targetConfigExists={!!parentConfig.targetConfig} /> ); diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/machine-config/[configId]/config-page-content.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/machine-config/[configId]/config-page-content.tsx index 32516e182..edb607fc3 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/machine-config/[configId]/config-page-content.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/machine-config/[configId]/config-page-content.tsx @@ -46,7 +46,7 @@ const ParameterTreeNode: React.FC<{ const { displayName, value, unit, language } = parameter.content[0]; node = ( {displayName}:{value} {unit}({language}) @@ -215,6 +215,10 @@ const ConfigContent: React.FC = ({ parentConfig }) => { }} >
+ + {siderOpen && ( )} - -
{siderOpen && (
= ({ parentConfig }) => { setSelectionId(selection ? selection.id : ''); setSelectionType(selection ? selection.type : 'config'); }} - parentConfig={parentConfig} />
)} diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/machine-config/[configId]/config-tree-view.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/machine-config/[configId]/config-tree-view.tsx index a1364d1fb..9109fb6e1 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/machine-config/[configId]/config-tree-view.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/machine-config/[configId]/config-tree-view.tsx @@ -12,8 +12,10 @@ import { EventDataNode } from 'antd/es/tree'; import { useRouter } from 'next/navigation'; import { Key, useMemo, useState } from 'react'; import { + customMachineConfiguration, defaultMachineConfiguration, defaultParameter, + defaultTargetConfiguration, findConfig, findParameter, } from '../configuration-helper'; @@ -82,23 +84,30 @@ const ConfigurationTreeView: React.FC = ({ values: { name: string; description: string; + copyTarget: boolean; }[], ) => { if (openModal !== 'target-config' && openModal !== 'machine-config') return; - const { name, description } = values[0]; - const newConfig = { - ...defaultMachineConfiguration(name, description), - type: openModal, - environmentId: parentConfig.environmentId, - }; - - if (openModal === 'machine-config') - await addMachineConfig(parentConfig.id, newConfig as MachineConfig); - else if (openModal === 'target-config') - await addTargetConfig(parentConfig.id, newConfig as TargetConfig); + const { name, description, copyTarget } = values[0]; + + if (openModal === 'target-config') { + const newConfig = defaultTargetConfiguration(parentConfig.environmentId, name, description); + await addTargetConfig(parentConfig.id, newConfig); + } else if (copyTarget && parentConfig.targetConfig) { + const newConfig = customMachineConfiguration( + parentConfig.environmentId, + name, + description, + parentConfig.targetConfig, + ); + await addMachineConfig(parentConfig.id, newConfig, true); + } else { + const newConfig = defaultMachineConfiguration(parentConfig.environmentId, name, description); + await addMachineConfig(parentConfig.id, newConfig); + } - router.refresh(); closeModal(); + router.refresh(); }; const addParameter = async ( @@ -227,6 +236,7 @@ const ConfigurationTreeView: React.FC = ({ onExpand={(keys: React.Key[]) => onExpandedChange(keys.map((key) => key.toString()))} /> + = ({ onCancel={closeModal} >

- Are you sure you want to delete the configuration {selectionName} with id{' '} - {rightClickedNode.id}? + Are you sure you want to delete the configuration {selectionName} with ID{' '} + {rightClickedNode.id}

+ + = ({ okText="Create" showKey /> + = ({ keyId, parameter, editable, p const { token } = theme.useToken(); const [createFieldOpen, setCreateFieldOpen] = useState(false); + const [deleteFieldOpen, setDeleteFieldOpen] = useState(false); - // TODO: check if this actually works when given the newest data after refresh - const currentKeyRef = useRef(keyId); - useEffect(() => { - if (keyId !== currentKeyRef.current) currentKeyRef.current = keyId; - }, [keyId]); - const restoreKey = () => { - currentKeyRef.current = keyId; + const handleKeyChange = async (newKey: string) => { + if (!newKey) return; + await updateParameter(parameter.id!, { key: newKey }); + router.refresh(); }; - const saveKey = async () => { - if (!currentKeyRef.current) return; - await updateParameter(parameter.id!, { key: currentKeyRef.current }); + + const handleDeleteConfirm = async () => { + if (parameter.id) await removeParameter(parameter.id); + setCreateFieldOpen(false); router.refresh(); }; @@ -149,33 +162,44 @@ const CustomField: React.FC = ({ keyId, parameter, editable, p return ( - , - tooltip: 'Edit Parameter Key', - onCancel: restoreKey, - onChange: (newValue) => (currentKeyRef.current = newValue), - onEnd: saveKey, - enterIcon: , +
+ , + tooltip: 'Edit Parameter Key', + onChange: handleKeyChange, + enterIcon: , + } } - } - > - {currentKeyRef.current[0].toUpperCase() + currentKeyRef.current.slice(1) /*TODO */} - + > + {keyId} + + {editable && ( + + +
+ - + + {(editable || (parameter.linkedParameters && parameter.linkedParameters.length > 0)) && ( Linked Parameters + {editable && ( @@ -191,6 +215,7 @@ const CustomField: React.FC = ({ keyId, parameter, editable, p /> )} + {!editable && parameter.linkedParameters.map((paramId: string) => ( @@ -198,6 +223,7 @@ const CustomField: React.FC = ({ keyId, parameter, editable, p ))} + {/* )} - { - importItems(file); - return false; // Prevent automatic upload - }} - > - - - - + + )} @@ -370,17 +375,19 @@ const ParentConfigList: React.FC = ({ data }) => { /> - setOpenDeleteModal(false)} - description="Are you sure you want to delete the selected configuration(s)?" - onConfirm={() => deleteItems(selectedRowElements)} - buttonProps={{ - icon: , - type: 'text', - }} - /> + <> + setOpenDeleteModal(false)} + description="Are you sure you want to delete the selected configuration(s)?" + onConfirm={() => deleteItems(selectedRowElements)} + buttonProps={{ + icon: , + type: 'text', + }} + /> + @@ -421,7 +428,7 @@ const ParentConfigList: React.FC = ({ data }) => { 1 ? 'es' : ''}`} + title={`Edit Machine Config${selectedRowKeys.length > 1 ? 'urations' : ''}`} onCancel={() => setOpenEditModal(false)} initialData={ editingItem diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/organization-settings/page.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/organization-settings/page.tsx index 9bc0b99a8..db78e27f1 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/organization-settings/page.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/organization-settings/page.tsx @@ -5,7 +5,7 @@ import Content from '@/components/content'; import Title from 'antd/es/typography/Title'; import { redirect } from 'next/navigation'; import SpaceSettings from './space-settings'; -import { getEnvironmentById, organizationHasLogo } from '@/lib/data/legacy/iam/environments'; +import { getEnvironmentById, organizationHasLogo } from '@/lib/data/DTOs'; import { OrganizationEnvironment } from '@/lib/data/environment-schema'; import DeleteOrganizationButton from './delete-organization-button'; import { AuthCan } from '@/components/auth-can'; @@ -15,8 +15,10 @@ const GeneralSettingsPage = async ({ params }: { params: { environmentId: string if (!activeEnvironment.isOrganization || !ability.can('manage', 'Environment')) return redirect('/'); - const organization = getEnvironmentById(activeEnvironment.spaceId) as OrganizationEnvironment; - const hasLogo = organizationHasLogo(activeEnvironment.spaceId); + const organization = (await getEnvironmentById( + activeEnvironment.spaceId, + )) as OrganizationEnvironment; + const hasLogo = await organizationHasLogo(activeEnvironment.spaceId); return ( diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/DragAndDropHandler.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/DragAndDropHandler.tsx index 9fed5ffb4..033875140 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/DragAndDropHandler.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/DragAndDropHandler.tsx @@ -4,13 +4,10 @@ import { pointerWithin, useSensor, useSensors, - KeyboardSensor, PointerSensor, DragOverlay, ClientRect, - Collision, getClientRect, - UniqueIdentifier, } from '@dnd-kit/core'; import { Active, DroppableContainer, RectMap } from '@dnd-kit/core/dist/store'; import { Coordinates } from '@dnd-kit/utilities'; @@ -50,10 +47,7 @@ const EditorDnDHandler: React.FC = ({ const pointerPosition = useRef({ x: 0, y: 0 }); - const sensors = useSensors( - useSensor(PointerSensor, { activationConstraint: { delay: 100, tolerance: 10 } }), - useSensor(KeyboardSensor), - ); + const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 10 } })); /** * This function is used to calculate the most likely changes to a target elements bounding box if the dragged element would be removed from its current position diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/_sidebar/Settings.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/_sidebar/Settings.tsx index d09b262a9..a906a1a90 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/_sidebar/Settings.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/_sidebar/Settings.tsx @@ -30,7 +30,7 @@ export const Settings: React.FC = () => { return (
- {/* this id is targeted by a react portal to render the text editor interface when a user start text editing */} + {/* this id is targeted by a react portal to render the text editor interface when a user starts text editing */}
{!isTextEditing && (selectedNodeId ? ( @@ -42,6 +42,8 @@ export const Settings: React.FC = () => { ) : (
No element selected.
))} + {/* this id is targeted by react portals that can be used in elements to edit specific sections of the element*/} +
); }; diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/_sidebar/Toolbox.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/_sidebar/Toolbox.tsx index 834cf8982..27f9c024f 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/_sidebar/Toolbox.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/_sidebar/Toolbox.tsx @@ -63,19 +63,25 @@ const Toolbox = () => { return (
}> - + }> - + }> - + }> - + }> - + }> diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/_utils/EditableText.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/_utils/EditableText.tsx index a832f83f5..33950f7f8 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/_utils/EditableText.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/_utils/EditableText.tsx @@ -12,48 +12,57 @@ type EditableTextProps = Omit< 'onChange' | 'value' > & { value: string; + active: boolean; + onStopEditing?: () => void; onChange: (newText: string) => void; tagName: T; }; function EditableText({ value, + active, + onStopEditing, onChange, tagName, ...props }: EditableTextProps) { const { editingEnabled } = useEditor((state) => ({ editingEnabled: state.options.enabled })); - const [active, setActive] = useState(false); const selectingText = useRef(false); + const externalEditingStart = useRef(false); const editorRef = useRef(null); const frame = useFrame(); + useEffect(() => { + if (active) externalEditingStart.current = true; + }, [active]); + // if the editor is disabled make sure that this is also disabled useEffect(() => { if (!editingEnabled) { - setActive(false); + onStopEditing?.(); } }, [editingEnabled]); useEffect(() => { const handleClick = async () => { - if (!selectingText.current) { + if (!selectingText.current && !externalEditingStart.current) { // when not selecting text disable this element when the mouse is released outside of it if (editorRef.current) { onChange(await editorRef.current.getCurrentValue()); } - setActive(false); + onStopEditing?.(); } else { selectingText.current = false; + externalEditingStart.current = false; } }; frame.window?.addEventListener('click', handleClick); return () => { frame.window?.removeEventListener('click', handleClick); }; - }, [frame, onChange]); + }, [frame, onChange, onStopEditing]); return ( <> @@ -72,7 +81,7 @@ function EditableText({ if (editorRef.current) { onChange(await editorRef.current.getCurrentValue()); } - setActive(false); + onStopEditing?.(); e.stopPropagation(); e.preventDefault(); } @@ -88,12 +97,7 @@ function EditableText({ ) : ( React.createElement(tagName, { dangerouslySetInnerHTML: { __html: value }, - onDoubleClick: () => { - if (editingEnabled) { - setActive(true); - } - }, - onClick: (e: MouseEvent) => !e.ctrlKey && e.preventDefault(), + onClick: (e: MouseEvent) => !(e.ctrlKey || e.metaKey) && e.preventDefault(), ...props, }) )} diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/_utils/lexical-text-editor.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/_utils/lexical-text-editor.tsx index a5c1bfacb..aab07c593 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/_utils/lexical-text-editor.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/_utils/lexical-text-editor.tsx @@ -1,11 +1,4 @@ -import React, { - useEffect, - useMemo, - useRef, - useState, - forwardRef, - useImperativeHandle, -} from 'react'; +import React, { useEffect, useMemo, useState, forwardRef, useImperativeHandle, useId } from 'react'; import { createPortal } from 'react-dom'; @@ -116,13 +109,19 @@ const LexicalTextEditor = forwardRef( ({ value, disabled, ...contentEditableProps }, ref) => { const setIsTextEditing = useBuilderStateStore((state) => state.setIsTextEditing); + const id = useId(); + const blockDragging = useBuilderStateStore((state) => state.blockDragging); + const unblockDragging = useBuilderStateStore((state) => state.unblockDragging); + useEffect(() => { // signal that we started editing text on mount setIsTextEditing(true); + blockDragging(id); return () => { // signal the end of text editing on unmount setIsTextEditing(false); + unblockDragging(id); }; }, []); diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/elements/CheckboxOrRadioGroup.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/elements/CheckboxOrRadioGroup.tsx index 41340f016..dacae4c86 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/elements/CheckboxOrRadioGroup.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/elements/CheckboxOrRadioGroup.tsx @@ -1,17 +1,35 @@ -import { useId } from 'react'; +import { useEffect, useId, useMemo, useState } from 'react'; -import { Select, Button } from 'antd'; +import { Divider, Input, MenuProps, Select, Space, Tooltip } from 'antd'; +import { InfoCircleOutlined } from '@ant-design/icons'; +import { TbRowInsertTop, TbRowInsertBottom, TbRowRemove } from 'react-icons/tb'; + +import cn from 'classnames'; import { UserComponent, useEditor, useNode } from '@craftjs/core'; import EditableText from '../_utils/EditableText'; -import { Setting } from '../utils'; +import { + Setting, + ContextMenu, + Overlay, + SidebarButtonFactory, + MenuItemFactoryFactory, +} from './utils'; import { WithRequired } from '@/lib/typescript-utils'; +import { SettingOutlined, EditOutlined } from '@ant-design/icons'; +import { createPortal } from 'react-dom'; + +const checkboxValueHint = + 'This will be the value that is added to the variable associated with this group when the checkbox is checked at the time the form is submitted.'; +const radioValueHint = + 'This will be the value that is assigned to the variable associated with this group when the radio button is selected at the time the form is submitted.'; + type CheckBoxOrRadioGroupProps = { type: 'checkbox' | 'radio'; variable?: string; - data?: { label: string; value: string; checked?: boolean }[]; + data: { label: string; value: string; checked: boolean }[]; }; type CheckBoxOrRadioButtonProps = WithRequired< @@ -20,9 +38,15 @@ type CheckBoxOrRadioButtonProps = WithRequired< > & { label: string; value: string; - checked?: boolean; + checked: boolean; onChange: () => void; onLabelChange: (newLabel: string) => void; + onEdit?: () => void; +}; + +const getNewElementLabel = (type: CheckBoxOrRadioButtonProps['type']) => { + if (type === 'checkbox') return 'New Checkbox'; + else return 'New Radio Button'; }; const CheckboxOrRadioButton: React.FC = ({ @@ -33,8 +57,11 @@ const CheckboxOrRadioButton: React.FC = ({ checked, onChange, onLabelChange, + onEdit, }) => { const id = useId(); + const [hovered, setHovered] = useState(false); + const [textEditing, setTextEditing] = useState(false); return ( <> @@ -47,38 +74,83 @@ const CheckboxOrRadioButton: React.FC = ({ onClick={onChange} onChange={onChange} /> - { - e.preventDefault(); - e.stopPropagation(); - }} - /> + setHovered(true)}> + setHovered(false)} + controls={[ + { + icon: setTextEditing(true)} />, + key: 'edit', + }, + { + icon: onEdit?.()} />, + key: 'setting', + }, + ]} + > + setTextEditing(false)} + onChange={onLabelChange} + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + }} + /> + + ); }; +const menuOptions = { + 'add-above': { label: 'Above', icon: }, + 'add-below': { label: 'Below', icon: }, + remove: { label: 'Remove', icon: }, +} as const; + +type EditAction = keyof typeof menuOptions; + +const SidebarButton = SidebarButtonFactory(menuOptions); +const toMenuItem = MenuItemFactoryFactory(menuOptions); + const CheckBoxOrRadioGroup: UserComponent = ({ type, variable = 'test', - data = [{ label: 'Double-Click Me', value: '', checked: false }], + data, }) => { const { query, editingEnabled } = useEditor((state) => ({ editingEnabled: state.options.enabled, })); + const [editTarget, setEditTarget] = useState(); + const [hoveredAction, setHoveredAction] = useState(); + const [currentValue, setCurrentValue] = useState(''); + const { connectors: { connect }, actions: { setProp }, - isHovered, + isSelected, } = useNode((state) => { const parent = state.data.parent && query.node(state.data.parent).get(); - return { isHovered: !!parent && parent.events.hovered }; + return { + isHovered: !!parent && parent.events.hovered, + isSelected: !!parent && parent.events.selected, + }; }); + useEffect(() => { + if (!isSelected) { + setEditTarget(undefined); + setHoveredAction(undefined); + setCurrentValue(''); + } + }, [isSelected]); + const handleLabelEdit = (index: number, text: string) => { if (!editingEnabled) return; setProp((props: CheckBoxOrRadioGroupProps) => { @@ -92,6 +164,19 @@ const CheckBoxOrRadioGroup: UserComponent = ({ }); }; + const handleValueChange = (index: number, value: string) => { + if (!editingEnabled) return; + setProp((props: CheckBoxOrRadioGroupProps) => { + props.data = data.map((entry, entryIndex) => { + let newValue = entry.value; + + if (entryIndex === index) newValue = value; + + return { ...entry, value: newValue }; + }); + }); + }; + const handleClick = (index: number) => { if (!editingEnabled) return; setProp((props: CheckBoxOrRadioGroupProps) => { @@ -106,9 +191,9 @@ const CheckBoxOrRadioGroup: UserComponent = ({ if (!editingEnabled) return; setProp((props: CheckBoxOrRadioGroupProps) => { props.data = [ - ...data.slice(0, index + 1), - { label: 'Double-Click Me', value: '', checked: false }, - ...data.slice(index + 1), + ...data.slice(0, index), + { label: getNewElementLabel(type), value: '', checked: false }, + ...data.slice(index), ]; }); }; @@ -120,54 +205,180 @@ const CheckBoxOrRadioGroup: UserComponent = ({ }); }; - return ( -
{ - r && connect(r); - }} - > -
- {data.map(({ label, value, checked }, index) => ( - - handleClick(index)} - onLabelChange={(newLabel) => handleLabelEdit(index, newLabel)} - /> - {editingEnabled && isHovered && ( - - )} - {editingEnabled && isHovered && data.length > 1 && ( -
+ ), + }, + ] + : []; + + const dataWithPreviews = useMemo(() => { + type DataOrPreview = (typeof data)[number] & { + isAddPreview?: boolean; + isRemovePreview?: boolean; + isEditTarget?: boolean; + }; + const dataCopy: DataOrPreview[] = data.map((entry, index) => { + return { + ...entry, + isEditTarget: editTarget === index, + isRemovePreview: editTarget === index && hoveredAction === 'remove', + }; + }); + + if (editTarget !== undefined) { + const addPreview = { + label: getNewElementLabel(type), + value: '', + isAddPreview: true, + checked: false, + }; + if (hoveredAction === 'add-above') dataCopy.splice(editTarget, 0, addPreview); + else if (hoveredAction === 'add-below') dataCopy.splice(editTarget + 1, 0, addPreview); + } + + return dataCopy; + }, [data, editTarget, hoveredAction]); + + return ( + { + setEditTarget(undefined); + setHoveredAction(undefined); + setCurrentValue(''); + }} + > +
{ + r && connect(r); + }} + > +
+ {dataWithPreviews.map( + ({ label, value, checked, isAddPreview, isRemovePreview, isEditTarget }, index) => ( +
{ + setCurrentValue(value); + setEditTarget(index); + e.preventDefault(); }} - title={`Remove ${type === 'checkbox' ? 'Checkbox' : 'Radio Button'}`} - onClick={() => handleRemoveButton(index)} + key={index} > - - - + handleClick(index)} + onLabelChange={(newLabel) => handleLabelEdit(index, newLabel)} + onEdit={() => { + setCurrentValue(value); + setEditTarget(index); + }} + /> +
+ ), + )} + {editTarget !== undefined && + createPortal( + <> + {type === 'checkbox' ? 'Checkbox' : 'Radio'} Settings + + + { + handleAddButton(editTarget); + setEditTarget(editTarget + 1); + }} + onHovered={setHoveredAction} + /> + handleAddButton(editTarget + 1)} + onHovered={setHoveredAction} + /> + { + handleRemoveButton(editTarget); + setEditTarget(undefined); + setCurrentValue(''); + setHoveredAction(undefined); + }} + onHovered={setHoveredAction} + /> + + + setCurrentValue(e.target.value)} + onClick={(e) => e.stopPropagation()} + onBlur={() => handleValueChange(editTarget, currentValue)} + /> + } + /> + + + , + document.getElementById('sub-element-settings-toolbar')!, )} - - ))} +
-
+ ); }; @@ -186,6 +397,7 @@ export const CheckBoxOrRadioGroupSettings = () => { label="Variable" control={ defaultEditable && e.stopPropagation()} - onDoubleClick={(e) => { - if (!editingEnabled) return; - e.currentTarget.focus(); - setDefaultEditable(true); + return ( + +
{ + r && connect(r); }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - e.currentTarget.blur(); - setDefaultEditable(false); - } + className="user-task-form-input" + style={{ + display: 'flex', + flexDirection: labelPosition === 'top' ? 'column' : 'row', + alignItems: labelPosition === 'left' ? 'baseline' : undefined, }} - onBlur={() => setDefaultEditable(false)} - onChange={(e) => setProp((props: InputProps) => (props.defaultValue = e.target.value))} - /> -
+ > + {labelPosition !== 'none' && ( +
setLabelHovered(true)} + > + setLabelHovered(false)} + controls={[ + { + key: 'edit', + icon: setTextEditing(true)} />, + }, + ]} + > + setTextEditing(false)} + tagName="label" + htmlFor={inputId} + onClick={(e) => e.preventDefault()} + onChange={(newText) => setProp((props: InputProps) => (props.label = newText))} + /> + +
+ )} + + { + if (!editingEnabled) return; + setEditingDefault(true); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') e.currentTarget.blur(); + }} + onBlur={() => setEditingDefault(false)} + onChange={(e) => setProp((props: InputProps) => (props.defaultValue = e.target.value))} + /> + +
); }; @@ -67,8 +111,10 @@ export const InputSettings = () => { const { actions: { setProp }, type, + labelPosition, } = useNode((node) => ({ type: node.data.props.type, + labelPosition: node.data.props.labelPosition, })); const { editingEnabled } = useEditor((state) => ({ editingEnabled: state.options.enabled })); @@ -78,6 +124,7 @@ export const InputSettings = () => { label="Type" control={
- ) : ( - - )} - + return React.createElement( + type, + { + style, + className: cn('user-task-form-table-cell', { + 'target-sub-element': isEditTarget && !isRemovePreview, + 'sub-element-add-preview': isAddPreview, + 'sub-element-remove-preview': isRemovePreview, + }), + onContextMenu: onEdit, + onMouseEnter: () => setHovered(true), + }, + setHovered(false)} + controls={[ + { + icon: setTextEditing(true)} />, + key: 'edit', + }, + { + icon: , + key: 'setting', + }, + ]} + > + setTextEditing(false)} + /> + , ); }; -const TableRow: React.FC<{ - tableData: Required['tableData']; +const menuOptions = { + 'remove-row': { label: 'Delete Row', icon: }, + 'remove-col': { label: 'Delete Column', icon: }, + 'add-row-above': { label: 'Add Row Above', icon: }, + 'add-row-below': { label: 'Add Row Below', icon: }, + 'add-col-left': { label: 'Add Column Before', icon: }, + 'add-col-right': { label: 'Add Column After', icon: }, +} as const; + +type CellAction = keyof typeof menuOptions; +const toMenuItem = MenuItemFactoryFactory(menuOptions); + +const SidebarButton = SidebarButtonFactory(menuOptions); + +type TableRowProps = { + tableRowData: CellDataWithPreviews[]; rowIndex: number; - isHovered: boolean; - addColumn: (colIndex: number) => void; - removeColumn: (colIndex: number) => void; - addRow: (rowIndex: number) => void; - removeRow: (rowIndex: number) => void; onUpdateContent: (newContent: string, rowIndex: number, colIndex: number) => void; -}> = ({ - tableData, + onEditCell: (rowIndex: number, colIndex: number) => void; +}; + +const TableRow: React.FC = ({ + tableRowData, rowIndex, - isHovered, - addColumn, - removeColumn, - addRow, - removeRow, onUpdateContent, + onEditCell, }) => { - const { editingEnabled } = useEditor((state) => ({ editingEnabled: state.options.enabled })); - return ( - - {tableData[rowIndex].map((col, colIndex) => ( - onUpdateContent(newContent, rowIndex, colIndex)} - > - {/* remove a column (cannot remove if there is only a single row) */} - {editingEnabled && - isHovered && - rowIndex === tableData.length - 1 && - tableData.length > 1 && ( - - )} - - {/* add a column at the start or between two other columns */} - {editingEnabled && isHovered && !rowIndex && ( - - )} - - {/* add a column at the end */} - {editingEnabled && isHovered && !rowIndex && colIndex === tableData[0].length - 1 && ( - - )} - - {/* remove a row (the header row cannot be removed) */} - {editingEnabled && isHovered && !!rowIndex && !colIndex && ( - - )} - - {/* add a new row (cannot add a row before the header row) */} - {editingEnabled && isHovered && colIndex === tableData[0].length - 1 && ( - // TODO: Seems not to work if the button is clicked outside of the borders of the table - - )} - - ))} - + <> + + {tableRowData.map((col, colIndex) => ( + onEditCell(rowIndex, colIndex)} + onChange={(newContent) => onUpdateContent(newContent, rowIndex, colIndex)} + key={`table-cell-${rowIndex}-${colIndex}`} + /> + ))} + + ); }; const Table: UserComponent = ({ tableData = [ - ['Double Click Me', 'Double Click Me'], - ['Double Click Me', 'Double Click Me'], + [defaultHeaderContent, defaultHeaderContent], + [defaultContent, defaultContent], ], }) => { const { query, editingEnabled } = useEditor((state) => ({ @@ -166,19 +145,29 @@ const Table: UserComponent = ({ const { connectors: { connect }, actions: { setProp }, - isHovered, + isSelected, } = useNode((state) => { const parent = state.data.parent && query.node(state.data.parent).get(); - return { isHovered: !!parent && parent.events.hovered }; + return { isSelected: !!parent && parent.events.selected }; }); + const [targetCell, setTargetCell] = useState<{ row: number; col: number }>(); + const [hoveredAction, setHoveredAction] = useState(); + + useEffect(() => { + if (!isSelected) { + setTargetCell(undefined); + setHoveredAction(undefined); + } + }, [isSelected]); + const addRow = (index: number) => { if (!editingEnabled) return; setProp((props: TableProps) => { props.tableData = [ ...tableData.slice(0, index), - Array.from({ length: tableData[0].length }, () => 'Double Click Me'), + Array.from({ length: tableData[0].length }, () => defaultContent), ...tableData.slice(index), ]; }); @@ -193,9 +182,9 @@ const Table: UserComponent = ({ const addColumn = (index: number) => { if (!editingEnabled) return; setProp((props: TableProps) => { - props.tableData = tableData.map((row) => [ + props.tableData = tableData.map((row, rowIndex) => [ ...row.slice(0, index), - 'Double Click Me', + rowIndex ? defaultContent : defaultHeaderContent, ...row.slice(index), ]); }); @@ -229,41 +218,203 @@ const Table: UserComponent = ({ }); }; + const contextMenu: MenuProps['items'] = []; + + if (targetCell) { + const add: NonNullable[number] = { + key: 'table-add', + label: 'Add', + children: [], + }; + contextMenu.push(add); + const { row, col } = targetCell; + if (row) { + add.children.push(toMenuItem('add-row-above', () => addRow(row), setHoveredAction)); + } + add.children.push( + toMenuItem('add-row-below', () => addRow(row + 1), setHoveredAction), + toMenuItem('add-col-left', () => addColumn(col), setHoveredAction), + toMenuItem('add-col-right', () => addColumn(col + 1), setHoveredAction), + ); + + const deleteOptions: NonNullable[number] = { + key: 'table-remove', + label: 'Remove', + children: [], + }; + contextMenu.push(deleteOptions); + if (row) { + deleteOptions.children.push(toMenuItem('remove-row', () => removeRow(row), setHoveredAction)); + } + if (tableData[0].length > 1) { + deleteOptions.children.push( + toMenuItem('remove-col', () => removeColumn(col), setHoveredAction), + ); + } + } + + const tableDataWithPreviews = useMemo(() => { + const dataCopy = tableData.map((row, rowIndex) => { + const rowCopy: CellDataWithPreviews[] = row.map((content, colIndex) => { + if (targetCell) { + return { + content, + isEditTarget: targetCell.row === rowIndex && targetCell.col === colIndex, + isRemovePreview: + (hoveredAction === 'remove-row' && targetCell.row === rowIndex) || + (hoveredAction === 'remove-col' && targetCell.col === colIndex), + }; + } + return { content }; + }); + + if (targetCell) { + if (hoveredAction === 'add-col-left') { + rowCopy.splice(targetCell.col, 0, { + content: rowIndex ? defaultContent : defaultHeaderContent, + isAddPreview: true, + }); + } else if (hoveredAction === 'add-col-right') { + rowCopy.splice(targetCell.col + 1, 0, { + content: rowIndex ? defaultContent : defaultHeaderContent, + isAddPreview: true, + }); + } + } + + return rowCopy; + }); + if (targetCell) { + if (hoveredAction === 'add-row-above') { + dataCopy.splice( + targetCell.row, + 0, + tableData[0].map(() => ({ + content: defaultContent, + isAddPreview: true, + })), + ); + } else if (hoveredAction === 'add-row-below') { + dataCopy.splice( + targetCell.row + 1, + 0, + tableData[0].map(() => ({ + content: defaultContent, + isAddPreview: true, + })), + ); + } + } + + return dataCopy; + }, [tableData, targetCell, hoveredAction]); + return ( -
- - {children} - - - {children} -
{ - r && connect(r); + { + setTargetCell(undefined); + setHoveredAction(undefined); }} > - - - - - {[...Array(tableData.length - 1).keys()].map((index) => ( +
{ + r && connect(r); + }} + > + { + setTargetCell({ row, col }); + }} /> - ))} - -
+ + + {[...Array(tableDataWithPreviews.length - 1).keys()].map((index) => ( + { + setTargetCell({ row, col }); + }} + /> + ))} + + + {isSelected && + targetCell && + createPortal( + <> + Cell Settings + + + { + addRow(targetCell.row); + setTargetCell({ + ...targetCell, + row: targetCell.row + 1, + }); + }} + onHovered={setHoveredAction} + /> + addRow(targetCell.row + 1)} + onHovered={setHoveredAction} + /> + { + removeRow(targetCell.row); + setTargetCell(undefined); + setHoveredAction(undefined); + }} + onHovered={setHoveredAction} + /> + + + { + addColumn(targetCell.col); + setTargetCell({ + ...targetCell, + col: targetCell.col + 1, + }); + }} + onHovered={setHoveredAction} + /> + addColumn(targetCell.col + 1)} + onHovered={setHoveredAction} + /> + { + removeColumn(targetCell.col); + setTargetCell(undefined); + setHoveredAction(undefined); + }} + onHovered={setHoveredAction} + /> + + + , + document.getElementById('sub-element-settings-toolbar')!, + )} + ); }; @@ -275,10 +426,10 @@ Table.craft = { tableData: [ [ // setting the th elements font weight to normal and giving lexical this as the default to enable changing the font weight in the editor - 'Double Click Me', - 'Double Click Me', + defaultHeaderContent, + defaultHeaderContent, ], - ['Double Click Me', 'Double Click Me'], + [defaultContent, defaultContent], ], }, }; diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/elements/Text.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/elements/Text.tsx index 7f5e636fd..524584886 100644 --- a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/elements/Text.tsx +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/elements/Text.tsx @@ -1,35 +1,53 @@ import { useNode, UserComponent } from '@craftjs/core'; +import { EditOutlined } from '@ant-design/icons'; + import EditableText from '../_utils/EditableText'; +import { ContextMenu, Overlay } from './utils'; +import { useState } from 'react'; type TextProps = { - text: string; + text?: string; }; -const Text: UserComponent = ({ text }) => { +const Text: UserComponent = ({ text = '' }) => { const { connectors: { connect }, actions: { setProp }, } = useNode(); + const [hovered, setHovered] = useState(false); + const [textEditing, setTextEditing] = useState(false); return ( -
{ - r && connect(r); - }} - > - setProp((props: TextProps) => (props.text = newText))} - /> -
+ +
setHovered(true)} + ref={(r) => { + r && connect(r); + }} + > + setHovered(false)} + controls={[{ key: 'edit', icon: setTextEditing(true)} /> }]} + > + setTextEditing(false)} + onChange={(newText) => setProp((props: TextProps) => (props.text = newText))} + /> + +
+
); }; export const TextSettings = () => { - return
Double click the text to edit it.
; + return ( +
Start editing the text to get text specific settings.
+ ); }; Text.craft = { @@ -40,7 +58,7 @@ Text.craft = { settings: TextSettings, }, props: { - text: 'Hi', + text: 'New Text Element', }, }; diff --git a/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/elements/utils.tsx b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/elements/utils.tsx new file mode 100644 index 000000000..2f6528796 --- /dev/null +++ b/src/management-system-v2/app/(dashboard)/[environmentId]/processes/[processId]/_user-task-builder/elements/utils.tsx @@ -0,0 +1,219 @@ +import React, { ReactElement, ReactNode, useEffect, useId, useMemo, useState } from 'react'; +import { createPortal } from 'react-dom'; + +import { Button, Menu, MenuProps } from 'antd'; +import { useDndContext } from '@dnd-kit/core'; + +import useBuilderStateStore from '../use-builder-state-store'; + +export const Setting: React.FC<{ + label: string; + control: ReactElement; + style?: React.CSSProperties; +}> = ({ label, control, style = {} }) => { + const id = useId(); + + const clonedControl = React.cloneElement(control, { id }); + + return ( +
+ + {clonedControl} +
+ ); +}; + +const getIframe = () => + document.getElementById('user-task-builder-iframe') as HTMLIFrameElement | undefined; + +type ContextMenuProps = React.PropsWithChildren<{ + onClose?: () => void; + menu: MenuProps['items']; +}>; + +export const ContextMenu: React.FC = ({ children, menu, onClose }) => { + const [menuPosition, setMenuPosition] = useState<{ top: number; left: number }>(); + + const id = useId(); + const blockDragging = useBuilderStateStore((state) => state.blockDragging); + const unblockDragging = useBuilderStateStore((state) => state.unblockDragging); + + const position = useMemo(() => { + const iframe = getIframe(); + if (!iframe || !menuPosition) return; + + const pos = { ...menuPosition }; + + const { top, left } = iframe.getBoundingClientRect(); + pos.top += top + 5; + pos.left += left + 5; + + return pos; + }, [menuPosition]); + + const open = !!position; + useEffect(() => { + if (open) { + blockDragging(id); + + return () => { + unblockDragging(id); + }; + } + }, [id, open]); + + useEffect(() => { + if (position) { + const handleClick = () => { + setMenuPosition(undefined); + onClose?.(); + }; + + const handleContextMenu = (e: MouseEvent) => { + setMenuPosition(undefined); + onClose?.(); + }; + + window.addEventListener('click', handleClick); + window.addEventListener('contextmenu', handleContextMenu); + + getIframe()?.contentWindow?.addEventListener('click', handleClick); + getIframe()?.contentWindow?.addEventListener('contextmenu', handleContextMenu, { + capture: true, + }); + return () => { + window.removeEventListener('click', handleClick); + window.removeEventListener('contextmenu', handleContextMenu); + getIframe()?.contentWindow?.removeEventListener('click', handleClick); + getIframe()?.contentWindow?.removeEventListener('contextmenu', handleContextMenu, { + capture: true, + }); + }; + } + }, [position, onClose]); + + return ( + <> + {position && + createPortal( + , + document.body, + )} +
{ + setMenuPosition({ left: e.clientX, top: e.clientY }); + e.stopPropagation(); + e.preventDefault(); + }} + > + {children} +
+ + ); +}; + +type OverlayProps = React.PropsWithChildren<{ + show: boolean; + onHide: () => void; + controls: { icon: ReactNode; key: string }[]; +}>; + +export const Overlay: React.FC = ({ show, onHide, controls, children }) => { + const { active } = useDndContext(); + + useEffect(() => { + if (show) { + window.addEventListener('mousemove', onHide); + getIframe()?.contentWindow?.addEventListener('mousemove', onHide); + return () => { + window.removeEventListener('mousemove', onHide); + getIframe()?.contentWindow?.removeEventListener('mousemove', onHide); + }; + } + }, [show]); + + return ( + <> + {show && !active && ( +
e.stopPropagation()}> + {controls.map(({ icon, key }) => ( +
+ {icon} +
+ ))} +
+ )} + {children} + + ); +}; + +type Option = { label: string; icon: ReactNode }; + +type SidebarButtonProps = { + action: T; + options: Record; + disabled?: boolean; + onClick: () => void; + onHovered: (action: T | undefined) => void; +}; + +function SidebarButton({ + action, + options, + disabled, + onClick, + onHovered, +}: SidebarButtonProps) { + return ( +