From 89e5df62b238f43f1f65e7f9e352f9df05779294 Mon Sep 17 00:00:00 2001 From: Vincent Date: Thu, 12 Feb 2026 11:30:14 +0100 Subject: [PATCH] Integrate the Datamapper into the Flow --- .github/workflows/build-release.yml | 132 ++++ pnpm-lock.yaml | 33 +- .../frontend/app/actions/navigationActions.ts | 32 + src/main/frontend/app/app.css | 59 +- .../frontend/app/components/app-content.tsx | 2 + .../datamapper/forms/add-conditions-form.tsx | 242 ++++++ .../datamapper/forms/add-field-form.tsx | 166 ++++ .../datamapper/forms/add-mapping-form.tsx | 333 ++++++++ .../datamapper/forms/add-mutation-form.tsx | 243 ++++++ .../react-flow/extra-source-node.tsx | 51 ++ .../react-flow/labeled-group-node.tsx | 59 ++ .../datamapper/react-flow/mapping-node.tsx | 64 ++ .../datamapper/react-flow/node-types.tsx | 96 +++ .../datamapper/react-flow/one-edge-node.tsx | 116 +++ .../datamapper/source-schema-definition.tsx | 55 ++ .../datamapper/toggle-theme-button.tsx | 25 + .../datamapper/upload-import-button.tsx | 119 +++ .../file-structure/studio-file-structure.tsx | 2 +- src/main/frontend/app/components/modal.tsx | 38 + .../frontend/app/components/navbar/navbar.tsx | 4 +- src/main/frontend/app/components/toast.tsx | 115 +++ .../hooks/use-datamapper-flow-management.tsx | 731 ++++++++++++++++++ src/main/frontend/app/routes/app-layout.tsx | 2 + .../configurations/configuration-manager.tsx | 23 +- .../configurations/configuration-tile.tsx | 91 ++- .../app/routes/datamapper/advanced-editor.tsx | 17 + .../app/routes/datamapper/initialize.tsx | 126 +++ .../app/routes/datamapper/mapping-table.tsx | 232 ++++++ .../app/routes/datamapper/property-list.tsx | 381 +++++++++ .../frontend/app/routes/datamapper/root.tsx | 144 ++++ .../frontend/app/routes/editor/editor.tsx | 8 +- .../projectlanding/new-project-modal.tsx | 34 +- .../routes/projectlanding/project-landing.tsx | 74 +- .../app/routes/projectlanding/project-row.tsx | 3 + .../app/routes/studio/canvas/flow.config.ts | 1 + .../app/routes/studio/canvas/flow.tsx | 81 +- .../studio/canvas/nodetypes/exit-node.tsx | 4 +- .../studio/canvas/nodetypes/group-node.tsx | 4 +- .../mappingListConfig/reducer.tsx | 77 ++ .../schemaQueue/schema-queue-context.tsx | 66 ++ .../frontend/app/stores/navigation-store.ts | 3 +- .../types/datamapper_types/config-types.ts | 114 +++ .../app/types/datamapper_types/data-types.ts | 27 + .../app/types/datamapper_types/node-types.ts | 54 ++ .../types/datamapper_types/schema-types.ts | 54 ++ .../config/condition-config.json | 219 ++++++ .../datamapper_utils/config/data-types.json | 55 ++ .../config/mutation-config.json | 299 +++++++ .../app/utils/datamapper_utils/const.tsx | 10 + .../datamapper_utils/convert-node-utils.tsx | 79 ++ .../datamapper_utils/init-example-nodes.tsx | 44 ++ .../datamapper_utils/react-node-utils.tsx | 197 +++++ .../frontend/app/utils/flow-utils.spec.ts | 194 +++++ src/main/frontend/app/utils/flow-utils.ts | 31 + src/main/frontend/icons/solar/Mapping.svg | 3 + src/main/frontend/tsconfig.json | 2 + src/main/frontend/tsconfig.spec.json | 8 +- .../frankframework/flow/project/Project.java | 15 + .../ProjectAlreadyExistsException.java | 10 + .../flow/project/ProjectController.java | 3 +- .../flow/project/ProjectService.java | 55 +- .../project/testproject/Configuration1.xml | 84 -- .../project/testproject/Configuration2.xml | 34 - .../project/testproject/Configuration3.xml | 13 - .../project/testproject_2/Configuration3.xml | 13 - .../flow/project/ProjectServiceTest.java | 20 +- 66 files changed, 5388 insertions(+), 337 deletions(-) create mode 100644 .github/workflows/build-release.yml create mode 100644 src/main/frontend/app/actions/navigationActions.ts create mode 100644 src/main/frontend/app/components/datamapper/forms/add-conditions-form.tsx create mode 100644 src/main/frontend/app/components/datamapper/forms/add-field-form.tsx create mode 100644 src/main/frontend/app/components/datamapper/forms/add-mapping-form.tsx create mode 100644 src/main/frontend/app/components/datamapper/forms/add-mutation-form.tsx create mode 100644 src/main/frontend/app/components/datamapper/react-flow/extra-source-node.tsx create mode 100644 src/main/frontend/app/components/datamapper/react-flow/labeled-group-node.tsx create mode 100644 src/main/frontend/app/components/datamapper/react-flow/mapping-node.tsx create mode 100644 src/main/frontend/app/components/datamapper/react-flow/node-types.tsx create mode 100644 src/main/frontend/app/components/datamapper/react-flow/one-edge-node.tsx create mode 100644 src/main/frontend/app/components/datamapper/source-schema-definition.tsx create mode 100644 src/main/frontend/app/components/datamapper/toggle-theme-button.tsx create mode 100644 src/main/frontend/app/components/datamapper/upload-import-button.tsx create mode 100644 src/main/frontend/app/components/modal.tsx create mode 100644 src/main/frontend/app/components/toast.tsx create mode 100644 src/main/frontend/app/hooks/use-datamapper-flow-management.tsx create mode 100644 src/main/frontend/app/routes/datamapper/advanced-editor.tsx create mode 100644 src/main/frontend/app/routes/datamapper/initialize.tsx create mode 100644 src/main/frontend/app/routes/datamapper/mapping-table.tsx create mode 100644 src/main/frontend/app/routes/datamapper/property-list.tsx create mode 100644 src/main/frontend/app/routes/datamapper/root.tsx create mode 100644 src/main/frontend/app/stores/datamapper_state/mappingListConfig/reducer.tsx create mode 100644 src/main/frontend/app/stores/datamapper_state/schemaQueue/schema-queue-context.tsx create mode 100644 src/main/frontend/app/types/datamapper_types/config-types.ts create mode 100644 src/main/frontend/app/types/datamapper_types/data-types.ts create mode 100644 src/main/frontend/app/types/datamapper_types/node-types.ts create mode 100644 src/main/frontend/app/types/datamapper_types/schema-types.ts create mode 100644 src/main/frontend/app/utils/datamapper_utils/config/condition-config.json create mode 100644 src/main/frontend/app/utils/datamapper_utils/config/data-types.json create mode 100644 src/main/frontend/app/utils/datamapper_utils/config/mutation-config.json create mode 100644 src/main/frontend/app/utils/datamapper_utils/const.tsx create mode 100644 src/main/frontend/app/utils/datamapper_utils/convert-node-utils.tsx create mode 100644 src/main/frontend/app/utils/datamapper_utils/init-example-nodes.tsx create mode 100644 src/main/frontend/app/utils/datamapper_utils/react-node-utils.tsx create mode 100644 src/main/frontend/app/utils/flow-utils.spec.ts create mode 100644 src/main/frontend/app/utils/flow-utils.ts create mode 100644 src/main/frontend/icons/solar/Mapping.svg create mode 100644 src/main/java/org/frankframework/flow/project/ProjectAlreadyExistsException.java delete mode 100644 src/main/resources/project/testproject/Configuration1.xml delete mode 100644 src/main/resources/project/testproject/Configuration2.xml delete mode 100644 src/main/resources/project/testproject/Configuration3.xml delete mode 100644 src/main/resources/project/testproject_2/Configuration3.xml diff --git a/.github/workflows/build-release.yml b/.github/workflows/build-release.yml new file mode 100644 index 00000000..f400e864 --- /dev/null +++ b/.github/workflows/build-release.yml @@ -0,0 +1,132 @@ +name: Build docker image + +# This workflow uses actions that are not certified by GitHub. +# They are provided by a third-party and are governed by +# separate terms of service, privacy policy, and support +# documentation. + +on: + push: + branches: [ "master" ] + # Publish semver tags as releases. + tags: [ 'v*.*.*' ] + +env: + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + JAVA_VERSION: 21 + NODE_VERSION: 23 + PNPM_VERSION: 10.4.0 + + +jobs: + build: + + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + # This is used to complete the identity challenge + # with sigstore/fulcio when running outside of PRs. + id-token: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + # Install the cosign tool except on PR + # https://github.com/sigstore/cosign-installer + - name: Install cosign + uses: sigstore/cosign-installer@59acb6260d9c0ba8f4a2f9d9b48431a222b68e20 #v3.5.0 + with: + cosign-release: 'v2.2.4' + + # Set up BuildKit Docker container builder to be able to build + # multi-platform images and export cache + # https://github.com/docker/setup-buildx-action + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@f95db51fddba0c2d1ec667646a06c2ce06100226 # v3.0.0 + + # Login against a Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + uses: docker/login-action@343f7c4344506bcbf9b4de18042ae17996df046d # v3.0.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Set up JDK ${{ env.JAVA_VERSION }} + uses: actions/setup-java@v5 + with: + java-version: ${{ env.JAVA_VERSION }} + distribution: 'temurin' + cache: 'maven' + + - name: Install pnpm + uses: pnpm/action-setup@41ff72655975bd51cab0327fa583b6e92b6d3061 + with: + version: ${{ env.PNPM_VERSION }} + + - name: Set up Node.js ${{ env.NODE_VERSION }} + id: pnpm-modules-cache + uses: actions/setup-node@v6 + with: + node-version: ${{ env.NODE_VERSION }} + cache: 'pnpm' + cache-dependency-path: 'pnpm-lock.yaml' + + - name: Install Frontend Dependencies + run: pnpm install --frozen-lockfile --ignore-scripts + working-directory: src/main/frontend + + - name: Test & Build Backend and run E2E Tests + run: mvn clean package + + - name: Get version from Pom + run: echo "VERSION=$(./mvnw help:evaluate -Dexpression=revision -q -DforceStdout)" >> $GITHUB_ENV + + - name: Generate revision + run: echo "REVISION=${VERSION/SNAPSHOT/$(date +%Y%m%d-%H%M%S)}" >> $GITHUB_ENV + shell: bash + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@96383f45573cb7f253c731d3b3ab81c87ef81934 # v5.0.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + tags: | + type=ref,event=branch + type=raw,value=${{ env.REVISION }} + type=raw,value=latest + + # Build and push Docker image with Buildx (don't push on PR) + # https://github.com/docker/build-push-action + - name: Build and push Docker image + id: build-and-push + uses: docker/build-push-action@0565240e2d4ab88bba5387d719585280857ece09 # v5.0.0 + with: + context: . + file: docker/Dockerfile + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # Sign the resulting Docker image digest except on PRs. + # This will only write to the public Rekor transparency log when the Docker + # repository is public to avoid leaking data. If you would like to publish + # transparency data even for private images, pass --force to cosign below. + # https://github.com/sigstore/cosign + - name: Sign the published Docker image + env: + # https://docs.github.com/en/actions/security-guides/security-hardening-for-github-actions#using-an-intermediate-environment-variable + TAGS: ${{ steps.meta.outputs.tags }} + DIGEST: ${{ steps.build-and-push.outputs.digest }} + # This step uses the identity token to provision an ephemeral certificate + # against the sigstore community Fulcio instance. + run: echo "${TAGS}" | xargs -I {} cosign sign --yes {}@${DIGEST} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index e9290ea0..c8136602 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2750,6 +2750,7 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} + deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true global-dirs@3.0.1: @@ -4920,7 +4921,7 @@ snapshots: '@babel/types': 7.28.5 '@jridgewell/remapping': 2.3.5 convert-source-map: 2.0.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 gensync: 1.0.0-beta.2 json5: 2.2.3 semver: 6.3.1 @@ -5088,7 +5089,7 @@ snapshots: '@babel/parser': 7.28.5 '@babel/template': 7.27.2 '@babel/types': 7.28.5 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -5333,7 +5334,7 @@ snapshots: '@eslint/config-array@0.21.1': dependencies: '@eslint/object-schema': 2.1.7 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 minimatch: 3.1.2 transitivePeerDependencies: - supports-color @@ -5349,7 +5350,7 @@ snapshots: '@eslint/eslintrc@3.3.3': dependencies: ajv: 6.12.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 espree: 10.4.0 globals: 14.0.0 ignore: 5.3.2 @@ -6233,7 +6234,7 @@ snapshots: '@typescript-eslint/types': 8.49.0 '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) '@typescript-eslint/visitor-keys': 8.49.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 eslint: 9.39.1(jiti@2.6.1) typescript: 5.9.3 transitivePeerDependencies: @@ -6243,7 +6244,7 @@ snapshots: dependencies: '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3) '@typescript-eslint/types': 8.49.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 typescript: 5.9.3 transitivePeerDependencies: - supports-color @@ -6262,7 +6263,7 @@ snapshots: '@typescript-eslint/types': 8.49.0 '@typescript-eslint/typescript-estree': 8.49.0(typescript@5.9.3) '@typescript-eslint/utils': 8.49.0(eslint@9.39.1(jiti@2.6.1))(typescript@5.9.3) - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 eslint: 9.39.1(jiti@2.6.1) ts-api-utils: 2.1.0(typescript@5.9.3) typescript: 5.9.3 @@ -6277,7 +6278,7 @@ snapshots: '@typescript-eslint/tsconfig-utils': 8.49.0(typescript@5.9.3) '@typescript-eslint/types': 8.49.0 '@typescript-eslint/visitor-keys': 8.49.0 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 minimatch: 9.0.5 semver: 7.7.3 tinyglobby: 0.2.15 @@ -7011,6 +7012,10 @@ snapshots: optionalDependencies: supports-color: 8.1.1 + debug@4.4.3: + dependencies: + ms: 2.1.3 + debug@4.4.3(supports-color@8.1.1): dependencies: ms: 2.1.3 @@ -7395,7 +7400,7 @@ snapshots: ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.6 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 escape-string-regexp: 4.0.0 eslint-scope: 8.4.0 eslint-visitor-keys: 4.2.1 @@ -7854,7 +7859,7 @@ snapshots: http-proxy-agent@7.0.2: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -7867,7 +7872,7 @@ snapshots: https-proxy-agent@7.0.6: dependencies: agent-base: 7.1.4 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 transitivePeerDependencies: - supports-color @@ -8606,7 +8611,7 @@ snapshots: micromark@4.0.2: dependencies: '@types/debug': 4.1.12 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 decode-named-character-reference: 1.2.0 devlop: 1.1.0 micromark-core-commonmark: 2.0.3 @@ -9888,7 +9893,7 @@ snapshots: vite-node@3.2.4(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: cac: 6.7.14 - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 vite: 6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.30.2) @@ -9919,7 +9924,7 @@ snapshots: vite-tsconfig-paths@5.1.4(typescript@5.9.3)(vite@6.4.1(@types/node@20.19.26)(jiti@2.6.1)(lightningcss@1.30.2)): dependencies: - debug: 4.4.3(supports-color@8.1.1) + debug: 4.4.3 globrex: 0.1.2 tsconfck: 3.1.6(typescript@5.9.3) optionalDependencies: diff --git a/src/main/frontend/app/actions/navigationActions.ts b/src/main/frontend/app/actions/navigationActions.ts new file mode 100644 index 00000000..33d6a880 --- /dev/null +++ b/src/main/frontend/app/actions/navigationActions.ts @@ -0,0 +1,32 @@ +import useTabStore from '~/stores/tab-store' +import useEditorTabStore from '~/stores/editor-tab-store' +import { useNavigationStore } from '~/stores/navigation-store' + +export function openInStudio(adapterName: string, filepath: string) { + const { setTabData, setActiveTab, getTab } = useTabStore.getState() + + if (!getTab(adapterName)) { + setTabData(adapterName, { + name: adapterName, + configurationPath: filepath, + flowJson: {}, + }) + } + + setActiveTab(adapterName) + useNavigationStore.getState().navigate('studio') +} + +export function openInEditor(relativePath: string, filepath: string) { + const { setTabData, setActiveTab, getTab } = useEditorTabStore.getState() + + if (!getTab(relativePath)) { + setTabData(relativePath, { + name: relativePath, + configurationPath: filepath, + }) + } + + setActiveTab(relativePath) + useNavigationStore.getState().navigate('editor') +} diff --git a/src/main/frontend/app/app.css b/src/main/frontend/app/app.css index 6418e432..11cb1514 100644 --- a/src/main/frontend/app/app.css +++ b/src/main/frontend/app/app.css @@ -1,9 +1,9 @@ -@import "tailwindcss"; +@import 'tailwindcss'; @custom-variant dark (&:where([data-theme=dark], [data-theme=dark] *)); @theme { - --font-inter: "Inter", var(--font-sans); + --font-inter: 'Inter', var(--font-sans); --color-brand: #fdc300; @@ -16,10 +16,14 @@ --color-foreground: inherit; --color-foreground-muted: inherit; --color-foreground-active: inherit; + --color-error: inherit; + --color-info: inherit; + --color-success: inherit; + --color-warning: inherit; } @layer base { - [data-theme="light"] { + [data-theme='light'] { --color-hover: var(--color-neutral-100); --color-selected: var(--color-neutral-200); --color-border: var(--color-neutral-300); @@ -28,36 +32,40 @@ --color-foreground: var(--color-neutral-950); --color-foreground-muted: var(--color-neutral-600); --color-foreground-active: var(--color-brand); + --color-error: #ff2424; + --color-info: #1389ff; + --color-warning: var(--color-brand); + --color-success: #247d12; /* Node styling */ - --type-pipe: #68D250; - --type-listener: #D250BF; - --type-receiver: #D250BF; - --type-sender: #30CCAF; - --type-validator: #3079CC; - --type-wrapper: #4A30CC; + --type-pipe: #68d250; + --type-listener: #d250bf; + --type-receiver: #d250bf; + --type-sender: #30ccaf; + --type-validator: #3079cc; + --type-wrapper: #4a30cc; --type-job: #fd7200; - --type-exit: #E84E4E; + --type-exit: #e84e4e; --type-errormessageformatter: #ff2424; --type-param: #1389ff; - --type-other: #FDC300; + --type-other: #fdc300; --type-sticky-note: #facc15; /* Palette Styling */ - --palette-pipes: #68D250; - --palette-listeners: #D250BF; - --palette-senders: #30CCAF; - --palette-validators: #3079CC; - --palette-batch: #FDC300; + --palette-pipes: #68d250; + --palette-listeners: #d250bf; + --palette-senders: #30ccaf; + --palette-validators: #3079cc; + --palette-batch: #fdc300; --palette-scheduling: #fd7200; --palette-errormessageformatters: #ff2424; - --palette-other: #FDC300; + --palette-other: #fdc300; --palette-parameters: #1389ff; --palette-monitoring: #00ff84; - --palette-transactionalstorages: #FDC300; + --palette-transactionalstorages: #fdc300; } - [data-theme="dark"] { + [data-theme='dark'] { --color-hover: var(--color-neutral-800); --color-selected: var(--color-neutral-700); --color-border: var(--color-neutral-600); /* 950 also looks good, but less for inputs and stuff */ @@ -66,14 +74,18 @@ --color-foreground: var(--color-neutral-100); --color-foreground-muted: var(--color-neutral-400); --color-foreground-active: var(--color-brand); + --color-error: #ff2424; + --color-info: #1389ff; + --color-warning: var(--color-brand); + --color-success: #247d12; /* Node styling */ --type-pipe: #136502; --type-listener: #853279; --type-receiver: #7e3072; --type-sender: #1c7c6a; - --type-validator: #3079CC; - --type-wrapper: #4A30CC; + --type-validator: #3079cc; + --type-wrapper: #4a30cc; --type-job: #9a4602; --type-exit: #882e2e; --type-errormessageformatter: #7b1313; @@ -84,7 +96,7 @@ --palette-pipes: #136502; --palette-listeners: #853279; --palette-senders: #1c7c6a; - --palette-validators: #3079CC; + --palette-validators: #3079cc; --palette-batch: #9c7800; --palette-scheduling: #9a4602; --palette-errormessageformatters: #7b1313; @@ -93,7 +105,6 @@ --palette-monitoring: #01924c; --palette-transactionalstorages: #9c7800; } - } html, @@ -108,7 +119,7 @@ body { } .monaco-editor .highlight-line { - @apply bg-yellow-200/30 border-l-4 border-yellow-400 transition-colors; + @apply border-l-4 border-yellow-400 bg-yellow-200/30 transition-colors; } :root { diff --git a/src/main/frontend/app/components/app-content.tsx b/src/main/frontend/app/components/app-content.tsx index ad11e324..71f7e1ce 100644 --- a/src/main/frontend/app/components/app-content.tsx +++ b/src/main/frontend/app/components/app-content.tsx @@ -6,12 +6,14 @@ const ConfigurationManager = lazy(() => import('~/routes/configurations/configur const Studio = lazy(() => import('~/routes/studio/studio')) const CodeEditor = lazy(() => import('~/routes/editor/editor')) const Help = lazy(() => import('~/routes/help/help')) +const Datamapper = lazy(() => import('~/routes/datamapper/root')) const Settings = lazy(() => import('~/routes/settings/settings')) const routeComponents: Record> = { configurations: ConfigurationManager, studio: Studio, editor: CodeEditor, + datamapper: Datamapper, help: Help, settings: Settings, } diff --git a/src/main/frontend/app/components/datamapper/forms/add-conditions-form.tsx b/src/main/frontend/app/components/datamapper/forms/add-conditions-form.tsx new file mode 100644 index 00000000..f4f53d5c --- /dev/null +++ b/src/main/frontend/app/components/datamapper/forms/add-conditions-form.tsx @@ -0,0 +1,242 @@ +import { useId, useState } from 'react' +import Button from '~/components/inputs/button' +import Dropdown from '~/components/inputs/dropdown' +import Input from '~/components/inputs/input' +import type { + Source, + Condition, + ConditionTypeConfig, + ConditionType, + ConditionInput, + ConditionTypeInput, + ConditionOperatorConfig, +} from '~/types/datamapper_types/config-types' +import conditionConfigJson from '~/utils/datamapper_utils/config/condition-config.json' + +interface AddConditionFormProperties { + sources: Source[] + onSave: (condition: Condition) => void + conditionToEdit?: Condition +} + +function AddConditionForm({ sources, onSave, conditionToEdit }: Readonly) { + const newId = `condition-${useId()}` + const id = conditionToEdit?.id ?? newId + const conditionsConfig = conditionConfigJson as ConditionTypeConfig + sources = sources.filter((source) => source.id != id) + const [condition, setCondition] = useState({ + id, + name: conditionToEdit?.name ?? '', + type: conditionToEdit?.type ?? null, + inputs: conditionToEdit?.inputs ?? [], + }) + + const isFormIncomplete = !condition.name || !condition.type + + function handleSave() { + onSave(condition) + } + + const selectedConditionConfig = conditionsConfig.conditions.find((c) => c.name === condition.type?.name) + + return ( +
+

Add Condition

+ + {/* Name input */} +
+ + setCondition((condition) => ({ ...condition, name: event.target.value }))} + placeholder="Enter a name for this condition" + /> +
+ + {/* Condition type selector */} +
+ + { + const conditionType = conditionsConfig.conditions.find((condition) => condition.name === event) ?? null + setCondition({ + id, + name: condition.name, + type: conditionType ?? null, + inputs: + conditionType?.inputs.map(() => ({ + type: '', + value: '', + })) ?? [], + }) + }} + options={Object.fromEntries(conditionsConfig.conditions.map((condition) => [condition.name, condition.name]))} + /> +
+ + {selectedConditionConfig && ( + + )} + + +
+ ) +} + +function ConditionDetailsForm({ + condition, + setCondition, + sources, + conditionConfig, +}: Readonly<{ + condition: Condition + setCondition: React.Dispatch> + sources: Source[] + conditionConfig: ConditionType +}>) { + function updateInput(index: number, value: ConditionInput) { + setCondition((condition) => { + const newInputs = [...condition.inputs] + newInputs[index] = value + return { ...condition, inputs: newInputs } + }) + } + + return ( +
+ {conditionConfig.inputs.map((inputConfig, index) => ( + updateInput(index, value)} + sources={sources} + /> + ))} +
+ ) +} + +function ConditionInputField({ + inputConfig, + value, + onChange, + sources, +}: Readonly<{ + inputConfig: ConditionTypeInput + value?: ConditionInput + onChange: (value_: ConditionInput) => void + sources: Source[] +}>) { + let filteredSources = sources + if (inputConfig.inputsAllowed !== 'all') { + filteredSources = sources.filter((source) => source.type === inputConfig.inputsAllowed) + } + + if (inputConfig.type === 'source') { + const selectedIsDefault = value?.type === 'defaultValue' + + return ( +
+ + + + {selectedIsDefault && ( + onChange({ type: 'defaultValue', value: event.target.value })} + /> + )} +
+ ) + } + + if (inputConfig.type === 'attribute') { + return ( +
+ + onChange({ type: 'attribute', value: event.target.value })} + /> +
+ ) + } + + if (inputConfig.type.includes('Operator')) { + const operatorConfig = (conditionConfigJson as ConditionTypeConfig).operators[ + inputConfig.type + ] as ConditionOperatorConfig + + if (!operatorConfig) { + throw new Error(`Operator config not found for ${inputConfig.type}`) + } + + return ( +
+ + +
+ ) + } + + // fallback + return ( +
+ + onChange({ type: 'defaultValue', value: event.target.value })} + /> +
+ ) +} + +export default AddConditionForm diff --git a/src/main/frontend/app/components/datamapper/forms/add-field-form.tsx b/src/main/frontend/app/components/datamapper/forms/add-field-form.tsx new file mode 100644 index 00000000..27b87069 --- /dev/null +++ b/src/main/frontend/app/components/datamapper/forms/add-field-form.tsx @@ -0,0 +1,166 @@ +import { useState, useEffect } from 'react' +import type { + FormatDefinition, + FormatState, + PropertyBasicTypes, + RuleSet, + PropertyDefinition, +} from '~/types/datamapper_types/data-types' +import type { CustomNodeData, NodeLabels } from '~/types/datamapper_types/node-types' +import { showWarningToast } from '../../toast' +import Input from '~/components/inputs/input' +import Dropdown from '~/components/inputs/dropdown' +import Button from '~/components/inputs/button' + +export interface FieldModalProperties { + fieldType: 'source' | 'target' + onSave: (data: CustomNodeData) => void + parents: NodeLabels[] + formatDefinition: FormatState + initialData: CustomNodeData | null +} + +function AddFieldForm({ fieldType, onSave, parents, formatDefinition, initialData }: FieldModalProperties) { + const [variableType, setVariableType] = useState(initialData?.variableType || '') + const [label, setLabel] = useState(initialData?.label || '') + const [defaultValue, setDefaultValue] = useState(initialData?.defaultValue || '') + const [parentId, setParent] = useState(initialData?.parentId || `${fieldType}-table`) + const [defaultValueRules, setDefaultValueRules] = useState() + const [defaultValueInputType, setDefaultValueInputType] = useState() + const [availableTypes, setAvailableTypes] = useState([]) + + useEffect(() => { + const format: FormatDefinition | null = formatDefinition[fieldType] + if (format) { + setAvailableTypes(format.properties) + } + }, [formatDefinition, fieldType]) + + useEffect(() => { + const format = formatDefinition[fieldType] + const propertyRules = format?.properties.find((a) => a.name == variableType) + setDefaultValueRules(propertyRules?.rules) + setDefaultValueInputType(propertyRules?.type) + }, [variableType]) + + const isFormIncomplete = !variableType || !label + + function handleSave() { + if (isFormIncomplete) { + showWarningToast('Please fill in all fields!', 'Invalid input') + return + } + onSave({ + variableType, + label, + defaultValue, + parentId, + id: initialData?.id || '', + }) + } + + function validateDefaultValue(value: string) { + if (defaultValueInputType !== 'number') { + setDefaultValue(value) + return + } + + // Allow numbers starting with -, followed up by any number, followed up by an optional . for decimal values and finally some more numbers + const numberRegex = /^-?\d*\.?\d*$/ + + if (!numberRegex.test(value)) { + // Invalid character, ignore the input + return + } + + // Update the field to allow for futher typing + setDefaultValue(value) + + // Optional: clean up number if it's a valid number. Check is necessary because else a user can't type 10. when they're in the process of writing decimals + if (value !== '' && !/^-$/.test(value) && !/\.$/.test(value)) { + let valueNumber = Number(value) + + if (!Number.isNaN(valueNumber)) { + if (defaultValueRules?.maxValue !== undefined) { + valueNumber = Math.min(valueNumber, defaultValueRules.maxValue) + } + if (defaultValueRules?.minValue !== undefined) { + valueNumber = Math.max(valueNumber, defaultValueRules.minValue) + } + if (defaultValueRules?.decimalAllowed === false) { + valueNumber = Math.trunc(valueNumber) + } + + setDefaultValue(valueNumber.toString()) + } + } + } + + return ( +
+

+ {initialData ? 'Edit' : 'Add'} {fieldType} property +

+ + {!initialData && ( + <> + + setParent(e)} + options={{ + ...Object.fromEntries(parents.map((p) => [p.id, p.label])), + [`${fieldType}-table`]: `${fieldType}-table`, + }} + /> + + )} + + + setVariableType(value)} + disabled={initialData?.variableType == 'object'} + options={Object.fromEntries(availableTypes.map((p) => [p.name, p.name]))} + /> + + + setLabel(event.target.value)} /> + + + + +
+ ) +} + +export default AddFieldForm diff --git a/src/main/frontend/app/components/datamapper/forms/add-mapping-form.tsx b/src/main/frontend/app/components/datamapper/forms/add-mapping-form.tsx new file mode 100644 index 00000000..2a1c73fb --- /dev/null +++ b/src/main/frontend/app/components/datamapper/forms/add-mapping-form.tsx @@ -0,0 +1,333 @@ +import React, { type Dispatch, type SetStateAction, useEffect, useState } from 'react' + +import AddMutationForm from './add-mutation-form' +import AddConditionForm from './add-conditions-form' +import type { Mutation, Condition, Source } from '~/types/datamapper_types/config-types' +import type { MappingConfig, NodeLabels } from '~/types/datamapper_types/node-types' +import Modal from '../../modal' +import Checkbox from '~/components/inputs/checkbox' +import Dropdown from '~/components/inputs/dropdown' + +export interface MappingModalProps { + onSave: (data: MappingConfig) => void + sources: NodeLabels[] + targets: NodeLabels[] + initialData: MappingConfig | null +} + +const updateArrayItem = (setter: Dispatch>, index: number, value: T) => { + setter((prev) => { + const next = [...prev] + if (index >= 0 && index < next.length) next[index] = value + return next + }) +} + +const deleteArrayItem = (setter: Dispatch>, index: number) => { + setter((prev) => prev.filter((_, i) => i !== index)) +} + +function AddMappingForm({ onSave, sources, targets, initialData }: MappingModalProps) { + const [sourceIds, setSourceIds] = useState([]) + const [targetId, setTargetId] = useState('') + + const [mutations, setMutations] = useState([]) + const [conditions, setConditions] = useState([]) + + const [addMutationModal, setMutationModal] = useState(false) + const [addConditionModal, setConditionModal] = useState(false) + + const [editMutation, setEditMutation] = useState(null) + const [editCondition, setEditCondition] = useState(null) + + const [output, setOutput] = useState('') + + const [isConditional, setIsConditional] = useState(!!initialData?.conditional) + const [selectedConditional, setSelectedConditional] = useState(initialData?.conditional ?? null) + + const unfilteredOutputs: Source[] = [ + ...sources.filter((source) => sourceIds.includes(source.id)), + ...mutations.map((mutation) => ({ + id: mutation.id, + label: mutation.name, + type: mutation.mutationType?.outputType, + })), + ...conditions.map((condition) => ({ + id: condition.id, + label: condition.name, + type: 'boolean', + })), + ] as Source[] + + const filteredOutputs = React.useMemo(() => { + if (!targetId) return [] + const outputType = targets.find((target) => target.id === targetId)?.type + if (!outputType) return [] + return unfilteredOutputs.filter((output) => output.type === outputType) + }, [targetId, targets, unfilteredOutputs]) + + useEffect(() => { + const outputType = targets.find((target) => target.id === targetId)?.type + const possibleOutputs = unfilteredOutputs.some( + (possibleOutput) => possibleOutput.id === output && possibleOutput.type === outputType, + ) + if (!possibleOutputs) { + setOutput('') + } + + if (filteredOutputs.length == 1) { + setOutput(filteredOutputs[0].id) + } + }, [targetId, filteredOutputs, unfilteredOutputs, output, targets]) + + useEffect(() => { + if (initialData) { + setSourceIds(initialData.sources) + setTargetId(initialData.target ?? '') + setMutations(initialData.mutations || []) + setConditions(initialData.conditions || []) + setOutput(initialData.output) + } else { + const selectedSources = sources.filter((source) => source.checked).map((source) => source.id) + const selectedTarget = targets.find((target) => target.checked)?.id ?? '' + + setSourceIds(selectedSources.length > 0 ? selectedSources : []) + setTargetId(selectedTarget) + } + }, [sources, targets, initialData]) + + const handleSave = () => { + onSave({ + id: initialData?.id, + colour: initialData?.colour, + sources: sourceIds, + target: targetId, + type: sourceIds.length > 1 ? 'many-to-one' : 'one-to-one', + mutations, + conditions, + output, + conditional: isConditional ? selectedConditional : null, + }) + } + + const isFormIncomplete = sourceIds.some((id) => !id) || !targetId || !output + + const scrollable = 'flex-1 min-h-0 overflow-y-auto space-y-2' + + return ( +
+

Add Mapping

+ + {/* Main grid with lists */} +
+ {/* Column 1 */} +
+
+ + +
+ {sourceIds.map((id, value) => ( +
+ + {/* Updated dropdown: TODO check if styling can be reworked to work properly + updateArrayItem(setSourceIds, value, e)} + options={Object.fromEntries(sources.map((s) => [s.id, `${s.label} (${s.type})`]))} + /> */} + +
+ ))} +
+ + +
+
+ + {/* Column 2 */} +
+
+ + +
+ {mutations.map((mutation) => ( +
+
+ {mutation.name} +
+ + +
+
+
+ ))} +
+ + +
+ +
+ + +
+ {conditions.map((condition) => ( +
+
+ {condition.name} +
+ + +
+
+
+ ))} +
+ + +
+
+
+ + {/* Target → Output */} +
+
+ + [output.id, `${output.label} (${output.type})`]), + )} + /> +
+ +
+ +
+ + + [target.id, `${target.label} (${target.type})`]))} + /> +
+
+ + {/* Conditional Mapping */} +
+ + + {isConditional && ( + setSelectedConditional(conditions.find((condition) => condition.id === e) ?? null)} + options={Object.fromEntries(conditions.map((condition) => [condition.id, condition.name]))} + /> + )} +
+ + + + {/* Modals */} + setMutationModal(false)}> + { + setMutations((mutations) => + editMutation + ? mutations.map((mutationToCompare) => + mutationToCompare.id === mutationToEdit.id ? mutationToEdit : mutationToCompare, + ) + : [...mutations, mutationToEdit], + ) + setMutationModal(false) + setEditMutation(null) + }} + /> + + + setConditionModal(false)}> + { + setConditions((conditions) => + editCondition + ? conditions.map((conditionToCompare) => + conditionToCompare.id === conditionToCompare.id ? conditionToCompare : conditionToEdit, + ) + : [...conditions, conditionToEdit], + ) + setConditionModal(false) + setEditCondition(null) + }} + /> + +
+ ) +} + +export default AddMappingForm diff --git a/src/main/frontend/app/components/datamapper/forms/add-mutation-form.tsx b/src/main/frontend/app/components/datamapper/forms/add-mutation-form.tsx new file mode 100644 index 00000000..796b5d2c --- /dev/null +++ b/src/main/frontend/app/components/datamapper/forms/add-mutation-form.tsx @@ -0,0 +1,243 @@ +import { useId, useState } from 'react' +import mutationConfig from '~/utils/datamapper_utils/config/mutation-config.json' +import type { + Mutation, + MutationInput, + MutationsConfig, + MutationTypeInput, + Source, +} from '~/types/datamapper_types/config-types' +import Input from '~/components/inputs/input' +import Dropdown from '~/components/inputs/dropdown' +import Button from '~/components/inputs/button' + +function AddMutationForm({ + sources, + onSave, + mutationToEdit, +}: { + sources: Source[] + onSave: (data: Mutation) => void + mutationToEdit?: Mutation +}) { + const newId = `mutation-${useId()}` + const id = mutationToEdit?.id ?? newId + const mutations: MutationsConfig = mutationConfig as MutationsConfig + sources = sources.filter((source) => source.id != id) + + const [mutation, setMutation] = useState({ + id, + name: mutationToEdit?.name ?? '', + mutationType: mutationToEdit?.mutationType ?? null, + inputs: mutationToEdit?.inputs ?? [], + }) + + const isFormIncomplete = !mutation.name || !mutation.mutationType || mutation.inputs.length === 0 + + function handleSave() { + onSave(mutation) + } + + return ( +
+

Add Mutation

+ + + setMutation((toSetMutation) => ({ ...toSetMutation, name: event.target.value }))} + /> + + + { + const mutationType = mutations.mutations.find((mutationToFind) => mutationToFind.name === e) ?? null + setMutation({ + id, + name: mutation.name, + mutationType: mutationType, + inputs: [], + }) + }} + options={Object.fromEntries( + mutations.mutations.map((mutationToMap) => [mutationToMap.name, mutationToMap.name]), + )} + /> + + {mutation.mutationType && } + + +
+ ) +} + +function MutationDetailsForm({ + mutation, + setMutation, + sources, +}: { + mutation: Mutation + setMutation: React.Dispatch> + sources: Source[] +}) { + function addInputField(mutationTypeInput: MutationTypeInput) { + setMutation((mutationToSet) => ({ + ...mutationToSet, + inputs: [ + ...mutationToSet.inputs, + { + type: mutationTypeInput.type === 'source' ? 'source' : 'defaultValue', + value: '', + }, + ], + })) + } + + function removeInput(index: number) { + setMutation((mutations) => ({ + ...mutations, + inputs: mutations.inputs.filter((_, index_) => index_ !== index), + })) + } + + return ( +
+ {mutation.mutationType?.inputs.map((mutationTypeInput, index) => { + return ( +
+ {/* Base input */} + + + {/* Render extra expandable inputs */} + {mutationTypeInput.expandable && + mutation.inputs.slice(index + 1).map((_, extraIndex) => { + const actualIndex = extraIndex + index + 1 + return ( + removeInput(actualIndex)} + /> + ) + })} + + {/* Add input button */} + {mutationTypeInput.expandable && ( + + )} +
+ ) + })} +
+ ) +} + +function MutationInputField({ + mutationInput, + index, + mutation, + setMutation, + sources, + showDelete = false, + onDelete, +}: { + mutationInput: MutationTypeInput + index: number + mutation: Mutation + setMutation: React.Dispatch> + sources: Source[] + showDelete?: boolean + onDelete?: () => void +}) { + if (mutationInput.inputsAllowed != 'all') { + sources = sources.filter((source) => source.type == mutationInput.inputsAllowed) + } + const value = mutation.inputs[index] ?? { + type: mutationInput.type, + value: '', + } + + function updateInput(updated: MutationInput) { + setMutation((mutation) => { + const newInputs = [...mutation.inputs] + newInputs[index] = { ...newInputs[index], ...updated } + return { ...mutation, inputs: newInputs } + }) + } + + function handleSourceChange(sourceId: string) { + if (sourceId === 'defaultValue') { + updateInput({ + type: mutationInput.type == 'attribute' ? mutationInput.type : 'defaultValue', + sourceId: 'defaultValue', + value: '', + }) + return + } + const source = sources.find((source) => source.id === sourceId) + if (!source) return + updateInput({ type: 'source', sourceId: source.id, value: source.label }) + } + + return ( +
+
+ {mutationInput.label && } + + {mutationInput.type === 'source' && ( + + )} + + {(mutationInput.type === 'attribute' || value.type === 'defaultValue') && ( + + updateInput({ + type: mutationInput.type === 'attribute' ? mutationInput.type : 'defaultValue', + value: event.target.value, + }) + } + /> + )} +
+ + {showDelete && ( + + )} +
+ ) +} + +export default AddMutationForm diff --git a/src/main/frontend/app/components/datamapper/react-flow/extra-source-node.tsx b/src/main/frontend/app/components/datamapper/react-flow/extra-source-node.tsx new file mode 100644 index 00000000..39c7c04d --- /dev/null +++ b/src/main/frontend/app/components/datamapper/react-flow/extra-source-node.tsx @@ -0,0 +1,51 @@ +import { Handle, Position } from '@xyflow/react' +import type { CustomNodeData } from '~/types/datamapper_types/node-types' +import { GROUP_WIDTH } from '~/utils/datamapper_utils/const' + +export interface ExtraSourceNodeProperties { + id: string + data: CustomNodeData + onDelete?: (id: string) => void + onHighlight?: (id: string) => void +} + +function ExtraSourceNode({ id, data, onDelete, onHighlight }: ExtraSourceNodeProperties) { + return ( +
+ {/* Header */} +
+ {data.label ? `extra source: ${data.label}` : 'ExtraSource'} +
+ +
+ (Source ) +
+ + +
+
+ + +
+ ) +} +export default ExtraSourceNode diff --git a/src/main/frontend/app/components/datamapper/react-flow/labeled-group-node.tsx b/src/main/frontend/app/components/datamapper/react-flow/labeled-group-node.tsx new file mode 100644 index 00000000..0f54fe56 --- /dev/null +++ b/src/main/frontend/app/components/datamapper/react-flow/labeled-group-node.tsx @@ -0,0 +1,59 @@ +import { Handle, Position } from '@xyflow/react' +import type { CustomNodeData } from '~/types/datamapper_types/node-types' +import { GROUP_WIDTH } from '~/utils/datamapper_utils/const' + +export interface LabeledGroupNodeProperties { + id: string + data: CustomNodeData + onEdit?: (data: CustomNodeData) => void + onDelete?: (id: string) => void + onHighlight?: (id: string) => void +} + +function LabeledGroupNode({ id, data, onEdit, onDelete, onHighlight }: LabeledGroupNodeProperties) { + return ( +
+ {/* Header */} +
{data.label ?? 'Group'}
+ +
+ (Object) +
+ + + +
+
+ + +
+ ) +} +export default LabeledGroupNode diff --git a/src/main/frontend/app/components/datamapper/react-flow/mapping-node.tsx b/src/main/frontend/app/components/datamapper/react-flow/mapping-node.tsx new file mode 100644 index 00000000..2a89c6bf --- /dev/null +++ b/src/main/frontend/app/components/datamapper/react-flow/mapping-node.tsx @@ -0,0 +1,64 @@ +import { Handle, Position } from '@xyflow/react' +import type { MappingConfig } from '~/types/datamapper_types/node-types' + +export interface MappingNodeProperties { + id: string + data: MappingConfig + + onClick?: (id: string) => void + onDelete?: (id: string) => void + onEdit?: (data: MappingConfig) => void +} + +function MappingNode({ id, data, onClick, onDelete, onEdit }: MappingNodeProperties) { + return ( +
onClick?.(id)} + className="relative flex w-[100px] flex-col gap-1 rounded-md p-2" + style={{ + backgroundColor: data.colour || 'var(--color-backdrop)', //Tailwind needs to have predefined styles at build time, so it can generate the required CSS for it. Because the color value is set dynamically, it needs to be set with inline style + }} + > +
{data.type}
+ + {/* Delete */} + + + {/* Edit */} + + + {/* Handles */} + + + +
+ ) +} + +export default MappingNode diff --git a/src/main/frontend/app/components/datamapper/react-flow/node-types.tsx b/src/main/frontend/app/components/datamapper/react-flow/node-types.tsx new file mode 100644 index 00000000..034ebb1b --- /dev/null +++ b/src/main/frontend/app/components/datamapper/react-flow/node-types.tsx @@ -0,0 +1,96 @@ +import type { Dispatch, RefObject, SetStateAction } from 'react' +import OneEdgeNode, { type OneEdgeNodeProperties } from './one-edge-node' +import LabeledGroupNode, { type LabeledGroupNodeProperties } from './labeled-group-node' +import MappingNode, { type MappingNodeProperties } from './mapping-node' +import type { NodeTypes, Node } from '@xyflow/react' + +import ExtraSourceNode, { type ExtraSourceNodeProperties } from './extra-source-node' +import type { useFlowManagement } from '~/hooks/use-datamapper-flow-management' +import type { CustomNodeData, MappingConfig } from '~/types/datamapper_types/node-types' + +interface GetNodeTypesParameters { + flow: ReturnType + setReactFlowNodes: Dispatch> + setEditingNode: Dispatch> + setAddFieldModal: Dispatch> + openModelType: RefObject<'source' | 'target'> + setEditingMapping: Dispatch> + openMapping: () => void +} + +export const getNodeTypes = ({ + flow, + setReactFlowNodes, + setEditingNode, + setAddFieldModal, + openModelType, + setEditingMapping, + openMapping, +}: GetNodeTypesParameters): NodeTypes => ({ + sourceOnly: (properties: OneEdgeNodeProperties) => ( + { + if (data) { + setEditingNode(data) + openModelType.current = 'source' + setAddFieldModal(true) + } + }} + onDelete={(id) => flow.deleteNode(id)} + onHighlight={(id) => flow.highlightFromPropertyNode(id)} + /> + ), + targetOnly: (properties: OneEdgeNodeProperties) => ( + { + if (data) { + setEditingNode(data) + openModelType.current = 'target' + setAddFieldModal(true) + } + }} + onDelete={(id) => flow.deleteNode(id)} + onHighlight={(id) => flow.highlightFromPropertyNode(id)} + /> + ), + labeledGroup: (node: LabeledGroupNodeProperties) => ( + flow.highlightFromPropertyNode(id)} + onEdit={(data) => { + if (data) { + setEditingNode(data) + openModelType.current = 'target' + setAddFieldModal(true) + } + }} + onDelete={(id) => flow.deleteNode(id)} + /> + ), + extraSourceNode: (node: ExtraSourceNodeProperties) => ( + flow.highlightFromPropertyNode(id)} + onDelete={(id) => flow.deleteNode(id)} + /> + ), + mappingNode: (node: MappingNodeProperties) => ( + flow.highlightFromMappingNode(id)} + onEdit={(data) => { + setEditingMapping(data) + openMapping() + }} + onDelete={(id) => { + flow.deleteMapping(id) + }} + /> + ), +}) diff --git a/src/main/frontend/app/components/datamapper/react-flow/one-edge-node.tsx b/src/main/frontend/app/components/datamapper/react-flow/one-edge-node.tsx new file mode 100644 index 00000000..d9e74b37 --- /dev/null +++ b/src/main/frontend/app/components/datamapper/react-flow/one-edge-node.tsx @@ -0,0 +1,116 @@ +import { Handle, Position, type Node, useNodeConnections } from '@xyflow/react' + +import clsx from 'clsx' +import type { CustomNodeData } from '~/types/datamapper_types/node-types' +import { PROPERTY_WIDTH } from '~/utils/datamapper_utils/const' + +export interface OneEdgeNodeProperties { + id: string + data: CustomNodeData + variant?: 'source' | 'target' + onEdit?: (data: CustomNodeData) => void + onDelete?: (id: string) => void + onHighlight?: (id: string) => void +} + +function OneEdgeNode({ id, data, variant = 'source', onEdit, onDelete, onHighlight }: OneEdgeNodeProperties) { + const checked = data?.checked + + const connections = useNodeConnections({ + id, + }) + + const isConnectable = connections.length === 0 || variant === 'source' + + const updateChecked = () => { + const newChecked = !checked + data.setNodes?.((nodes: Node[]) => + nodes.map((node) => (node.id === id ? { ...node, data: { ...node.data, checked: newChecked } } : node)), + ) + if (variant != 'source') { + data.setNodes?.((nodes: Node[]) => + nodes.map((node) => + node.id !== id && node.parentId?.includes('target') + ? { ...node, data: { ...node.data, checked: false } } + : node, + ), + ) + } + } + + return ( +
+ {/* Label */} +
{data.label}
+ + {/* Footer */} +
+ ({data.variableType}) + +
+ + + + + +
+
+ + {/* Active handle */} + + + {/* Hidden opposite handle */} + +
+ ) +} + +export default OneEdgeNode diff --git a/src/main/frontend/app/components/datamapper/source-schema-definition.tsx b/src/main/frontend/app/components/datamapper/source-schema-definition.tsx new file mode 100644 index 00000000..40cbcfa5 --- /dev/null +++ b/src/main/frontend/app/components/datamapper/source-schema-definition.tsx @@ -0,0 +1,55 @@ +import { useState } from 'react' + +import UploadImportButton from './upload-import-button' +import type { MappingListConfig } from '~/types/datamapper_types/config-types' +import { useFile } from '~/stores/datamapper_state/schemaQueue/schema-queue-context' +import Input from '../inputs/input' +import Button from '../inputs/button' + +interface SourceDefinitionComponentProperties { + config?: MappingListConfig + onDelete: () => void +} + +function SourceDefinitionComponent({ config, onDelete }: SourceDefinitionComponentProperties) { + const { deleteSourceSchema } = useFile() + const [sourceName, setSourceName] = useState('') + + const [confirmed, setConfirmed] = useState(false) + return ( +
+ + { + setSourceName(event.target.value) + }} + disabled={confirmed} + /> + + + +
+ ) +} + +export default SourceDefinitionComponent diff --git a/src/main/frontend/app/components/datamapper/toggle-theme-button.tsx b/src/main/frontend/app/components/datamapper/toggle-theme-button.tsx new file mode 100644 index 00000000..a89a79ce --- /dev/null +++ b/src/main/frontend/app/components/datamapper/toggle-theme-button.tsx @@ -0,0 +1,25 @@ +import { useSettingsStore } from '~/routes/settings/settings-store' +import { useTheme } from '~/hooks/use-theme' + +//TEMPORARY COMPONENT ONLY USED FOR TESTING +function ThemeToggleButton() { + const theme = useTheme() + const setGeneralSettings = useSettingsStore((s) => s.setGeneralSettings) + + const toggleTheme = () => { + setGeneralSettings({ + theme: theme === 'light' ? 'dark' : 'light', + }) + } + + return ( + + ) +} + +export default ThemeToggleButton diff --git a/src/main/frontend/app/components/datamapper/upload-import-button.tsx b/src/main/frontend/app/components/datamapper/upload-import-button.tsx new file mode 100644 index 00000000..21e88ef1 --- /dev/null +++ b/src/main/frontend/app/components/datamapper/upload-import-button.tsx @@ -0,0 +1,119 @@ +import { type ChangeEvent, type Dispatch, useId } from 'react' + +import clsx from 'clsx' +import type { useFlowManagement } from '~/hooks/use-datamapper-flow-management' +import type { ConfigActions } from '~/stores/datamapper_state/mappingListConfig/reducer' +import { useFile } from '~/stores/datamapper_state/schemaQueue/schema-queue-context' +import type { MappingListConfig } from '~/types/datamapper_types/config-types' +import { FLOW_KEY } from '~/utils/datamapper_utils/const' +import { showErrorToast, showSuccessToast } from '../toast' + +interface SchemaUploadButtonProperties { + label: string + flowType?: 'source' | 'target' + configDispatch?: Dispatch + flow?: ReturnType + config?: MappingListConfig + name?: string + disabled?: boolean +} + +// Generic import button with visual feedback for uploaded files +function UploadImportButton({ + label, + flowType, + configDispatch, + config, + name, + disabled = false, +}: SchemaUploadButtonProperties) { + const { sourceSchematics, addSourceSchematic, targetSchematic, setTargetSchematic } = useFile() + + const inputId = `UploadImportButton${useId()}` + + // Determine the uploaded file name (for source/target) + const uploadedFileName = (() => { + if (!flowType) return null + + if (flowType === 'source') { + const match = sourceSchematics?.find((s) => s.name === name) + return match?.file?.name ?? null + } + + if (flowType === 'target') { + return targetSchematic?.name ?? null + } + + return null + })() + + const isUploaded = Boolean(uploadedFileName) + + const handleFileChange = async (event: ChangeEvent) => { + if (disabled) return // prevent any action if disabled + const file = event.target.files?.[0] + if (!file) return + + try { + if (!flowType && configDispatch) { + const text = await file.text() + const parsed = JSON.parse(text) + configDispatch({ + type: 'IMPORT_CONFIG', + payload: parsed as MappingListConfig, + }) + localStorage.setItem(FLOW_KEY, text) + + showSuccessToast('Imported config JSON!') + } else if (flowType) { + if (flowType === 'source') { + addSourceSchematic({ file, name: name }) + showSuccessToast(`Added ${file.name} to source schematics`) + } else if (flowType === 'target') { + setTargetSchematic(file) + showSuccessToast(`Target schematic set: ${file.name}`) + } + } else { + showErrorToast('Invalid import!', 'Import failed!') + } + } catch { + showErrorToast('Invalid json file!', 'Import failed') + } + + event.target.value = '' + } + + return ( +
+ + + +
+ ) +} + +export default UploadImportButton diff --git a/src/main/frontend/app/components/file-structure/studio-file-structure.tsx b/src/main/frontend/app/components/file-structure/studio-file-structure.tsx index 489f8257..18b7eda5 100644 --- a/src/main/frontend/app/components/file-structure/studio-file-structure.tsx +++ b/src/main/frontend/app/components/file-structure/studio-file-structure.tsx @@ -19,7 +19,7 @@ import { } from 'react-complex-tree' import FilesDataProvider from '~/components/file-structure/studio-files-data-provider' import { useProjectStore } from '~/stores/project-store' -import type { FileNode, FileTreeNode } from './editor-data-provider' +import type { FileNode } from './editor-data-provider' import { useProjectTree } from '~/hooks/use-project-tree' const TREE_ID = 'studio-files-tree' diff --git a/src/main/frontend/app/components/modal.tsx b/src/main/frontend/app/components/modal.tsx new file mode 100644 index 00000000..dc4fba90 --- /dev/null +++ b/src/main/frontend/app/components/modal.tsx @@ -0,0 +1,38 @@ +import { type ReactNode } from 'react' +import { createPortal } from 'react-dom' +import { useTheme } from '~/hooks/use-theme' + +interface ModalProperties { + isOpen: boolean + onClose: () => void + title?: string + children: ReactNode +} + +function Modal({ isOpen, onClose, title, children }: ModalProperties) { + const theme = useTheme() + + if (!isOpen) return null + + return createPortal( +
+
e.stopPropagation()} + > + {title &&

{title}

} +
{children}
+ +
+
, + document.body, + ) +} + +export default Modal diff --git a/src/main/frontend/app/components/navbar/navbar.tsx b/src/main/frontend/app/components/navbar/navbar.tsx index 8fa45a6c..96b6df32 100644 --- a/src/main/frontend/app/components/navbar/navbar.tsx +++ b/src/main/frontend/app/components/navbar/navbar.tsx @@ -5,10 +5,11 @@ import RulerCrossPenIcon from '/icons/solar/Ruler Cross Pen.svg?react' import CodeIcon from '/icons/solar/Code.svg?react' import HelpIcon from '/icons/solar/Help.svg?react' import SettingsIcon from '/icons/solar/Settings.svg?react' +import DatamapperIcon from '/icons/solar/Mapping.svg?react' export default function Navbar() { return ( -