diff --git a/.github/workflows/integration-tests-api.yml b/.github/workflows/integration-tests-api.yml index 4eb165f9..91c44412 100644 --- a/.github/workflows/integration-tests-api.yml +++ b/.github/workflows/integration-tests-api.yml @@ -16,8 +16,7 @@ jobs: - name: Install Domino. run: | python -m pip install --upgrade pip - pip install -r requirements.txt - pip install -e . + pip install -e .[cli] - name: Build REST image. uses: docker/build-push-action@v3 @@ -32,10 +31,14 @@ jobs: with: install_only: true + - name: Print list domino src dir + run: | + ls ./src/domino + - name: Prepare and create cluster. run: | - domino platform prepare --cluster-name=domino-cluster --workflows-repository=${{secrets.TESTS_WORKFLOWS_REPOSITORY}} --github-workflows-ssh-private-key=None --github-workflows-token=${{ secrets.TESTS_GITHUB_WORKFLOWS_TOKEN }} --github-default-pieces-repository-token=${{ secrets.TESTS_GITHUB_PIECES_TOKEN }} --deploy-mode=local-k8s --local-pieces-repository-path=[] --local-domino-path='' - domino platform create --domino-rest-image=domino-rest:test --run-airflow=False + domino platform prepare --cluster-name=domino-cluster --workflows-repository=${{secrets.TESTS_WORKFLOWS_REPOSITORY}} --github-workflows-ssh-private-key='' --github-workflows-token=${{ secrets.TESTS_GITHUB_WORKFLOWS_TOKEN }} --github-default-pieces-repository-token=${{ secrets.TESTS_GITHUB_PIECES_TOKEN }} --deploy-mode=local-k8s-dev --local-pieces-repository-path=[] --local-domino-path='./src/domino' --local-rest-image='domino-rest:test' --local-frontend-image='' --local-airflow-image='' + domino platform create --install-airflow=False - name: Install tests dependencies. run: pip install -r rest/requirements-test.txt @@ -51,7 +54,6 @@ jobs: DOMINO_DEFAULT_PIECES_REPOSITORY_TOKEN: ${{ secrets.TESTS_GITHUB_PIECES_TOKEN }} DOMINO_GITHUB_ACCESS_TOKEN_WORKFLOWS: ${{ secrets.TESTS_GITHUB_WORKFLOWS_TOKEN }} DOMINO_GITHUB_WORKFLOWS_REPOSITORY: ${{secrets.TESTS_WORKFLOWS_REPOSITORY}} - run: pytest rest/tests/ -v - name: Delete cluster diff --git a/.github/workflows/release-airflow-images-dev.yaml b/.github/workflows/release-airflow-images-dev.yaml new file mode 100644 index 00000000..92d82062 --- /dev/null +++ b/.github/workflows/release-airflow-images-dev.yaml @@ -0,0 +1,105 @@ +name: Build and publish Domino Airflow development images + +on: + push: + branches: + - dev + paths: + - src/domino/** + +jobs: + domino-airflow-base: + name: Domino Airflow Base Image + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push Domino Airflow Base image + uses: docker/build-push-action@v3 + with: + push: true + tags: ghcr.io/tauffer-consulting/domino-airflow-base:latest-dev + context: . + file: Dockerfile-airflow-domino-base-dev + + domino-pod-base: + name: Domino Base Pod Image + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push Domino POD Base image + uses: docker/build-push-action@v3 + with: + push: true + tags: ghcr.io/tauffer-consulting/domino-airflow-pod:latest-dev + context: . + file: Dockerfile-airflow-domino-pod-dev + + domino-pod-gpu-base: + name: Domino Base Pod GPU Image + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push Domino POD Base image with GPU support + uses: docker/build-push-action@v3 + with: + push: true + tags: ghcr.io/tauffer-consulting/domino-airflow-pod:gpu-dev + context: . + file: Dockerfile-airflow-domino-pod-gpu + + domino-storage-sidecar: + name: Domino Storage Sidecar Image + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push Domino Storage Sidecar image + uses: docker/build-push-action@v3 + with: + push: true + tags: ghcr.io/tauffer-consulting/domino-shared-storage-sidecar:latest-dev + context: src/domino/custom_operators/sidecar + file: src/domino/custom_operators/sidecar/Dockerfile diff --git a/.github/workflows/release-airflow-images.yaml b/.github/workflows/release-airflow-images.yaml index e72777bf..19cddc55 100644 --- a/.github/workflows/release-airflow-images.yaml +++ b/.github/workflows/release-airflow-images.yaml @@ -5,7 +5,7 @@ on: branches: - main paths: - - domino/** + - src/domino/** # workflow_run: # workflows: # - "Build and publish domino-py package" @@ -26,21 +26,19 @@ jobs: uses: docker/setup-qemu-action@v2 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v2 - - name: Login to GitHub Container Registry uses: docker/login-action@v2 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - - name: Build and push Domino Airflow Base Prod + - name: Build and push Domino Airflow Base image uses: docker/build-push-action@v3 with: push: true tags: ghcr.io/tauffer-consulting/domino-airflow-base:latest context: . - file: Dockerfile-airflow-domino-base-dev # TODO check file to use in prod env, by now using the same as dev + file: Dockerfile-airflow-domino-base-prod domino-pod-base: #if: ${{ github.event.workflow_run.conclusion == 'success' }} @@ -59,13 +57,13 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push Domino POD Base Prod + - name: Build and push Domino POD Base image uses: docker/build-push-action@v3 with: push: true tags: ghcr.io/tauffer-consulting/domino-airflow-pod:latest context: . - file: Dockerfile-airflow-domino-pod-dev # TODO check file to use in prod env, by now using the same as dev + file: Dockerfile-airflow-domino-pod-prod domino-pod-gpu-base: #if: ${{ github.event.workflow_run.conclusion == 'success' }} @@ -84,7 +82,7 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push Domino POD Base Dev + - name: Build and push Domino POD Base image with GPU support uses: docker/build-push-action@v3 with: push: true @@ -109,7 +107,7 @@ jobs: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} - - name: Build and push + - name: Build and push Domino Storage Sidecar image uses: docker/build-push-action@v3 with: push: true diff --git a/.github/workflows/release-domino-py.yaml b/.github/workflows/release-domino-py.yaml index c05d494f..91c503af 100644 --- a/.github/workflows/release-domino-py.yaml +++ b/.github/workflows/release-domino-py.yaml @@ -5,7 +5,7 @@ on: branches: - main paths: - - domino/** + - src/domino/** jobs: github-release: diff --git a/.github/workflows/release-frontend-dev.yaml b/.github/workflows/release-frontend-dev.yaml new file mode 100644 index 00000000..037a4919 --- /dev/null +++ b/.github/workflows/release-frontend-dev.yaml @@ -0,0 +1,62 @@ +name: Build and publish Domino Frontend images + +on: + push: + branches: + - dev + paths: + - frontend/** + +jobs: + release-domino-frontend-k8s: + name: Domino Frontend Image + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v3 + with: + push: true # Push is a shorthand for --output=type=registry + tags: ghcr.io/tauffer-consulting/domino-frontend:k8s-dev + context: frontend + file: frontend/Dockerfile.prod # Path to the Dockerfile + env: + API_ENV: dev + + release-domino-frontend-compose: + name: Domino Frontend Image + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v3 + with: + push: true # Push is a shorthand for --output=type=registry + tags: ghcr.io/tauffer-consulting/domino-frontend:compose-dev + context: frontend + file: frontend/Dockerfile.prod # Path to the Dockerfile + env: + API_ENV: local + DOMINO_DEPLOY_MODE: local-compose diff --git a/.github/workflows/release-frontend.yaml b/.github/workflows/release-frontend.yaml index b2713e4f..27a93670 100644 --- a/.github/workflows/release-frontend.yaml +++ b/.github/workflows/release-frontend.yaml @@ -31,6 +31,9 @@ jobs: tags: ghcr.io/tauffer-consulting/domino-frontend:k8s context: frontend file: frontend/Dockerfile.prod # Path to the Dockerfile + env: + VITE_API_ENV: prod + release-domino-frontend-compose: name: Domino Frontend Image runs-on: ubuntu-latest @@ -53,4 +56,7 @@ jobs: push: true # Push is a shorthand for --output=type=registry tags: ghcr.io/tauffer-consulting/domino-frontend:compose context: frontend - file: frontend/Dockerfile.compose # Path to the Dockerfile \ No newline at end of file + file: frontend/Dockerfile.prod # Path to the Dockerfile + env: + API_ENV: local + DOMINO_DEPLOY_MODE: local-compose diff --git a/.github/workflows/release-rest-dev.yaml b/.github/workflows/release-rest-dev.yaml new file mode 100644 index 00000000..ba2d30bc --- /dev/null +++ b/.github/workflows/release-rest-dev.yaml @@ -0,0 +1,33 @@ +name: Build and publish Domino REST production image + +on: + push: + branches: + - dev + paths: + - rest/** + +jobs: + release-domino-rest: + name: Domino REST API Image + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Set up QEMU + uses: docker/setup-qemu-action@v2 + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v2 + - name: Login to GitHub Container Registry + uses: docker/login-action@v2 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - name: Build and push + uses: docker/build-push-action@v3 + with: + push: true # Push is a shorthand for --output=type=registry + tags: ghcr.io/tauffer-consulting/domino-rest:latest-dev + context: rest + file: rest/Dockerfile diff --git a/Dockerfile-airflow-domino-base-dev b/Dockerfile-airflow-domino-base-dev index 7e791cad..aa895ea1 100644 --- a/Dockerfile-airflow-domino-base-dev +++ b/Dockerfile-airflow-domino-base-dev @@ -10,11 +10,8 @@ RUN apt-get install nano -y # Editable pip install for Domino from local folder USER airflow RUN mkdir -p domino/domino_py -COPY setup.py domino/domino_py/ -COPY requirements.txt domino/domino_py/ -COPY requirements-airflow.txt domino/domino_py/ -COPY README.md domino/domino_py/ -COPY domino domino/domino_py/domino +COPY pyproject.toml domino/domino_py/ +COPY src/domino domino/domino_py/src/domino WORKDIR /opt/airflow/domino/domino_py USER root RUN chmod -R 777 . diff --git a/Dockerfile-airflow-domino-base-prod b/Dockerfile-airflow-domino-base-prod index 0c6543ef..2dd32892 100644 --- a/Dockerfile-airflow-domino-base-prod +++ b/Dockerfile-airflow-domino-base-prod @@ -7,4 +7,5 @@ USER root RUN apt-get update USER airflow -RUN pip install domino-py \ No newline at end of file +RUN pip install --upgrade pip \ + pip install domino-py[airflow] \ No newline at end of file diff --git a/Dockerfile-airflow-domino-compose-worker b/Dockerfile-airflow-domino-compose-worker index 15bb5210..28977f8a 100644 --- a/Dockerfile-airflow-domino-compose-worker +++ b/Dockerfile-airflow-domino-compose-worker @@ -44,11 +44,8 @@ WORKDIR /opt/airflow # Editable pip install for Domino from local folder USER airflow RUN mkdir -p domino/domino_py -COPY setup.py domino/domino_py/ -COPY requirements.txt domino/domino_py/ -COPY requirements-airflow.txt domino/domino_py/ -COPY README.md domino/domino_py/ -COPY domino domino/domino_py/domino +COPY pyproject.toml domino/domino_py/ +COPY src/domino domino/domino_py/src/domino # provisory repository used for testing only - remove it COPY ./pieces_repository_test domino/pieces_repository COPY ./pieces_repository_test/.domino domino/pieces_repository/.domino diff --git a/Dockerfile-airflow-domino-pod-dev b/Dockerfile-airflow-domino-pod-dev index e4c208bc..470b8a28 100644 --- a/Dockerfile-airflow-domino-pod-dev +++ b/Dockerfile-airflow-domino-pod-dev @@ -23,13 +23,10 @@ RUN chmod -R 777 . # Editable pip install for Domino from local folder RUN mkdir -p /home/domino/domino_py -COPY setup.py /home/domino/domino_py/ -COPY requirements.txt /home/domino/domino_py/ -COPY requirements-airflow.txt /home/domino/domino_py/ -COPY README.md /home/domino/domino_py/ -COPY domino /home/domino/domino_py/domino +COPY pyproject.toml /home/domino/domino_py/ +COPY src/domino /home/domino/domino_py/src/domino WORKDIR /home/domino/domino_py RUN chmod -R 777 . -RUN pip install --no-cache -e . +RUN pip install --no-cache -e .[piece] WORKDIR /home \ No newline at end of file diff --git a/Dockerfile-airflow-domino-pod-gpu b/Dockerfile-airflow-domino-pod-gpu index c9ae29ba..dea6af7b 100644 --- a/Dockerfile-airflow-domino-pod-gpu +++ b/Dockerfile-airflow-domino-pod-gpu @@ -31,13 +31,10 @@ RUN chmod -R 777 . # Editable pip install for Domino from local folder RUN mkdir -p /home/domino/domino_py -COPY setup.py /home/domino/domino_py/ -COPY requirements.txt /home/domino/domino_py/ -COPY requirements-airflow.txt /home/domino/domino_py/ -COPY README.md /home/domino/domino_py/ -COPY domino /home/domino/domino_py/domino +COPY pyproject.toml /home/domino/domino_py/ +COPY src/domino /home/domino/domino_py/src/domino WORKDIR /home/domino/domino_py RUN chmod -R 777 . -RUN pip install --no-cache -e . +RUN pip install --no-cache -e .[piece] WORKDIR /home \ No newline at end of file diff --git a/Dockerfile-airflow-domino-pod-prod b/Dockerfile-airflow-domino-pod-prod new file mode 100644 index 00000000..f9b01614 --- /dev/null +++ b/Dockerfile-airflow-domino-pod-prod @@ -0,0 +1,24 @@ +FROM python:3.9-slim + +ENV PYTHONDONTWRITEBYTECODE=1 +ENV PYTHONUNBUFFERED=1 + +# Create folders to store source code +RUN mkdir -p /home/domino/pieces_repository + +# Create folder to store run data results +RUN mkdir -p /home/run_data +WORKDIR /home/run_data +RUN chmod -R 777 . + +# Create folder for sidecar pod mount to read xcom data +RUN mkdir -p /airflow/xcom/ +RUN echo "{}" > /airflow/xcom/return.json +WORKDIR /airflow/xcom +RUN chmod -R 777 . + +# Editable pip install for Domino from local folder +RUN /usr/local/bin/python -m pip install --upgrade pip \ + pip install domino-py[piece] + +WORKDIR /home \ No newline at end of file diff --git a/README-dev.md b/README-dev.md new file mode 100644 index 00000000..b527797a --- /dev/null +++ b/README-dev.md @@ -0,0 +1,78 @@ +# Kubernetes + +When running the application in development mode using the key `DOMINO_DEPLOY_MODE=local-k8s-dev` in your `config.toml` file, the application will use the Kubernetes API to create some volumes mappings, allowing some development features: +1. Hot reloading for local domino package. +2. Use of local helm charts when creating the cluster. +3. Use of local frontend image in cluster. +4. Use of local rest image in cluster. +5. Hot reloading for local pieces repositories code. + + +### 1. Activating hot reloading for local domino package +If you want to run your cluster with hot reloading for your local domino package you should follow the next steps: +1. Go to your `config.toml` file in the `dev` section +2. Add the key `DOMINO_LOCAL_DOMINO_PACKAGE` with the value to the path of your `src/domino` folder of your local domino package. Example: `DOMINO_LOCAL_DOMINO_PACKAGE=path/to/your/domino/src/domino` + +### 2. Using local helm charts when creating the cluster +If you need to test run your cluster with local helm charts you should follow the same steps as the previous section. +The applicaton will get the `helm` folder from the path `path/to/your/domino/helm/domino` based on the path you defined in the `DOMINO_LOCAL_DOMINO_PACKAGE` key. +So, edit your helm charts in the `path/to/your/domino/helm/domino` folder and the application will use them when creating the cluster. + +### 3. Using local frontend image in cluster +If you want to run your cluster with a local frontend image you should follow the next steps: +1. Build your local frontend image. You can use the following command from the root of the project: `DOCKER_BUILDKIT=1 docker build -f ./frontend/Dockerfile.dev -t domino-frontend ./frontend` +2. Add the key `DOMINO_FRONTEND_IMAGE` with the value `domino-frontend` (or other tag you choose) to your `config.toml` file in the `dev` section. +**Important:** This will not activate hot reloading for your local frontend code. If you need to update your frontend code in the cluster you should build the image again. + +### 4. Using local rest image in cluster +As the frontend image, if you want to run your cluster with a local rest image you should follow the next steps: +1. Build your local rest image. You can use the following command from the root of the project: `DOCKER_BUILDKIT=1 docker build -f ./rest/Dockerfile -t domino-rest ./rest` +2. Add the key `DOMINO_REST_IMAGE` with the value `domino-rest` (or other tag you choose) to your `config.toml` file in the `dev` section. + +This will load your local rest image in the kind cluster. +**Important:** This will not activate hot reloading for your local rest code. If you need to update your rest code in the cluster you should build the image again. + +### 5. Using local airflow base image +If you need to update the base airflow image used in worker and scheduler containers you can do it by following the next steps: +1. Build your local airflow base image. You can use the following command from the root of the project: `DOCKER_BUILDKIT=1 docker build -f Dockerfile-airflow-domino-base-dev -t domino-airflow .` +2. Addthe key `DOMINO_AIRFLOW_IMAGE` with the value `domino-airflow` (or other tag you choose) to your `config.toml` file in the `dev` section. + +This will load your local airflow base image in the kind cluster. +**Important:** This is useful if you want to update airflow image dependencies. +To update only the domino package code in the airflow containers you don't need to build the image again, you can use the hot reloading feature for local domino package describe in the section 1. + + +### 5. Activating hot reloading for local pieces repositories code +You can activate hot reloading for the local code of your pieces repositories. To do so, you should follow the next steps: +1. Go to your `config.toml` file in the `dev` section +2. Add the key `` with the value to the path of your `` folder of your local pieces repository. Example: `default_domino_pieces=path/to/your/default_domino_pieces` +The `` must be the same name in the piece repository `config.toml` file in `REPOSITORY_NAME` key. + +The application will create a volume mapping between the path you specified and the path in the cluster where the pieces repository is mounted. This will allow you to update your pieces repository code and see the changes in the cluster without the need to build the image again. + + +# Docker Compose +You can run your application in development mode using docker compose file. +To do so, you should run using the `docker-compose-dev.yaml` file, as follows: +```bash +docker-compose -f docker-compose-dev.yaml up +``` +This will activate some development features, such as: +1. Hot reloading for local domino package in `worker` and `scheduler` containers. +2. Hot reloading for `rest` code. +3. Hot reloading for `frontend` code. + +**Important:** This will **not** activate hot reloading for your pieces repositories code and will **not** activate hot reloading for local domino package in the **pieces containers**. If you are running a piece and you need to have the domino package updated in the piece container you should update the piece image wit the domino development package you want to use. + +**Workaround:** As mentioned before, currently we don't have a direct way to allow hot reloading for domino package in pieces containers, however, you can use the following workaround: + +1. Go to `domino/src/domino/custom_operators/docker_operator.py` file. +2. In the `__init__` method and start the `mounts` variable with the following code: +```python +mounts = [ + Mount(source='/path/to/domino/src/domino', target='/home/domino/domino_py/', type='bind', read_only=True) +] +``` +The `source` must be the path to your local domino package. The `target` must be the path where the domino package will be mounted in the pieces container, by default the pieces images are built to use `/home/domino/domino_py/`. +**DEPRECATED WARNING:** The target path will be deprecated in the next update since the pieces will be built using `/home/domino/domino_py/src/domino` as the default path for the domino package. + diff --git a/README.md b/README.md index f846fdb2..fee63099 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ The Domino Python package can be installed via pip. We reccommend you install Do pip install domino-py ``` -You can then use Domino command line interface to easily run the Domino platform locally (requires Docker compose). Go to a new, empty directory and run the following command: +You can then use Domino command line interface to easily run the Domino platform locally (requires [Docker Compose V2](https://docs.docker.com/compose/)). Go to a new, empty directory and run the following command: ```bash domino platform run-compose diff --git a/docker-compose-dev.yaml b/docker-compose-dev.yaml index cd9a5f0c..52e45fbb 100644 --- a/docker-compose-dev.yaml +++ b/docker-compose-dev.yaml @@ -33,15 +33,9 @@ x-airflow-common: condition: service_healthy services: - docker-proxy: - image: bobrik/socat - command: "TCP4-LISTEN:2375,fork,reuseaddr UNIX-CONNECT:/var/run/docker.sock" - ports: - - "2376:2375" - volumes: - - /var/run/docker.sock:/var/run/docker.sock postgres: image: postgres:13 + container_name: airflow-postgres environment: POSTGRES_USER: airflow POSTGRES_PASSWORD: airflow @@ -57,6 +51,7 @@ services: redis: image: redis:latest + container_name: airflow-redis expose: - 6379 healthcheck: @@ -69,6 +64,7 @@ services: airflow-webserver: <<: *airflow-common + container_name: airflow-webserver command: webserver ports: - "8080:8080" @@ -92,6 +88,7 @@ services: airflow-triggerer: <<: *airflow-common + container_name: airflow-triggerer command: triggerer healthcheck: test: @@ -111,6 +108,7 @@ services: airflow-init: <<: *airflow-common + container_name: airflow-init entrypoint: /bin/bash # yamllint disable rule:line-length command: @@ -189,6 +187,7 @@ services: airflow-cli: <<: *airflow-common + container_name: airflow-cli profiles: - debug environment: @@ -205,6 +204,7 @@ services: # See: https://docs.docker.com/compose/profiles/ flower: <<: *airflow-common + container_name: airflow-flower command: celery flower profiles: - flower @@ -225,7 +225,7 @@ services: # Modified Airflow Scheduler with Domino airflow-scheduler: <<: *airflow-common - # image: ghcr.io/tauffer-consulting/domino-airflow-base:latest + image: ghcr.io/tauffer-consulting/domino-airflow-base:latest build: context: . dockerfile: Dockerfile-airflow-domino-base-dev @@ -251,7 +251,7 @@ services: - ${AIRFLOW_PROJ_DIR:-./airflow}/dags:/opt/airflow/dags - ${AIRFLOW_PROJ_DIR:-./airflow}/logs:/opt/airflow/logs - ${AIRFLOW_PROJ_DIR:-./airflow}/plugins:/opt/airflow/plugins - - ./domino/:/opt/airflow/domino/domino_py/domino # Hot reload domino package - remove in production + - ./src/domino/:/opt/airflow/domino/domino_py/src/domino # Hot reload domino package - remove in production depends_on: <<: *airflow-common-depends-on airflow-init: @@ -260,7 +260,7 @@ services: # Modified Airflow Worker with Domino airflow-worker: <<: *airflow-common - # image: ghcr.io/tauffer-consulting/domino-airflow-base:latest + image: ghcr.io/tauffer-consulting/domino-airflow-base:latest build: context: . dockerfile: Dockerfile-airflow-domino-base-dev @@ -287,7 +287,7 @@ services: - ${AIRFLOW_PROJ_DIR:-./airflow}/logs:/opt/airflow/logs - ${AIRFLOW_PROJ_DIR:-./airflow}/plugins:/opt/airflow/plugins - ${PWD}/domino_data:/home/shared_storage - - ./domino/:/opt/airflow/domino/domino_py/domino # Enable hot reload for domino package - remove in production + - ./src/domino/:/opt/airflow/domino/domino_py/src/domino # Enable hot reload for domino package - remove in production depends_on: <<: *airflow-common-depends-on airflow-init: @@ -309,6 +309,7 @@ services: - DOMINO_DB_HOST=domino_postgres - DOMINO_DB_PORT=5432 - DOMINO_DB_NAME=postgres + - DOMINO_DEFAULT_PIECES_REPOSITORY_VERSION=0.3.12 - DOMINO_DEFAULT_PIECES_REPOSITORY_TOKEN=${DOMINO_DEFAULT_PIECES_REPOSITORY_TOKEN} - DOMINO_GITHUB_ACCESS_TOKEN_WORKFLOWS=${DOMINO_GITHUB_ACCESS_TOKEN_WORKFLOWS} - DOMINO_GITHUB_WORKFLOWS_REPOSITORY=${DOMINO_GITHUB_WORKFLOWS_REPOSITORY} @@ -353,22 +354,33 @@ services: # Domino Frontend domino_frontend: - image: ghcr.io/tauffer-consulting/domino-frontend:compose - # build: - # context: ./frontend - # dockerfile: Dockerfile + #image: ghcr.io/tauffer-consulting/domino-frontend:compose + build: + context: ./frontend + dockerfile: Dockerfile.dev container_name: domino-frontend - # command: yarn start:local + command: yarn start environment: - - REACT_APP_DOMINO_DEPLOY_MODE=local-compose + - API_ENV=local + - DOMINO_DEPLOY_MODE=local-compose ports: - - "3000:80" - # volumes: - # - ./frontend:/usr/src/app # Enable hot reload for frontend + - 3000:3000 + volumes: + - ./frontend:/usr/src/app # Enable hot reload for frontend depends_on: domino_rest: condition: service_started + # Domino Docker Proxy + docker-proxy: + image: bobrik/socat + container_name: domino-docker-proxy + command: "TCP4-LISTEN:2375,fork,reuseaddr UNIX-CONNECT:/var/run/docker.sock" + ports: + - "2376:2375" + volumes: + - /var/run/docker.sock:/var/run/docker.sock + volumes: postgres-db-volume: null domino-postgres-volume: null diff --git a/domino/__init__.py b/domino/__init__.py deleted file mode 100644 index 71525559..00000000 --- a/domino/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .version import __version__ \ No newline at end of file diff --git a/domino/schemas/from_upstream.py b/domino/schemas/from_upstream.py deleted file mode 100644 index 34410f28..00000000 --- a/domino/schemas/from_upstream.py +++ /dev/null @@ -1,10 +0,0 @@ -from pydantic import BaseModel, Field - - -class FromUpstream(BaseModel): - upstream_task_id: str = Field( - description="Upstream task id", - ) - output_arg: str = Field( - description="Output argument from upstream task", - ) \ No newline at end of file diff --git a/domino/version.py b/domino/version.py deleted file mode 100644 index 3d26edf7..00000000 --- a/domino/version.py +++ /dev/null @@ -1 +0,0 @@ -__version__ = "0.4.1" diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 00000000..6fd10a4d --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1 @@ +/node_modules/* \ No newline at end of file diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 00000000..5d47c21c --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,12 @@ +# EditorConfig is awesome: https://EditorConfig.org + +# top-most EditorConfig file +root = true + +[*] +indent_style = space +indent_size = 2 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/frontend/.env.production b/frontend/.env.production new file mode 100644 index 00000000..d9366126 --- /dev/null +++ b/frontend/.env.production @@ -0,0 +1,3 @@ +# These values are placeholders +API_ENV="local" +DOMINO_DEPLOY_MODE="local-compose" diff --git a/frontend/.eslintignore b/frontend/.eslintignore new file mode 100644 index 00000000..d782ae72 --- /dev/null +++ b/frontend/.eslintignore @@ -0,0 +1,6 @@ +# Third party +**/node_modules + +# Build products +build/ +coverage/ diff --git a/frontend/.eslintrc.json b/frontend/.eslintrc.json new file mode 100644 index 00000000..409c6007 --- /dev/null +++ b/frontend/.eslintrc.json @@ -0,0 +1,131 @@ +{ + "env": { + "browser": true, + "es2021": true, + "node": true + }, + "extends": [ + "standard-with-typescript", + "plugin:react/recommended", + "plugin:prettier/recommended" + ], + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module" + }, + "plugins": [ + "react", + "prettier" + ], + "settings": { + "react": { + "version": "detect" + } + }, + "rules": { + "no-restricted-imports": [ + "error", + { + "patterns": [ + "@/features/*/*" + ] + } + ], + "linebreak-style": [ + "error", + "unix" + ], + "react/prop-types": "off", + "import/order": [ + "error", + { + "groups": [ + "builtin", + "external", + "internal", + "parent", + "sibling", + "index", + "object" + ], + "newlines-between": "always", + "alphabetize": { + "order": "asc", + "caseInsensitive": true + } + } + ], + "import/default": "off", + "import/no-named-as-default-member": "off", + "import/no-named-as-default": "off", + "react/react-in-jsx-scope": "off", + "jsx-a11y/anchor-is-valid": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_", + "varsIgnorePattern": "^_" + } + ], + "@typescript-eslint/explicit-function-return-type": [ + "off" + ], + "@typescript-eslint/explicit-module-boundary-types": [ + "off" + ], + "@typescript-eslint/no-empty-function": [ + "off" + ], + "@typescript-eslint/no-explicit-any": [ + "off" + ], + "@typescript-eslint/strict-boolean-expressions": [ + "off" + ], + "@typescript-eslint/no-misused-promises": [ + "error", + { + "checksVoidReturn": false + } + ], + "@typescript-eslint/naming-convention": [ + "warn", + { + "selector": "variable", + "format": [ + "camelCase", + "snake_case", + "PascalCase", + "UPPER_CASE" + ], + "filter":{ + "regex": "^_", + "match":false + } + }, + { + "selector": "function", + "format": [ + "camelCase", + "PascalCase" + ] + }, + { + "selector": "typeLike", + "format": [ + "PascalCase" + ] + }, + { + "selector": "enum", + "format": [ + "camelCase" + ] + } + ], + "prettier/prettier": [ + "error", + {} + ] + } +} diff --git a/frontend/Dockerfile.compose b/frontend/Dockerfile.compose deleted file mode 100644 index ff0a9862..00000000 --- a/frontend/Dockerfile.compose +++ /dev/null @@ -1,34 +0,0 @@ -FROM node:14-alpine as build - -# Set the working directory -WORKDIR /app - -# Copy the package.json and yarn.lock files -COPY package.json yarn.lock ./ - -ENV REACT_APP_API_ENV=local -ENV REACT_APP_DOMINO_DEPLOY_MODE=local-compose - -# Install dependencies -RUN yarn install - -# Copy the rest of the source code -COPY . . - -# Build the app for production -RUN yarn build - -# Use nginx as the web server -FROM nginx:1.19-alpine - -# Copy the build output from the previous stage -COPY --from=build /app/build /usr/share/nginx/html - -# Configure nginx to serve the app -COPY nginx.conf /etc/nginx/conf.d/default.conf - -# Expose port 80 -EXPOSE 80 - -# Run nginx in the foreground -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile.dev similarity index 65% rename from frontend/Dockerfile rename to frontend/Dockerfile.dev index 55bd6a3d..360cff5a 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile.dev @@ -1,20 +1,19 @@ # Refs: # https://dev.to/otomato_io/how-to-optimize-production-docker-images-running-nodejs-with-yarn-504b # https://snyk.io/blog/10-best-practices-to-containerize-nodejs-web-applications-with-docker/ +FROM node:18-alpine -FROM node:19.2-bullseye-slim - -ENV REACT_APP_API_ENV=local -ENV DOMINO_DEPLOY_MODE=local-compose +ENV VITE_API_ENV=dev # ENV PATH /app/node_modules/.bin:$PATH WORKDIR /usr/src/app COPY --chown=node:node . /usr/src/app -RUN mkdir -p node_modules/.cache +RUN mkdir -p node_modules/.cache RUN chmod -R 777 node_modules/.cache +RUN mkdir -p node_modules/.vite +RUN chmod -R 777 node_modules/.vite -RUN yarn install --frozen-lockfile --production -RUN yarn cache clean +RUN yarn install --frozen-lockfile && yarn cache clean USER node CMD ["yarn", "start"] diff --git a/frontend/Dockerfile.prod b/frontend/Dockerfile.prod index 0db9d235..470efe6c 100644 --- a/frontend/Dockerfile.prod +++ b/frontend/Dockerfile.prod @@ -1,13 +1,17 @@ -FROM node:14-alpine as build +# Stage 1: Build the React app +FROM node:18-slim as build # Set the working directory WORKDIR /app -# Copy the package.json and yarn.lock files +# Copy package.json and yarn.lock files COPY package.json yarn.lock ./ # Install dependencies -RUN yarn install +RUN yarn install --production --frozen-lockfile && yarn cache clean + +RUN npx pkg ./node_modules/@import-meta-env/cli/bin/import-meta-env.js \ + -o import-meta-env -y # Copy the rest of the source code COPY . . @@ -15,17 +19,20 @@ COPY . . # Build the app for production RUN yarn build -# Use nginx as the web server -FROM nginx:1.19-alpine +# Stage 2: Create a minimal NGINX image +FROM nginx:1.25.2 # Copy the build output from the previous stage COPY --from=build /app/build /usr/share/nginx/html +COPY --from=build /app/import-meta-env /usr/share/nginx/html # Configure nginx to serve the app COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY entrypoint.sh /usr/share/nginx/html + # Expose port 80 EXPOSE 80 -# Run nginx in the foreground -CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file +# Run NGINX in the foreground +ENTRYPOINT ["sh","/usr/share/nginx/html/entrypoint.sh"] diff --git a/frontend/README.md b/frontend/README.md index 527c12ff..4a845002 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1,8 +1,47 @@ # Domino Frontend +# A GUI for creating workflows. +### Recommended -A GUI for creating workflows. +This config allow you to ensure code style every time you save a file in frontend folder, +alternatively you can just run the command `yarn lint:fix` +- [ESlint extension](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + - Add to settings.json ([Ctrl + Shift + P] to open 'Open Settings (JSON)') + ```json + { + ... + "editor.codeActionsOnSave": { + "source.fixAll.eslint": true, + "source.fixAll.prettier": true + }, + "eslint.validate": [ + "javascript", + "typescript", + "javascriptreact", + "typescriptreact" + ] + ... + } + ``` + +### How to Run + +#### Install dependencies + +```bash +yarn install +``` + +#### Run the application Running Domino frontend locally: ```bash yarn start -``` \ No newline at end of file +``` + +### Build image + +```bash +DOCKER_BUILDKIT=1 docker build -f ./Dockerfile.prod -t domino-frontend . +``` + +### [Project Structure](./docs/project-structure.md) diff --git a/frontend/docs/project-structure.md b/frontend/docs/project-structure.md new file mode 100644 index 00000000..2e7fbcf5 --- /dev/null +++ b/frontend/docs/project-structure.md @@ -0,0 +1,79 @@ +# 🗄️ Project Structure + +Most of the code lives in the `src` folder and looks like this: + +```sh +src +| ++-- @types # base types used across the application +| ++-- components # shared components used across the entire application +| ++-- config # all the global configuration, env variables etc. get exported from here and used in the app +| ++-- context # shared context used across the entire application +| ++-- features # feature based modules +| ++-- providers # all of the application providers +| ++-- routes # routes configuration +| ++-- services # re-exporting different libraries preconfigured for the application +| ++-- test # test utilities and mock server +| ++-- utils # shared utility functions +``` + +In order to scale the application in the easiest and most maintainable way, keep most of the code inside the `features` folder, which should contain different feature-based things. Every `feature` folder should contain domain specific code for a given feature. This will allow you to keep functionalities scoped to a feature and not mix its declarations with shared things. This is much easier to maintain than a flat folder structure with many files. + +A feature could have the following structure: + +```sh +src/features/awesome-feature +| ++-- api # exported API request declarations and api hooks related to a specific feature +| ++-- assets # assets folder can contain all the static files for a specific feature +| ++-- components # components scoped to a specific feature +| ++-- context # context scoped to a specific feature +| ++-- pages # route components for a specific feature pages +| ++-- types # typescript types for TS specific feature domain +| ++-- utils # utility functions for a specific feature +| ++-- index.ts # entry point for the feature, it should serve as the public API of the given feature and exports everything that should be used outside the feature +``` + +Everything from a feature should be exported from the `index.ts` file which behaves as the public API of the feature. + +You should import stuff from other features only by using: + +`import {AwesomeComponent} from "features/awesome-feature"` + +and not + +`import {AwesomeComponent} from "features/awesome-feature/components/AwesomeComponent` + +This can also be configured in the ESLint configuration to disallow the later import by the following rule: + +```js +{ + rules: { + 'no-restricted-imports': [ + 'error', + { + patterns: ['features/*/*'], + }, + ], + + // ...rest of the configuration +} +``` + +This was inspired by how [NX](https://nx.dev/) handles libraries that are isolated but available to be used by the other modules. Think of a feature as a library or a module that is self-contained but can expose different parts to other features via its entry point. diff --git a/frontend/entrypoint.sh b/frontend/entrypoint.sh new file mode 100644 index 00000000..4ba1aad1 --- /dev/null +++ b/frontend/entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +echo "API_ENV=$API_ENV" >> .env.production +echo "DOMINO_DEPLOY_MODE=$DOMINO_DEPLOY_MODE" >> .env.production + +/usr/share/nginx/html/import-meta-env -x .env.production -p /usr/share/nginx/html/index.html || exit 1 + +nginx -g "daemon off;" diff --git a/frontend/public/index.html b/frontend/index.html similarity index 80% rename from frontend/public/index.html rename to frontend/index.html index 76550375..7c77f587 100644 --- a/frontend/public/index.html +++ b/frontend/index.html @@ -4,9 +4,11 @@ - + @@ -20,6 +22,7 @@
+ - \ No newline at end of file + diff --git a/frontend/package.json b/frontend/package.json index 4f5615e5..a3e87d2c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,55 +2,55 @@ "name": "domino", "version": "0.1.0", "private": true, + "engines": { + "node": ">=18 < 20" + }, "dependencies": { - "@babel/core": "^7.20.5", - "@babel/plugin-syntax-flow": "^7.18.6", - "@babel/plugin-transform-react-jsx": "^7.19.0", "@emotion/react": "^11.10.5", "@emotion/styled": "^11.10.5", - "@jsonforms/core": "^3.0.0", - "@jsonforms/material-renderers": "^3.0.0", - "@jsonforms/react": "^3.0.0", + "@import-meta-env/cli": "^0.6.6", + "@import-meta-env/unplugin": "^0.4.10", "@material-ui/core": "^4.12.4", - "@material-ui/icons": "^4.11.3", "@mui/icons-material": "^5.11.0", "@mui/lab": "^5.0.0-alpha.113", "@mui/material": "^5.11.1", - "@mui/system": "^5.11.1", - "@mui/x-data-grid": "^5.17.16", - "@mui/x-date-pickers": "^5.0.11", - "@react-pdf/renderer": "^3.1.9", + "@mui/x-data-grid": "^6.15.0", + "@mui/x-date-pickers": "^6.5.0", "@types/react-dom": "^18.0.9", "@types/uuid": "^9.0.0", - "ag-grid-community": "^28.2.1", - "ag-grid-react": "^28.2.1", + "@uiw/react-textarea-code-editor": "^2.1.1", + "@vitejs/plugin-react": "^4.1.0", "axios": "^1.2.1", "axios-mock-adapter": "^1.21.2", "cross-env": "^7.0.3", + "date-fns": "^2.30.0", + "dayjs": "^1.11.7", + "dotenv": "^16.3.1", + "elkjs": "^0.8.2", "localforage": "^1.10.0", - "material-ui-dropzone": "^3.5.0", - "prop-types": "^15.8.1", "react": "^18.2.0", - "react-datepicker": "^4.8.0", "react-dom": "^18.2.0", "react-draggable": "^4.4.5", - "react-hook-form": "^7.41.0", + "react-hook-form": "^7.45.1", "react-markdown": "^8.0.7", "react-router-dom": "^6.6.0", - "react-scripts": "^5.0.1", "react-toastify": "^9.1.1", "reactflow": "^11.4.0", "swr": "^2.0.0", - "typescript": "^4.9.4", - "uuid": "^9.0.0" + "typescript": "*", + "uuid": "^9.0.0", + "vite": "^4.4.11", + "vite-plugin-svgr": "^2.2.1", + "vite-tsconfig-paths": "^3.5.0", + "web-worker": "^1.2.0", + "yup": "^1.2.0" }, "scripts": { - "start": "cross-env REACT_APP_API_ENV=dev react-scripts start", - "start:local": "cross-env NODE_OPTIONS=--openssl-legacy-provider REACT_APP_API_ENV=local react-scripts start", - "start:mock": "cross-env NODE_OPTIONS=--openssl-legacy-provider REACT_APP_API_ENV=local REACT_APP_USE_MOCK=true react-scripts start", - "build": "cross-env react-scripts build", - "test": "cross-env NODE_OPTIONS=--openssl-legacy-provider react-scripts test", - "eject": "cross-env NODE_OPTIONS=--openssl-legacy-provider react-scripts eject" + "start": "vite", + "start:mock": "cross-env NODE_OPTIONS=--openssl-legacy-provider VITE_USE_MOCK=true vite", + "build": "tsc && vite build", + "lint": "eslint . --ext js,jsx,ts,tsx", + "lint:fix": "eslint . --ext js,jsx,ts,tsx --fix" }, "eslintConfig": { "extends": [ @@ -62,5 +62,21 @@ ">0.2%", "not dead", "not op_mini all" - ] + ], + "devDependencies": { + "@typescript-eslint/eslint-plugin": "^6.4.0", + "@typescript-eslint/parser": "^6.5.0", + "eslint": "^8.0.1", + "eslint-config-prettier": "^9.0.0", + "eslint-config-standard-with-typescript": "^39.0.0", + "eslint-import-resolver-typescript": "^3.6.0", + "eslint-plugin-import": "^2.25.2", + "eslint-plugin-n": "^15.0.0 || ^16.0.0 ", + "eslint-plugin-prettier": "^5.0.0", + "eslint-plugin-promise": "^6.0.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-testing-library": "^6.0.1", + "prettier": "^3.0.3" + } } diff --git a/frontend/src/@types/global.d.ts b/frontend/src/@types/global.d.ts new file mode 100644 index 00000000..cf982313 --- /dev/null +++ b/frontend/src/@types/global.d.ts @@ -0,0 +1,3 @@ +/* eslint-disable @typescript-eslint/triple-slash-reference */ +/// +/// diff --git a/frontend/src/@types/piece/index.d.ts b/frontend/src/@types/piece/index.d.ts new file mode 100644 index 00000000..cbce8176 --- /dev/null +++ b/frontend/src/@types/piece/index.d.ts @@ -0,0 +1,19 @@ +/* eslint-disable @typescript-eslint/consistent-type-imports */ +declare global { + type Piece = import("./piece").Piece; + type PieceSchema = import("./piece").PieceSchema; + type Definitions = import("./piece").Definitions; + type Definition = import("./piece").Definition; + type ObjectDefinition = import("./piece").ObjectDefinition; + type EnumDefinition = import("./piece").EnumDefinition; + type SimpleInputSchemaProperty = import("./piece").SimpleInputSchemaProperty; + type ArrayObjectProperty = import("./piece").ArrayObjectProperty; + type InputSchemaProperty = import("./piece").InputSchemaProperty; + type FromUpstream = import("./piece").FromUpstream; + + type PieceForageSchema = import("./piece").PieceForageSchema; + type PiecesRepository = import("./piece").PiecesRepository; + type PieceRepository = import("./piece").PieceRepository; +} + +export {}; diff --git a/frontend/src/@types/piece/piece.d.ts b/frontend/src/@types/piece/piece.d.ts new file mode 100644 index 00000000..e3f404f8 --- /dev/null +++ b/frontend/src/@types/piece/piece.d.ts @@ -0,0 +1,85 @@ +/* eslint-disable @typescript-eslint/consistent-type-imports */ +export type FromUpstream = import("./properties").FromUpstream; + +export type InputSchemaProperty = import("./properties").InputSchemaProperty; +export type ArrayObjectProperty = import("./properties").ArrayObjectProperty; + +export type SimpleInputSchemaProperty = + import("./properties").SimpleInputSchemaProperty; + +export interface EnumDefinition { + title: string; + description: string; + type: "string"; + enum: string[]; +} + +export interface ObjectDefinition { + title: string; + description: string; + type: "object"; + properties: Record; +} + +export type Definition = EnumDefinition | ObjectDefinition; + +export type Definitions = Record; + +export type SchemaProperties = Record; + +export interface PieceSchema { + title: string; + description: string; + + type: "object"; + + properties: SchemaProperties; + definitions: Definitions; +} + +export interface Piece { + id: number; + name: string; + description: string; + + repository_id: number; + + input_schema: PieceSchema; + output_schema: PieceSchema; + secrets_schema: PieceSchema | null; + + source_image: string; + source_url: string | null; + dependency: { + docker_image: string | null; + dockerfile: string | null; + requirements_file: string | null; + }; + + style?: { + label?: string; + module?: string; + + nodeType?: string; + nodeStyle?: CSSProperties; + + useIcon?: boolean; + iconClassName?: string; + iconStyle?: CSSProperties; + }; +} + +export type PieceForageSchema = Record; + +export type PiecesRepository = Record; + +export interface PieceRepository { + id: string; + name: string; + label: string; + created_at: string; + source: string; + path: string; + version: string; + workspace_id: number; +} diff --git a/frontend/src/@types/piece/properties.d.ts b/frontend/src/@types/piece/properties.d.ts new file mode 100644 index 00000000..38637624 --- /dev/null +++ b/frontend/src/@types/piece/properties.d.ts @@ -0,0 +1,81 @@ +interface Reference { + $ref: `#/definitions/${string}`; +} +type FromUpstream = "always" | "never" | "allowed"; + +interface DefaultPropertyProps { + title: string; + description: string; + from_upstream?: FromUpstream; +} + +type BooleanProperty = DefaultPropertyProps & { + type: "boolean"; + default: boolean; +}; + +type NumberProperty = DefaultPropertyProps & { + type: "number" | "integer" | "float"; + default: number; + exclusiveMaximum?: number; + exclusiveMinimum?: number; +}; + +type StringProperty = DefaultPropertyProps & { + type: "string"; + default: string; + + widget?: string; + format?: "date" | "time" | "date-time"; +}; + +type EnumProperty = DefaultPropertyProps & { + allOf: Reference[]; + default: string; +}; + +type ArrayStringProperty = DefaultPropertyProps & { + type: "array"; + default: string[]; + items: { + type: "string"; + + widget?: string; + format?: "date" | "time" | "date-time"; + }; +}; + +type ArrayNumberProperty = DefaultPropertyProps & { + type: "array"; + default: number[]; + items: { + type: "number" | "integer"; + }; +}; + +type ArrayBooleanProperty = DefaultPropertyProps & { + type: "array"; + default: boolean[]; + items: { + type: "boolean"; + }; +}; + +type ArrayObjectProperty = DefaultPropertyProps & { + type: "array"; + default: Array>; + items: Reference; +}; + +export type SimpleInputSchemaProperty = + | BooleanProperty + | NumberProperty + | StringProperty + | EnumProperty; + +export type InputSchemaProperty = + | SimpleInputSchemaProperty + | ArrayStringProperty + | ArrayNumberProperty + | ArrayBooleanProperty + | ArrayObjectProperty; diff --git a/frontend/src/@types/utils/index.d.ts b/frontend/src/@types/utils/index.d.ts new file mode 100644 index 00000000..cea52507 --- /dev/null +++ b/frontend/src/@types/utils/index.d.ts @@ -0,0 +1,12 @@ +declare global { + type FunctionOrPromiseReturnType = T extends ( + instance: ReactFlowInstance, + ) => infer R + ? R + : T extends ( + instance: ReactFlowInstance, + ) => Promise + ? R + : never; +} +export {}; diff --git a/frontend/src/common/config/environment.config.ts b/frontend/src/common/config/environment.config.ts deleted file mode 100644 index da37bb54..00000000 --- a/frontend/src/common/config/environment.config.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { - IEnvironment, - INodeEnv, - IApiEnv -} from 'common/interfaces/environment.interface' - -/** - * Stores all environment variables for easier access - */ -export const environment: IEnvironment = { - NODE_ENV: process.env.NODE_ENV as INodeEnv, - API_ENV: process.env.REACT_APP_API_ENV as IApiEnv, - USE_MOCK: !!process.env.REACT_APP_USE_MOCK -} diff --git a/frontend/src/common/hocs/with-context.hoc.tsx b/frontend/src/common/hocs/with-context.hoc.tsx deleted file mode 100644 index faf70fce..00000000 --- a/frontend/src/common/hocs/with-context.hoc.tsx +++ /dev/null @@ -1,17 +0,0 @@ -import { FC, memo } from 'react' - -export function withContext

(Provider: FC

, Component: FC

): FC

{ - const MemoizedComponent = memo(Component) - /** - * @todo solve as any type - */ - const WithContext: FC

= (props) => { - return ( - - - - ) - } - - return WithContext -} diff --git a/frontend/src/common/interfaces/environment.interface.ts b/frontend/src/common/interfaces/environment.interface.ts deleted file mode 100644 index 43712047..00000000 --- a/frontend/src/common/interfaces/environment.interface.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** - * @todo add other envs when available - */ -export type INodeEnv = 'development' -export type IApiEnv = 'local' | 'dev' | 'prod' - -export interface IEnvironment { - NODE_ENV: INodeEnv - API_ENV: IApiEnv - USE_MOCK: boolean -} diff --git a/frontend/src/common/interfaces/repository-source.enum.ts b/frontend/src/common/interfaces/repository-source.enum.ts deleted file mode 100644 index 357eb937..00000000 --- a/frontend/src/common/interfaces/repository-source.enum.ts +++ /dev/null @@ -1,3 +0,0 @@ -export enum ERepositorySource { - github = 'github' -} diff --git a/frontend/src/common/schemas/containerResourcesSchemas.ts b/frontend/src/common/schemas/containerResourcesSchemas.ts deleted file mode 100644 index 07be7098..00000000 --- a/frontend/src/common/schemas/containerResourcesSchemas.ts +++ /dev/null @@ -1,50 +0,0 @@ -export const containerResourcesSchema = { - type: 'object', - properties: { - "cpu": { - "type": "object", - "title": "CPU", - "properties": { - "min": { - "title": "Min - Thousandth of a core (m)", - "type": "number", - "default": 100, // todo: min value we will accept ? - //"maximum": 10000, // todo: max value we will accept ? - "minimum": 50 // todo: min value we will accept ? - }, - "max": { - "title": "Max - Thousandth of a core (m)", - "type": "number", - "default": 100, - //"maximum": 10000, // todo: max value we will accept ? - "minimum": 50 // todo: min value we will accept ? - } - } - }, - "memory": { - "type": "object", - "title": "Memory", - "properties": { - "min": { - "title": "Min - Mebibyte (Mi)", - "type": "number", - "default": 128, - //'maximum': 15258, // todo: max value we will accept ? - 'minimum': 32 - }, - "max": { - "title": "Max - Mibibyte (Mi)", - "type": "number", - "default": 128, - //"maximum": 15258, // todo: max value we will accept ? - 'minimum': 32 - } - } - }, - "useGpu":{ - "title": "Use GPU", - "type": "boolean", - "default": false - }, - } -} \ No newline at end of file diff --git a/frontend/src/common/schemas/storageSchemas.ts b/frontend/src/common/schemas/storageSchemas.ts deleted file mode 100644 index 47e34644..00000000 --- a/frontend/src/common/schemas/storageSchemas.ts +++ /dev/null @@ -1,15 +0,0 @@ -export const operatorStorageSchema = { - type: 'object', - properties: { - "storageAccessMode": { - "title": "Access Mode", - "type": "string", - "enum": [ - "None", - "Read", - "Read/Write", - ], - "default": "Read/Write" - }, - } -} \ No newline at end of file diff --git a/frontend/src/common/schemas/workflowFormSchema.ts b/frontend/src/common/schemas/workflowFormSchema.ts deleted file mode 100644 index bf059836..00000000 --- a/frontend/src/common/schemas/workflowFormSchema.ts +++ /dev/null @@ -1,152 +0,0 @@ - -export const workflowFormSchema = { - "type": "object", - "title": "workflowForm", - "properties": { - "config": { - "type": "object", - "title": "Config", - "properties": { - "name": { - "title": "Name", - "type": "string", - "minLength": 3, - "description": "Workflow Name" - }, - "schedule_interval": { - "title": "Schedule Interval", - "type": "string", - "enum": [ - "none", - "once", - "hourly", - "daily", - "weekly", - "monthly", - "yearly" - ], - "default": "none" - }, - "start_date": { - "title": "Start Date", - "type": "string", - "format": "date" - }, - "generate_report": { - "title": "Generate Report", - "type": "boolean", - "default": false, - "description": "[description]" - } - }, - "required": [ - "name", - "start_date", - "schedule_interval" - ] - }, - // Why object schemas is not separating the forms in two section as before? - "storage": { - "type": "object", - "title": "Storage", - "properties": { - "source": { - "title": "Storage Source", - "type": "string", - "enum": process.env.REACT_APP_DOMINO_DEPLOY_MODE === "local-compose" ? [ - "None", - "Local", - ] : - [ - "None", - "AWS S3" - ], - "default": "None" - }, - "baseFolder": { - "title": "Base folder", - "type": "string", - "description": "Base folder from source storage.", - "default": "" - }, - "bucket": { - "title": "Bucket name", - "type": "string", - "description": "Name for the S3 Bucket.", - } - }, - "required": [ - "source" - ] - } - }, -} - -export const workflowFormUISchema = { - "type": "VerticalLayout", - "elements": [ - { - "type": "Group", - "label": "Settings", - "elements": [ - { - "type": "Control", - "scope": "#/properties/config/properties/name" - }, - { - "type": "Control", - "scope": "#/properties/config/properties/schedule_interval" - }, - { - "type": "Control", - "scope": "#/properties/config/properties/start_date" - }, - { - "type": "Control", - "scope": "#/properties/config/properties/generate_report" - }, - ] - }, - // TODO check why group label font size is small - { - "type": "Group", - "label": "Storage", - "elements": [ - { - "type": "Control", - "scope": "#/properties/storage/properties/source" - }, - { - "type": "Control", - "scope": "#/properties/storage/properties/baseFolder", - "rule": { - "effect": "SHOW", - "condition": { - "scope": "#/properties/storage/properties/source", - "schema": { - "enum": [ - "AWS S3" - ] - } - } - } - }, - { - "type": "Control", - "scope": "#/properties/storage/properties/bucket", - "rule": { - "effect": "SHOW", - "condition": { - "scope": "#/properties/storage/properties/source", - "schema": { - "enum": [ - "AWS S3" - ] - } - } - } - }, - ] - } - ] -} \ No newline at end of file diff --git a/frontend/src/components/Breadcrumbs/index.tsx b/frontend/src/components/Breadcrumbs/index.tsx new file mode 100644 index 00000000..53556f9d --- /dev/null +++ b/frontend/src/components/Breadcrumbs/index.tsx @@ -0,0 +1,57 @@ +import NavigateNextIcon from "@mui/icons-material/NavigateNext"; +import BreadcrumbsMui from "@mui/material/Breadcrumbs"; +import Typography from "@mui/material/Typography"; +import React from "react"; +import { Link as RouterLink, useLocation } from "react-router-dom"; + +export const Breadcrumbs: React.FC = () => { + const location = useLocation(); + const pathnames = location.pathname.split("/").filter((x) => x); + + return ( + } + > + {pathnames.map((value, index) => { + const last = index === pathnames.length - 1; + const to = `/${pathnames.slice(0, index + 1).join("/")}`; + const capitalizedValue = value.charAt(0).toUpperCase() + value.slice(1); + + return last ? ( + + {capitalizedValue} + + ) : ( + { + e.currentTarget.style.opacity = "0.7"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.opacity = "1"; + }} + > + + {capitalizedValue} + + + ); + })} + + ); +}; diff --git a/frontend/src/components/CheckboxInput/index.tsx b/frontend/src/components/CheckboxInput/index.tsx new file mode 100644 index 00000000..79b87cea --- /dev/null +++ b/frontend/src/components/CheckboxInput/index.tsx @@ -0,0 +1,63 @@ +import { Checkbox, FormControlLabel, FormHelperText } from "@mui/material"; +import React from "react"; +import { + useFormContext, + Controller, + type FieldValues, + type Path, +} from "react-hook-form"; +import { fetchFromObject } from "utils"; + +interface Props { + name: Path; + label?: string; + disabled?: boolean; +} + +function CheckboxInput({ + label, + name, + disabled = false, +}: Props) { + const { + control, + formState: { errors }, + } = useFormContext(); + + const error = fetchFromObject(errors, name); + + return ( + <> + {label ? ( + ( + + )} + /> + } + /> + ) : ( + ( + + )} + /> + )} + {error?.message} + + ); +} + +export default CheckboxInput; diff --git a/frontend/src/components/CodeEditorInput/index.tsx b/frontend/src/components/CodeEditorInput/index.tsx new file mode 100644 index 00000000..717026a9 --- /dev/null +++ b/frontend/src/components/CodeEditorInput/index.tsx @@ -0,0 +1,49 @@ +import CodeEditor from "@uiw/react-textarea-code-editor"; +import React from "react"; +import { + Controller, + type FieldValues, + type Path, + useFormContext, +} from "react-hook-form"; + +const CodeEditorItem = React.forwardRef(({ ...register }) => ( + +)); + +CodeEditorItem.displayName = "CodeEditorItem"; + +interface Props { + name: Path; +} + +function CodeEditorInput({ name }: Props) { + const { control } = useFormContext(); + + return ( + } + /> + ); +} + +export default CodeEditorInput; diff --git a/frontend/src/components/DatetimeInput/index.tsx b/frontend/src/components/DatetimeInput/index.tsx new file mode 100644 index 00000000..859b59cc --- /dev/null +++ b/frontend/src/components/DatetimeInput/index.tsx @@ -0,0 +1,124 @@ +import { + DatePicker, + DateTimePicker, + LocalizationProvider, + TimePicker, +} from "@mui/x-date-pickers"; +import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs"; +import { DemoContainer } from "@mui/x-date-pickers/internals/demo"; +import dayjs from "dayjs"; +import React from "react"; +import { + Controller, + type FieldValues, + type Path, + useFormContext, +} from "react-hook-form"; + +interface Props { + label: string; + name: Path; + type?: "time" | "date" | "date-time"; + defaultValue?: Date; +} + +function DatetimeInput({ + label, + name, + type = "date", + defaultValue = new Date(), +}: Props) { + const { control } = useFormContext(); + + switch (type) { + case "date-time": { + const defaultDateTime = dayjs( + defaultValue ?? new Date(), + "YYYY-MM-DD HH:mm", + ); + + return ( + ( + + + { + onChange(dayjs(e).toISOString() as any); + }} + {...rest} + /> + + + )} + /> + ); + } + case "time": { + const defaultTime = dayjs(defaultValue ?? new Date(), "HH:mm"); + + return ( + ( + + + { + onChange(dayjs(e).format("HH:mm") as any); + }} + {...rest} + /> + + + )} + /> + ); + } + case "date": + default: + // eslint-disable-next-line no-case-declarations + const defaultDate = dayjs(defaultValue ?? new Date(), "YYYY-MM-DD"); + + return ( + ( + + + { + onChange(dayjs(e).format("YYYY-MM-DD") as any); + }} + {...rest} + /> + + + )} + /> + ); + } +} + +export default DatetimeInput; diff --git a/frontend/src/components/Loading/index.tsx b/frontend/src/components/Loading/index.tsx new file mode 100644 index 00000000..4a53dc57 --- /dev/null +++ b/frontend/src/components/Loading/index.tsx @@ -0,0 +1,19 @@ +import Backdrop from "@mui/material/Backdrop"; +import CircularProgress from "@mui/material/CircularProgress"; +import React from "react"; + +const Loading: React.FC = () => { + return ( + theme.palette.primary.main, + zIndex: (theme) => theme.zIndex.drawer + 1, + }} + open={true} + > + + + ); +}; + +export default Loading; diff --git a/frontend/src/components/NewFeatureDialog/index.tsx b/frontend/src/components/NewFeatureDialog/index.tsx new file mode 100644 index 00000000..449507f7 --- /dev/null +++ b/frontend/src/components/NewFeatureDialog/index.tsx @@ -0,0 +1,43 @@ +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Grid, +} from "@mui/material"; +import React from "react"; + +interface Props { + isOpen: boolean; + confirmFn: () => void; +} + +export const NewFeatureDialog: React.FC = ({ isOpen, confirmFn }) => { + return ( +

+ New feature + + + This feature is not ready yet! We launch new versions every time, + check out our changelog for more information ! + + + + + + + + + + + ); +}; diff --git a/frontend/src/components/NoDataOverlay/index.tsx b/frontend/src/components/NoDataOverlay/index.tsx new file mode 100644 index 00000000..7f17f385 --- /dev/null +++ b/frontend/src/components/NoDataOverlay/index.tsx @@ -0,0 +1,75 @@ +import { Box } from "@mui/material"; +import { styled } from "@mui/material/styles"; +import React from "react"; + +const StyledGridOverlay = styled("div")(({ theme }) => ({ + display: "flex", + flexDirection: "column", + alignItems: "center", + justifyContent: "center", + height: "100%", + "& .ant-empty-img-1": { + fill: theme.palette.mode === "light" ? "#aeb8c2" : "#262626", + }, + "& .ant-empty-img-2": { + fill: theme.palette.mode === "light" ? "#f5f5f7" : "#595959", + }, + "& .ant-empty-img-3": { + fill: theme.palette.mode === "light" ? "#dce0e6" : "#434343", + }, + "& .ant-empty-img-4": { + fill: theme.palette.mode === "light" ? "#fff" : "#1c1c1c", + }, + "& .ant-empty-img-5": { + fillOpacity: theme.palette.mode === "light" ? "0.8" : "0.08", + fill: theme.palette.mode === "light" ? "#f5f5f5" : "#fff", + }, +})); + +export const NoDataOverlay: React.FC = () => { + return ( + + + + + + + + + + + + + + + + + No Data + + ); +}; diff --git a/frontend/src/components/NumberInput/index.tsx b/frontend/src/components/NumberInput/index.tsx new file mode 100644 index 00000000..933902a8 --- /dev/null +++ b/frontend/src/components/NumberInput/index.tsx @@ -0,0 +1,66 @@ +import { TextField, type TextFieldProps } from "@mui/material"; +import React, { useMemo } from "react"; +import { + type FieldValues, + type Path, + type RegisterOptions, + useFormContext, +} from "react-hook-form"; +import { fetchFromObject } from "utils"; + +type Props = Omit & { + label: string; + name: Path; + defaultValue?: number; + type: "float" | "int"; + registerOptions?: RegisterOptions; +}; + +function NumberInput({ + name, + label, + type = "int", + defaultValue = 0, + inputProps, + registerOptions, + ...rest +}: Props) { + const { + register, + formState: { errors }, + } = useFormContext(); + + const error = fetchFromObject(errors, name); + + const options = useMemo>(() => { + if (registerOptions) { + return { + ...registerOptions, + valueAsNumber: true, + } as unknown as RegisterOptions; + } + return { + valueAsNumber: true, + } as unknown as RegisterOptions; + }, [registerOptions]); + + return ( + + ); +} + +export default NumberInput; diff --git a/frontend/src/components/PrivateLayout/header/drawerMenu.style.ts b/frontend/src/components/PrivateLayout/header/drawerMenu.style.ts new file mode 100644 index 00000000..0645af34 --- /dev/null +++ b/frontend/src/components/PrivateLayout/header/drawerMenu.style.ts @@ -0,0 +1,102 @@ +import MuiAppBar, { + type AppBarProps as MuiAppBarProps, +} from "@mui/material/AppBar"; +import MuiDrawer from "@mui/material/Drawer"; +import { styled, type Theme, type CSSObject } from "@mui/material/styles"; + +const drawerWidth = 270; + +interface AppBarProps extends MuiAppBarProps { + open?: boolean; +} + +const openedMixin = (theme: Theme): CSSObject => ({ + width: drawerWidth, + transition: theme.transitions.create("width", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.enteringScreen, + }), + overflowX: "hidden", +}); + +const closedMixin = (theme: Theme): CSSObject => ({ + transition: theme.transitions.create("width", { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + overflowX: "hidden", + width: `calc(${theme.spacing(7)} + 1px)`, + [theme.breakpoints.up("sm")]: { + width: `calc(${theme.spacing(8)} + 1px)`, + }, +}); + +export const DrawerHeaderStyled = styled("div")(({ theme }) => ({ + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + padding: theme.spacing(0, 1), + // necessary for content to be below app bar + ...theme.mixins.toolbar, +})); + +export const DrawerStyled = styled(MuiDrawer, { + shouldForwardProp: (prop) => prop !== "open", +})(({ theme, open }) => ({ + width: drawerWidth, + flexShrink: 0, + whiteSpace: "nowrap", + boxSizing: "border-box", + ...(open && { + ...openedMixin(theme), + "& .MuiDrawer-paper": openedMixin(theme), + }), + ...(!open && { + ...closedMixin(theme), + "& .MuiDrawer-paper": closedMixin(theme), + }), +})); + +export const Drawer = styled(MuiDrawer, { + shouldForwardProp: (prop) => prop !== "open", +})(({ theme, open }) => ({ + width: drawerWidth, + flexShrink: 0, + whiteSpace: "nowrap", + boxSizing: "border-box", + ...(open && { + ...openedMixin(theme), + "& .MuiDrawer-paper": openedMixin(theme), + }), + ...(!open && { + ...closedMixin(theme), + "& .MuiDrawer-paper": closedMixin(theme), + }), +})); + +export const AppBar = styled(MuiAppBar, { + shouldForwardProp: (prop) => prop !== "open", +})(({ theme, open }) => ({ + zIndex: theme.zIndex.drawer + 1, + transition: theme.transitions.create(["width", "margin"], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.leavingScreen, + }), + ...(open && { + marginLeft: drawerWidth, + width: `calc(100% - ${drawerWidth}px)`, + transition: theme.transitions.create(["width", "margin"], { + easing: theme.transitions.easing.sharp, + duration: theme.transitions.duration.enteringScreen, + }), + }), +})); + +export const DrawerHeader = styled("div")(({ theme }) => ({ + display: "flex", + alignItems: "center", + justifyContent: "flex-end", + padding: theme.spacing(0, 1), + // necessary for content to be below app bar + ...theme.mixins.toolbar, +})); diff --git a/frontend/src/components/PrivateLayout/header/drawerMenu.tsx b/frontend/src/components/PrivateLayout/header/drawerMenu.tsx new file mode 100644 index 00000000..b9c6dfe1 --- /dev/null +++ b/frontend/src/components/PrivateLayout/header/drawerMenu.tsx @@ -0,0 +1,156 @@ +import { + AccountTree as AccountTreeIcon, + BlurCircular, + ChevronLeft as ChevronLeftIcon, + ChevronRight as ChevronRightIcon, + Logout as LogoutIcon, + Person as PersonIcon, + Toc, + Workspaces, +} from "@mui/icons-material"; +import MenuIcon from "@mui/icons-material/Menu"; +import { + Box, + Divider, + IconButton, + List, + Toolbar, + useTheme, +} from "@mui/material"; +import { useAuthentication } from "context/authentication"; +import { useWorkspaces } from "context/workspaces"; +import { type FC } from "react"; +import { useLocation, useNavigate } from "react-router-dom"; + +import { AppBar, Drawer, DrawerHeader } from "./drawerMenu.style"; +import { DrawerMenuItem } from "./drawerMenuItem"; + +interface IDrawerMenuProps { + isOpen: boolean; + handleClose: () => void; +} + +/** + * Drawer menu. + * @todo move AppBar into its own component (or to header.component) + */ +export const DrawerMenu: FC = ({ isOpen, handleClose }) => { + const theme = useTheme(); + const { logout } = useAuthentication(); + const navigate = useNavigate(); + const { pathname } = useLocation(); + const { workspace } = useWorkspaces(); + + return ( + + + + + {theme.direction === "rtl" ? : } + + logo + { + if (workspace) { + navigate("/workspace-settings"); + } + } /* go to selected workspace setting */ + } + > + + {workspace?.workspace_name + ? workspace?.workspace_name + : "No workspace selected"} + + + + + + + {theme.direction === "rtl" ? ( + + ) : ( + + )} + + + + + { + navigate("/workspaces"); + }} + icon={} + label={"Workspaces"} + isMenuOpen={isOpen} + /> + + + + { + if (workspace?.id) navigate("/workflows"); + }} + icon={} + label={"Workflows"} + isMenuOpen={isOpen} + disabled={!workspace?.id} + /> + + { + if (workspace?.id) navigate("/workflows-editor"); + }} + icon={} + label={"Workflow Editor"} + isMenuOpen={isOpen} + disabled={!workspace?.id} + /> + + + + { + navigate("/profile"); + }} + icon={} + label={"Profile"} + isMenuOpen={isOpen} + disabled + /> + { + logout(); + }} + icon={} + label={"Logout"} + isMenuOpen={isOpen} + /> + + + + ); +}; diff --git a/frontend/src/modules/layout/private-layout/header/drawer-menu-item.component.tsx b/frontend/src/components/PrivateLayout/header/drawerMenuItem.tsx similarity index 53% rename from frontend/src/modules/layout/private-layout/header/drawer-menu-item.component.tsx rename to frontend/src/components/PrivateLayout/header/drawerMenuItem.tsx index 17870889..f4e18b72 100644 --- a/frontend/src/modules/layout/private-layout/header/drawer-menu-item.component.tsx +++ b/frontend/src/components/PrivateLayout/header/drawerMenuItem.tsx @@ -2,19 +2,19 @@ import { ListItem, ListItemButton, ListItemIcon, - ListItemText -} from '@mui/material' -import Tooltip from '@mui/material/Tooltip' -import { FC, ReactNode } from 'react' + ListItemText, +} from "@mui/material"; +import Tooltip from "@mui/material/Tooltip"; +import { type FC, type ReactNode } from "react"; interface IDrawerMenuItemProps { - disabled?: boolean - isMenuOpen: boolean - selected?: boolean - icon: ReactNode - label: string - onClick: () => void - className?: string + disabled?: boolean; + isMenuOpen: boolean; + selected?: boolean; + icon: ReactNode; + label: string; + onClick: () => void; + className?: string; } export const DrawerMenuItem: FC = ({ @@ -23,39 +23,39 @@ export const DrawerMenuItem: FC = ({ selected, icon, label, - onClick + onClick, }) => { return ( - + {icon} - ) -} + ); +}; diff --git a/frontend/src/components/PrivateLayout/header/header.tsx b/frontend/src/components/PrivateLayout/header/header.tsx new file mode 100644 index 00000000..d96bdf06 --- /dev/null +++ b/frontend/src/components/PrivateLayout/header/header.tsx @@ -0,0 +1,22 @@ +import { Box } from "@mui/material"; +import { type FC, useRef, useState } from "react"; + +import { DrawerMenu } from "./drawerMenu"; + +export const Header: FC = () => { + const [menuOpen, setMenuOpen] = useState(false); + const barHeight = useRef(null); + + return ( + <> + + { + setMenuOpen(!menuOpen); + }} + /> + + + ); +}; diff --git a/frontend/src/components/PrivateLayout/index.tsx b/frontend/src/components/PrivateLayout/index.tsx new file mode 100644 index 00000000..6115dcc8 --- /dev/null +++ b/frontend/src/components/PrivateLayout/index.tsx @@ -0,0 +1,23 @@ +import { Box, Container } from "@mui/material"; +import { type FC, type ReactNode } from "react"; + +import { Header } from "./header/header"; + +interface IPrivateLayoutProps { + children: ReactNode; + sidePanel?: ReactNode; +} + +export const PrivateLayout: FC = ({ children }) => { + return ( + +
+ + + {children} + + + ); +}; + +export default PrivateLayout; diff --git a/frontend/src/components/PublicLayout/index.tsx b/frontend/src/components/PublicLayout/index.tsx new file mode 100644 index 00000000..af5dc129 --- /dev/null +++ b/frontend/src/components/PublicLayout/index.tsx @@ -0,0 +1,23 @@ +import { Box, Container, Card, CardContent } from "@mui/material"; +import { type FC, type ReactNode } from "react"; + +export const PublicLayout: FC<{ children: ReactNode }> = ({ children }) => { + return ( + + + + {children} + + + + ); +}; + +export default PublicLayout; diff --git a/frontend/src/components/SelectInput/index.tsx b/frontend/src/components/SelectInput/index.tsx new file mode 100644 index 00000000..781efdae --- /dev/null +++ b/frontend/src/components/SelectInput/index.tsx @@ -0,0 +1,101 @@ +import { + FormControl, + FormHelperText, + InputLabel, + MenuItem, + Select, + type SelectProps, +} from "@mui/material"; +import React from "react"; +import { + Controller, + type FieldValues, + type Path, + type RegisterOptions, + useFormContext, +} from "react-hook-form"; +import { fetchFromObject } from "utils"; + +type Props = + | (SelectProps & { + name: Path; + label: string; + options: string[] | Array<{ label: string; value: string }>; + + emptyValue: true; + defaultValue?: string; + registerOptions?: + | RegisterOptions> + | undefined; + }) + | (SelectProps & { + name: Path; + label: string; + options: string[] | Array<{ label: string; value: string }>; + + emptyValue?: boolean; + registerOptions?: RegisterOptions; + }); + +function SelectInput({ + options, + label, + name, + emptyValue, + ...rest +}: Props) { + const { + control, + formState: { errors }, + } = useFormContext(); + + const error = fetchFromObject(errors, name); + + return ( + + + {label} + + ( + + )} + /> + + {error?.message} + + ); +} + +export default SelectInput; diff --git a/frontend/src/components/TextInput/index.tsx b/frontend/src/components/TextInput/index.tsx new file mode 100644 index 00000000..02d96b0c --- /dev/null +++ b/frontend/src/components/TextInput/index.tsx @@ -0,0 +1,46 @@ +import { TextField, type TextFieldProps } from "@mui/material"; +import React from "react"; +import { + type FieldValues, + type Path, + type RegisterOptions, + useFormContext, +} from "react-hook-form"; +import { fetchFromObject } from "utils"; + +type Props = TextFieldProps & { + label: string; + name: Path; + defaultValue?: string; + registerOptions?: RegisterOptions; +}; + +function TextInput({ + name, + label, + defaultValue = "", + registerOptions, + ...rest +}: Props) { + const { + register, + formState: { errors }, + } = useFormContext(); + + const error = fetchFromObject(errors, name); + + return ( + + ); +} + +export default TextInput; diff --git a/frontend/src/components/WorkflowPanel/ConnectionLine/index.tsx b/frontend/src/components/WorkflowPanel/ConnectionLine/index.tsx new file mode 100644 index 00000000..a27caa35 --- /dev/null +++ b/frontend/src/components/WorkflowPanel/ConnectionLine/index.tsx @@ -0,0 +1,35 @@ +import React from "react"; +import { + getBezierPath, + type ConnectionLineComponentProps, + BaseEdge, +} from "reactflow"; + +export const CustomConnectionLine: React.FC = ({ + fromX, + fromY, + fromPosition, + toX, + toY, + toPosition, +}) => { + const [linePath] = getBezierPath({ + sourceX: fromX, + sourceY: fromY, + sourcePosition: fromPosition, + targetX: toX, + targetY: toY, + targetPosition: toPosition, + }); + + return ( + + + + ); +}; diff --git a/frontend/src/components/WorkflowPanel/DefaultEdge/index.tsx b/frontend/src/components/WorkflowPanel/DefaultEdge/index.tsx new file mode 100644 index 00000000..bab95a5a --- /dev/null +++ b/frontend/src/components/WorkflowPanel/DefaultEdge/index.tsx @@ -0,0 +1,34 @@ +import { BaseEdge, type EdgeProps, getBezierPath } from "reactflow"; + +const DefaultEdge: React.FC = ({ + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + markerEnd, +}: EdgeProps) => { + const [edgePath] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + }); + + return ( + + ); +}; + +export default DefaultEdge; diff --git a/frontend/src/components/WorkflowPanel/DefaultNode/index.tsx b/frontend/src/components/WorkflowPanel/DefaultNode/index.tsx new file mode 100644 index 00000000..2cf2d826 --- /dev/null +++ b/frontend/src/components/WorkflowPanel/DefaultNode/index.tsx @@ -0,0 +1,154 @@ +import { Paper, Typography } from "@mui/material"; +import theme from "providers/theme.config"; +import React, { type CSSProperties, memo, useMemo } from "react"; +import { Handle, Position } from "reactflow"; +import { getUuidSlice, useMouseProximity } from "utils"; + +import { type DefaultNodeProps } from "../types"; + +export const CustomNode = memo(({ id, data, selected }) => { + const [isNear, ElementRef] = useMouseProximity(150); + + const handleStyle = useMemo( + () => + isNear + ? { + border: 0, + borderRadius: "16px", + backgroundColor: theme.palette.info.main, + transition: "ease 100", + zIndex: 2, + width: "12px", + height: "12px", + } + : { + border: 0, + borderRadius: "16px", + backgroundColor: "transparent", + transition: "ease 100", + zIndex: 2, + }, + [isNear], + ); + + const extendedClassExt = useMemo<"input" | "default" | "output">(() => { + const dominoReactflowClassTypeMap = Object.freeze({ + source: "input", + default: "default", + sink: "output", + }); + if ( + !data?.style.nodeType || + !["default", "source", "sink"].includes(data?.style.nodeType) + ) { + return "default"; + } else { + return dominoReactflowClassTypeMap[data.style.nodeType]; + } + }, [data]); + + const nodeTypeRenderHandleMap = useMemo( + () => ({ + input: { + renderTargetHandle: false, + renderSourceHandle: true, + }, + output: { + renderTargetHandle: true, + renderSourceHandle: false, + }, + default: { + renderTargetHandle: true, + renderSourceHandle: true, + }, + }), + [], + ); + + const nodeStyle = useMemo(() => { + return { + ...data.style.nodeStyle, + display: "flex", + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + + position: "relative", + width: 150, + height: 70, + lineHeight: "60px", + border: selected ? "2px" : "", + borderStyle: selected ? "solid" : "", + borderColor: selected ? theme.palette.info.dark : "", + borderRadius: selected ? "3px" : "", + ...(data.validationError && { + backgroundColor: theme.palette.error.light, + color: theme.palette.error.contrastText, + }), + }; + }, [data, selected]); + + const { sourcePosition, targetPosition } = useMemo( + () => ({ + ...(data.orientation === "horizontal" + ? { + targetPosition: Position.Left, + sourcePosition: Position.Right, + } + : { + targetPosition: Position.Top, + sourcePosition: Position.Bottom, + }), + }), + [data], + ); + + return ( + <> + {nodeTypeRenderHandleMap[extendedClassExt].renderSourceHandle && ( + + )} + {nodeTypeRenderHandleMap[extendedClassExt].renderTargetHandle && ( + + )} + +
+ + {data?.style?.label ? data?.style?.label : data?.name} + + + {getUuidSlice(id)} + +
+
+ + ); +}); + +CustomNode.displayName = "CustomNode"; diff --git a/frontend/src/components/WorkflowPanel/RunNode/index.tsx b/frontend/src/components/WorkflowPanel/RunNode/index.tsx new file mode 100644 index 00000000..aece5146 --- /dev/null +++ b/frontend/src/components/WorkflowPanel/RunNode/index.tsx @@ -0,0 +1,170 @@ +/* eslint-disable no-prototype-builtins */ +import { Paper, Typography } from "@mui/material"; +import { taskState } from "features/workflows/types"; +import theme from "providers/theme.config"; +import React, { type CSSProperties, memo, useCallback, useMemo } from "react"; +import { Position, Handle } from "reactflow"; +import { getUuidSlice } from "utils"; + +import { type RunNodeProps } from "../types"; + +const RunNode = memo(({ id, data, selected }) => { + const extendedClassExt = useMemo(() => { + const dominoReactflowClassTypeMap: any = { + source: "input", + default: "default", + sink: "output", + }; + if ( + data?.style.nodeType === undefined || + !["default", "source", "sink"].includes(data?.style.nodeType) + ) { + return "default"; + } else { + return dominoReactflowClassTypeMap[data?.style.nodeType]; + } + }, [data]); + + const nodeTypeRenderHandleMap = useMemo( + () => + ({ + input: { + renderTargetHandle: false, + renderSourceHandle: true, + }, + output: { + renderTargetHandle: true, + renderSourceHandle: false, + }, + default: { + renderTargetHandle: true, + renderSourceHandle: true, + }, + }) as any, + [], + ); + + const handleStyle = useMemo( + () => ({ + border: 0, + borderRadius: "16px", + backgroundColor: theme.palette.grey[400], + transition: "ease 100", + zIndex: 2, + }), + [], + ); + + const getTaskStatusColor = useCallback((state: taskState) => { + const colors = { + backgroundColor: theme.palette.background.default, + color: theme.palette.getContrastText(theme.palette.background.default), + }; + + switch (state) { + case taskState.success: + colors.backgroundColor = theme.palette.success.light; + colors.color = theme.palette.getContrastText( + theme.palette.success.light, + ); + break; + case taskState.running: + colors.backgroundColor = theme.palette.info.light; + colors.color = theme.palette.getContrastText(theme.palette.info.light); + break; + + case taskState.failed: + colors.backgroundColor = theme.palette.error.light; + colors.color = theme.palette.getContrastText(theme.palette.error.light); + break; + } + + return colors; + }, []); + + const nodeStyle = useMemo(() => { + return { + ...data.style.nodeStyle, + display: "flex", + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + + position: "relative", + width: 150, + height: 70, + lineHeight: "60px", + border: selected ? "2px" : "", + borderStyle: selected ? "solid" : "", + borderColor: selected ? theme.palette.info.dark : "", + borderRadius: selected ? "3px" : "", + ...(data.state && getTaskStatusColor(data.state)), + }; + }, [data, selected]); + + const { sourcePosition, targetPosition } = useMemo( + () => ({ + ...(data.orientation === "horizontal" + ? { + targetPosition: Position.Left, + sourcePosition: Position.Right, + } + : { + targetPosition: Position.Top, + sourcePosition: Position.Bottom, + }), + }), + [data], + ); + + return ( + <> + {nodeTypeRenderHandleMap[extendedClassExt].renderSourceHandle && ( + + )} + {nodeTypeRenderHandleMap[extendedClassExt].renderTargetHandle && ( + + )} + +
+ + {data?.style?.label ? data?.style?.label : data?.name} + + + {getUuidSlice(id)} + +
+
+ + ); +}); + +RunNode.displayName = "RunNode"; + +export default RunNode; diff --git a/frontend/src/components/WorkflowPanel/WorkflowPanel.tsx b/frontend/src/components/WorkflowPanel/WorkflowPanel.tsx new file mode 100644 index 00000000..4db4b8aa --- /dev/null +++ b/frontend/src/components/WorkflowPanel/WorkflowPanel.tsx @@ -0,0 +1,342 @@ +import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; +import Elk from "elkjs"; +import theme from "providers/theme.config"; +import React, { + useCallback, + type DragEvent, + useState, + useRef, + forwardRef, + useImperativeHandle, + type ForwardedRef, + useMemo, +} from "react"; +import ReactFlow, { + type Node, + addEdge, + Background, + Controls, + ReactFlowProvider, + type Connection, + type Edge, + useNodesState, + useEdgesState, + type NodeMouseHandler, + type OnNodesDelete, + type OnEdgesDelete, + type ReactFlowInstance, + type XYPosition, + ControlButton, + MarkerType, + type EdgeTypes, + type NodeTypes, +} from "reactflow"; + +import { CustomConnectionLine } from "./ConnectionLine"; +import DefaultEdge from "./DefaultEdge"; +import { CustomNode } from "./DefaultNode"; +import RunNodeComponent from "./RunNode"; + +import "reactflow/dist/style.css"; + +// Load CustomNode +const DEFAULT_NODE_TYPES: NodeTypes = { + CustomNode, +}; + +const RUN_NODE_TYPES: NodeTypes = { + CustomNode: RunNodeComponent, +}; + +const EDGE_TYPES: EdgeTypes = { + default: DefaultEdge, +}; +type OnInit = + | ((instance: ReactFlowInstance) => { + nodes: Node[]; + edges: Edge[]; + }) + | (( + instance: ReactFlowInstance, + ) => Promise<{ nodes: Node[]; edges: Edge[] }>); + +type OnDrop = + | ((event: DragEvent, position: XYPosition) => Node) + | ((event: DragEvent, position: XYPosition) => Promise); + +type Props = + | { + editable: true; + onNodesDelete: OnNodesDelete; + onEdgesDelete: OnEdgesDelete; + onDrop: OnDrop; + onInit?: OnInit; + + onNodeDoubleClick?: NodeMouseHandler; + } + | { + editable: false; + onInit?: OnInit; + onNodeDoubleClick?: NodeMouseHandler; + }; +export interface WorkflowPanelRef { + nodes: Node[]; + edges: Edge[]; + setNodes: React.Dispatch>; + setEdges: React.Dispatch>; +} +const WorkflowPanel = forwardRef( + (props: Props, ref: ForwardedRef) => { + const reactFlowWrapper = useRef(null); + const [instance, setInstance] = useState(null); + const [rawNodes, setNodes, onNodesChange] = useNodesState([]); + const [rawEdges, setEdges, onEdgesChange] = useEdgesState([]); + + const onInit = useCallback( + async (instance: ReactFlowInstance) => { + setInstance(instance); + if (props.onInit) { + const result = props.onInit(instance); + if (result instanceof Promise) { + result + .then(({ nodes, edges }) => { + setNodes(nodes); + setEdges(edges); + }) + .catch((error) => { + console.error("Error from Promise-returning function:", error); + }); + } else { + const { nodes, edges } = result; + setNodes(nodes); + setEdges(edges); + } + } + window.requestAnimationFrame(() => instance.fitView()); + }, + [props], + ); + + const onNodesDelete = useCallback( + props.editable ? props.onNodesDelete : () => {}, + [props], + ); + + const onEdgesDelete = useCallback( + props.editable ? props.onEdgesDelete : () => {}, + [props], + ); + + const onNodeDoubleClick = useCallback( + (e, n) => { + if (props.onNodeDoubleClick) { + props.onNodeDoubleClick(e, n); + } + if (!props.editable && instance) { + const nodeCenter = (n.width ?? 0) / 2; + instance.setCenter(n.position.x + nodeCenter, n.position.y); + } + }, + [instance, props], + ); + + const onDragOver = (event: DragEvent) => { + event.preventDefault(); + event.dataTransfer.dropEffect = "move"; + }; + + const onDrop = useCallback( + async (event: DragEvent) => { + event.preventDefault(); + if (reactFlowWrapper?.current === null) { + return; + } + const reactFlowBounds = + reactFlowWrapper.current.getBoundingClientRect(); + // @ts-expect-error: Unreachable code error + const position = instance.project({ + x: event.clientX - reactFlowBounds.left, + y: event.clientY - reactFlowBounds.top, + }); + + if (props.editable) { + const result = props.onDrop(event, position); + if (result instanceof Promise) { + result + .then((node) => { + setNodes((ns: Node[]) => ns.concat(node)); + }) + .catch((error) => { + console.error("Error from Promise-returning function:", error); + }); + } else { + const node = result; + setNodes((ns: Node[]) => ns.concat(node)); + } + } + }, + [instance, setNodes, props], + ); + + const onConnect = useCallback((connection: Connection) => { + setEdges((prevEdges: Edge[]) => addEdge(connection, prevEdges)); + }, []); + + const autoLayout = useCallback(async () => { + const elkGraph = { + id: "root", + children: rawNodes.map((node) => ({ + id: node.id, + width: 230, + height: 140, + })), + edges: rawEdges.map((edge) => ({ + id: edge.id, + sources: [edge.source], + targets: [edge.target], + })), + }; + + const elk = new Elk(); + + try { + const elkLayout = await elk.layout(elkGraph); + + if (elkLayout?.children && elkLayout.edges) { + const updatedNodes = elkLayout.children.map((elkNode) => { + const node = rawNodes.find((node) => node.id === elkNode.id); + if ( + node && + elkNode.x !== undefined && + elkNode.width !== undefined && + elkNode.y !== undefined && + elkNode.height !== undefined + ) { + return { + ...node, + position: { + x: elkNode.x - elkNode.width / 2, + y: elkNode.y - elkNode.height / 2, + }, + }; + } + return node; + }); + + const updatedEdges = elkLayout.edges.map((elkEdge) => ({ + ...rawEdges.find((edge) => edge.id === elkEdge.id), + })); + + setNodes(updatedNodes as Node[]); + setEdges(updatedEdges as Edge[]); + window.requestAnimationFrame(() => instance?.fitView()); + } + } catch (error) { + console.error("Error during layout:", error); + } + }, [rawNodes, rawEdges]); + + const { nodes, edges } = useMemo(() => { + const nodes = [...rawNodes].map((node: Node) => ({ + ...node, + data: { + ...node.data, + }, + })); + const edges = [...rawEdges].map((edge: Edge) => ({ + ...edge, + markerEnd: { + type: MarkerType.ArrowClosed, + width: 20, + height: 20, + }, + })); + + return { + nodes, + edges, + }; + }, [rawNodes, rawEdges]); + + useImperativeHandle( + ref, + () => { + return { + edges: rawEdges, + nodes: rawNodes, + setEdges, + setNodes, + }; + }, + [rawEdges, rawNodes, setEdges, setNodes], + ); + + return ( + +
+ {props.editable ? ( + + + + + + + + + ) : ( + + + + + )} +
+
+ ); + }, +); + +WorkflowPanel.displayName = "WorkflowPanel"; + +export { WorkflowPanel }; diff --git a/frontend/src/components/WorkflowPanel/index.ts b/frontend/src/components/WorkflowPanel/index.ts new file mode 100644 index 00000000..26db2a52 --- /dev/null +++ b/frontend/src/components/WorkflowPanel/index.ts @@ -0,0 +1,2 @@ +export * from "./WorkflowPanel"; +export * from "./types"; diff --git a/frontend/src/components/WorkflowPanel/types.ts b/frontend/src/components/WorkflowPanel/types.ts new file mode 100644 index 00000000..8eeacd2c --- /dev/null +++ b/frontend/src/components/WorkflowPanel/types.ts @@ -0,0 +1,40 @@ +import { type taskState } from "features/workflows/types"; +import { type CSSProperties } from "react"; +import { type Node, type NodeProps } from "reactflow"; + +interface IStyleData { + nodeType: "default" | "source" | "sink"; + nodeStyle: CSSProperties; + useIcon: boolean; + iconId: string; + iconClassName: string; + iconStyle: CSSProperties; + label: string; + module: string; +} + +interface DefaultNodeData { + name: string; + style: IStyleData; + validationError: boolean; + orientation: "vertical" | "horizontal"; +} + +interface RunNodeData { + taskId: string; + name: string; + style: IStyleData; + state: taskState; + orientation: "vertical" | "horizontal"; +} + +export type DefaultNode = Node; +export type RunNode = Node; + +export interface DefaultNodeProps extends NodeProps { + data: DefaultNodeData; +} + +export interface RunNodeProps extends NodeProps { + data: RunNodeData; +} diff --git a/frontend/src/config/environment.config.ts b/frontend/src/config/environment.config.ts new file mode 100644 index 00000000..39bad99e --- /dev/null +++ b/frontend/src/config/environment.config.ts @@ -0,0 +1,20 @@ +/** + * @todo add other envs when available + */ +export type INodeEnv = "development"; +export type IApiEnv = "local" | "dev" | "prod"; + +export interface IEnvironment { + NODE_ENV: INodeEnv; + API_ENV: IApiEnv; + USE_MOCK: boolean; +} + +/** + * Stores all environment variables for easier access + */ +export const environment: IEnvironment = { + NODE_ENV: import.meta.env.NODE_ENV as INodeEnv, + API_ENV: import.meta.env.API_ENV as IApiEnv, + USE_MOCK: !!import.meta.env.VITE_USE_MOCK, +}; diff --git a/frontend/src/constants/index.ts b/frontend/src/constants/index.ts deleted file mode 100644 index be40b75c..00000000 --- a/frontend/src/constants/index.ts +++ /dev/null @@ -1,32 +0,0 @@ -export const workflowFormName: string = 'workflowForm' - -export const taskStatesColorMap = { - 'success': '#02b120', - 'failed': '#ff0000', - 'upstream_failed': '#ff6600', - 'running': '#00bfff', - 'skipped': '#ffcc00', - 'up_for_retry': '#ffcc00', - 'up_for_reschedule': '#7b00b4', - 'queued': '#aaaaaa', - 'scheduled': '#00ffaa', - 'none': '#ffffff', - 'deferred': '#00ffaa', - 'removed': '#000000', - 'restarting': '#8ee7fd', - 'default': '#ffffff' -} - -export const storageOptions = [ - { - "name": "None", - "fields": [] - }, - { - "name": "AWS s3", - "fields": [ - "AWS Access Key ID", - - ] - } -] \ No newline at end of file diff --git a/frontend/src/context/authentication/api/index.ts b/frontend/src/context/authentication/api/index.ts new file mode 100644 index 00000000..38581eea --- /dev/null +++ b/frontend/src/context/authentication/api/index.ts @@ -0,0 +1,2 @@ +export * from "./postAuthLogin"; +export * from "./postAuthRegister"; diff --git a/frontend/src/context/authentication/api/postAuthLogin.ts b/frontend/src/context/authentication/api/postAuthLogin.ts new file mode 100644 index 00000000..003a0df9 --- /dev/null +++ b/frontend/src/context/authentication/api/postAuthLogin.ts @@ -0,0 +1,32 @@ +import { type AxiosResponse } from "axios"; +import { dominoApiClient } from "services/clients/domino.client"; + +interface IPostAuthLoginParams { + email: string; + password: string; +} + +interface IPostAuthLoginResponseInterface { + user_id: string; + group_ids: number[]; + access_token: string; +} + +/** + * Authenticate the user using POST /auth/login + * @param params `{ email: string, password: string }` + * @returns access token + */ +export const postAuthLogin: ( + params: IPostAuthLoginParams, +) => Promise> = async ( + params, +) => { + return await dominoApiClient.post("/auth/login", params); +}; + +export const postAuthLoginMockResponse: IPostAuthLoginResponseInterface = { + user_id: "some_id", + group_ids: [0], + access_token: "MOCK ACCESS TOKEN", +}; diff --git a/frontend/src/context/authentication/api/postAuthRegister.ts b/frontend/src/context/authentication/api/postAuthRegister.ts new file mode 100644 index 00000000..1f021ee8 --- /dev/null +++ b/frontend/src/context/authentication/api/postAuthRegister.ts @@ -0,0 +1,33 @@ +import { type AxiosResponse } from "axios"; +import { dominoApiClient } from "services/clients/domino.client"; + +interface IPostAuthRegisterParams { + email: string; + password: string; +} + +interface IPostAuthRegisterResponseInterface { + id: string; + email: string; + groups: Array<{ group_id: number; group_name: string }>; +} + +/** + * Authenticate the user using POST /auth/register + * @param params `{ email: string, password: string }` + * @returns access token + */ +export const postAuthRegister: ( + params: IPostAuthRegisterParams, +) => Promise> = async ( + params, +) => { + return await dominoApiClient.post("/auth/register", params); +}; + +export const postAuthRegisterMockResponse: IPostAuthRegisterResponseInterface = + { + id: "some_id", + email: "some@email.com", + groups: [{ group_id: 0, group_name: "some group" }], + }; diff --git a/frontend/src/context/authentication/authentication.context.tsx b/frontend/src/context/authentication/authentication.context.tsx index 3ee2b4c4..cb119a91 100644 --- a/frontend/src/context/authentication/authentication.context.tsx +++ b/frontend/src/context/authentication/authentication.context.tsx @@ -1,119 +1,120 @@ +import { AxiosError } from "axios"; +import localforage from "localforage"; import React, { - ReactNode, + type ReactNode, useCallback, useEffect, useMemo, useRef, - useState -} from 'react' -import { useNavigate } from 'react-router-dom' -import { toast } from 'react-toastify' -import localforage from 'localforage' + useState, +} from "react"; +import { useNavigate } from "react-router-dom"; +import { toast } from "react-toastify"; +import { createCustomContext } from "utils"; -import { createCustomContext } from 'utils' +import { postAuthLogin, postAuthRegister } from "./api"; import { - postAuthLogin, - postAuthRegister -} from 'services/requests/authentication' - -import { - IAuthenticationContext, - IAuthenticationStore -} from './authentication.interface' -import { DOMINO_LOGOUT } from './authentication.logout' - + type IAuthenticationContext, + type IAuthenticationStore, +} from "./authentication.interface"; +import { DOMINO_LOGOUT } from "./authentication.logout"; export const [AuthenticationContext, useAuthentication] = - createCustomContext('Authentication Context') + createCustomContext("Authentication Context"); /** * Authentication provider. * @todo refactor local storage implementation with Local Forage */ export const AuthenticationProvider: React.FC<{ children: ReactNode }> = ({ - children + children, }) => { - const navigate = useNavigate() - const [authLoading, setAuthLoading] = useState(false) + const navigate = useNavigate(); + const [authLoading, setAuthLoading] = useState(false); const [store, setStore] = useState({ - token: localStorage.getItem('auth_token'), - userId: localStorage.getItem('userId') - }) + token: localStorage.getItem("auth_token"), + userId: localStorage.getItem("userId"), + }); - const isLogged = useRef(!!store.token) + const isLogged = useRef(!!store.token); const login = useCallback( - (token: string, userId: string, redirect = '') => { - isLogged.current = true + (token: string, userId: string, redirect = "") => { + isLogged.current = true; setStore((store) => ({ ...store, token, - userId - })) - localStorage.setItem('auth_token', token) - localStorage.setItem('userId', userId as string) - navigate(redirect) + userId, + })); + localStorage.setItem("auth_token", token); + localStorage.setItem("userId", userId); + navigate(redirect); }, - [navigate] - ) + [navigate], + ); const logout = useCallback(() => { - localStorage.clear() - localforage.clear() - isLogged.current = false + localStorage.clear(); + void localforage.clear(); + isLogged.current = false; setStore((store) => ({ ...store, - token: null - })) - navigate('/sign-in') - }, [navigate]) + token: null, + })); + navigate("/sign-in"); + }, [navigate]); /** * @todo improve error handling */ const authenticate = useCallback( async (email: string, password: string) => { - setAuthLoading(true) + setAuthLoading(true); postAuthLogin({ email, password }) .then((res) => { if (res.status === 200) { - login(res.data.access_token, res.data.user_id as string) + login(res.data.access_token, res.data.user_id); } }) - .catch(() => { - toast.error(`Error while authenticating`) + .catch((e) => { + if (e instanceof AxiosError) { + toast.error( + e.response?.data?.detail ?? + "Error on login, please review your inputs and try again", + ); + } }) .finally(() => { - setAuthLoading(false) - }) + setAuthLoading(false); + }); }, - [login] - ) + [login], + ); const register = useCallback( async (email: string, password: string) => { - setAuthLoading(true) + setAuthLoading(true); postAuthRegister({ email, password }) .then((res) => { if (res.status === 201) { - toast.success('E-mail and password registered successfully!') - authenticate(email, password) + toast.success("E-mail and password registered successfully!"); + void authenticate(email, password); } }) .catch((err) => { - console.log(err?.response?.status) + console.log(err?.response?.status); if (err?.response?.status === 409) { - toast.warning(`This e-mail is already registered`) + toast.warning(`This e-mail is already registered`); } else { - toast.error(err?.response?.data?.detail ?? `Error while register`) + toast.error(err?.response?.data?.detail ?? `Error while register`); } }) .finally(() => { - setAuthLoading(false) - }) + setAuthLoading(false); + }); }, - [authenticate] - ) + [authenticate], + ); const value = useMemo((): IAuthenticationContext => { return { @@ -122,23 +123,27 @@ export const AuthenticationProvider: React.FC<{ children: ReactNode }> = ({ authLoading, logout, authenticate, - register - } - }, [store, logout, authenticate, register, authLoading]) + register, + }; + }, [store, logout, authenticate, register, authLoading]); /** * Listen to "logout" event and handles it (ie. unauthorized request) */ useEffect(() => { - window.addEventListener(DOMINO_LOGOUT, () => logout()) + window.addEventListener(DOMINO_LOGOUT, () => { + logout(); + }); return () => { - window.removeEventListener(DOMINO_LOGOUT, () => logout()) - } - }, [logout]) + window.removeEventListener(DOMINO_LOGOUT, () => { + logout(); + }); + }; + }, [logout]); return ( {children} - ) -} + ); +}; diff --git a/frontend/src/context/authentication/authentication.interface.ts b/frontend/src/context/authentication/authentication.interface.ts index 1a96c003..5d8fd984 100644 --- a/frontend/src/context/authentication/authentication.interface.ts +++ b/frontend/src/context/authentication/authentication.interface.ts @@ -1,13 +1,13 @@ export interface IAuthenticationStore { - token: string | null - userId: string | null + token: string | null; + userId: string | null; } export interface IAuthenticationContext { - store: IAuthenticationStore - isLogged: boolean - authLoading: boolean - logout: () => void - authenticate: (email: string, password: string) => void - register: (email: string, password: string) => void + store: IAuthenticationStore; + isLogged: boolean; + authLoading: boolean; + logout: () => void; + authenticate: (email: string, password: string) => Promise; + register: (email: string, password: string) => Promise; } diff --git a/frontend/src/context/authentication/authentication.logout.ts b/frontend/src/context/authentication/authentication.logout.ts index 0ce4ac49..2e49ba74 100644 --- a/frontend/src/context/authentication/authentication.logout.ts +++ b/frontend/src/context/authentication/authentication.logout.ts @@ -1,13 +1,13 @@ -export const DOMINO_LOGOUT = 'DOMINO_LOGOUT' +export const DOMINO_LOGOUT = "DOMINO_LOGOUT"; const event = new CustomEvent(DOMINO_LOGOUT, { bubbles: true, cancelable: true, detail: { - message: 'Logout' - } -}) + message: "Logout", + }, +}); export const dispatchLogout = () => { - window.dispatchEvent(event) -} + window.dispatchEvent(event); +}; diff --git a/frontend/src/context/authentication/index.ts b/frontend/src/context/authentication/index.ts index 01826bf9..1f2b97be 100644 --- a/frontend/src/context/authentication/index.ts +++ b/frontend/src/context/authentication/index.ts @@ -1,2 +1,2 @@ -export * from './authentication.context' -export * from './authentication.logout' +export * from "./authentication.context"; +export * from "./authentication.logout"; diff --git a/frontend/src/context/workflows/workflows-editor.context.tsx b/frontend/src/context/workflows/workflows-editor.context.tsx deleted file mode 100644 index 670b7ecd..00000000 --- a/frontend/src/context/workflows/workflows-editor.context.tsx +++ /dev/null @@ -1,533 +0,0 @@ -import { FC, useCallback, useEffect, useMemo, useState } from 'react' -import * as localForage from 'localforage' -import { toast } from 'react-toastify' -import { Edge } from 'reactflow' - -import { workflowFormName } from '../../constants'; -import { - IRepositoryOperators, - IOperatorForageSchema, - IGetRepoOperatorsResponseInterface, - IOperatorRepository, - useAuthenticatedGetOperatorRepositories, - IOperator -} from 'services/requests/piece' -import { - IWorkflowElement, - IPostWorkflowParams, - useAuthenticatedPostWorkflow, - IPostWorkflowResponseInterface -} from 'services/requests/workflow' -import { useFetchAuthenticatedGetRepoIdOperators } from 'services/requests/piece/get-piece-repository-pieces.request' -import { useWorkspaces } from 'context/workspaces/workspaces.context'; -import { createCustomContext } from 'utils' - - -// Config localforage -localForage.config({ - name: 'Domino', - storeName: 'domino_data', // Should be alphanumeric, with underscores. - description: 'Domino database' -}) - -interface IWorkflowsEditorContext { - repositories: IOperatorRepository[] - repositoriesError: boolean - repositoriesLoading: boolean - repositoryOperators: IRepositoryOperators - - edges: Edge[] - setEdges: any // todo add type - nodes: IWorkflowElement[] - setNodes: any // todo add type - - fetchForageWorkflowEdges: () => Promise - fetchForageWorkflowNodes: () => Promise - handleCreateWorkflow: (params: IPostWorkflowParams) => Promise - - search: string - handleSearch: (word: string) => void - fetchForagePieceById: (id: number) => Promise - fetchForageDataById: (id: string) => Promise // TODO add type - setFormsForageData: (id: string, data: any) => Promise - removeFormsForageDataById: (id: string) => Promise - removeFormsForageDataNotInIds: (ids: string[]) => Promise - clearForageData: () => Promise - nodeDirection: "horizontal" | "vertical" - toggleNodeDirection: () => void - workflowsEditorBodyFromFlowchart: () => any // TODO add type - - fetchRepoById: (params: { - id: string - }) => Promise - - // Upstream Map - getForageUpstreamMap: () => Promise // TODO add type - setForageUpstreamMap: (data: any) => Promise // TODO add type - clearForageUpstreamMap: () => Promise - - setForageCheckboxStates: (checkboxStatesMap: any) => Promise // TODO add type - getForageCheckboxStates: () => Promise // TODO add type - - setNameKeyUpstreamArgsMap: (nameKeyUpstreamArgsMap: any) => Promise // TODO add type - getNameKeyUpstreamArgsMap: () => Promise // TODO add type - clearNameKeyUpstreamArgsMap: () => Promise -} - -export const [WorkflowsEditorContext, useWorkflowsEditor] = - createCustomContext('WorkflowsEditor Context') - -interface IWorkflowsEditorProviderProps { - children?: React.ReactNode; -} -/** - * WorkflowsEditor provider. - * @TODO: refactor local storage implementation with Local Forage -*/ -export const WorkflowsEditorProvider: FC = ({ children }) => { - const { workspace } = useWorkspaces() - const [search, setSearch] = useState('') - const [nodeDirection, setNodeDirection] = useState<'horizontal' | 'vertical'>('horizontal') - const [repositoryOperators, setRepositoryOperatos] = useState({}) - const [nodes, setNodes] = useState([]) - const [edges, setEdges] = useState([]) - const [loadingNodes, setLoadingNodes] = useState(true) - const [loadingEdges, setLoadingEdges] = useState(true) - - - // Requests hooks - const { - data, - error: repositoriesError, - isValidating: repositoriesLoading - // mutate: repositoriesRefresh - } = useAuthenticatedGetOperatorRepositories({}) - - const fetchRepoById = useFetchAuthenticatedGetRepoIdOperators() - const postWorkflow = useAuthenticatedPostWorkflow() - - // Error handlers - useEffect(() => { - if (!!repositoriesError) { - toast.error('Error loading repositories, try again later') - } - }, [repositoriesError]) - - - // Memoized values - const repositories: IOperatorRepository[] = useMemo( - () => data?.data.filter((repo) => repo.name.includes(search)) ?? [], - [data, search] - ) - - // Requests handlers - const handleCreateWorkflow = useCallback(async (payload: IPostWorkflowParams) => { - return postWorkflow({ ...payload, workspace_id: workspace?.id ?? '' }) - }, [postWorkflow, workspace]) - - - - /** Operators */ - useEffect(() => { - const updateRepositoriesOperators = async () => { - var repositoyOperatorsAux: IRepositoryOperators = {} - var forageOperators: IOperatorForageSchema = {} - for (const repo of repositories) { - fetchRepoById({ id: repo.id }) - .then((pieces: any) => { - repositoyOperatorsAux[repo.id] = [] - for (const op of pieces) { - repositoyOperatorsAux[repo.id].push(op) - forageOperators[op.id] = op - } - setRepositoryOperatos(repositoyOperatorsAux) - localForage.setItem("pieces", forageOperators) - }) - // Set piece item to storage -> {piece_id: Operator} - } - } - updateRepositoriesOperators() - }, [repositories, fetchRepoById]) - - // Forage handlers - const setForageCheckboxStates = useCallback(async (checkboxStatesMap: any) => { - await localForage.setItem('checkboxStates', checkboxStatesMap) - }, []) - - const getForageCheckboxStates = useCallback(async () => { - const checkboxStates = await localForage.getItem('checkboxStates') - if (!checkboxStates) { - return {} - } - return checkboxStates - }, []) - - const clearForageCheckboxStates = useCallback(async () => { - await localForage.setItem('checkboxStates', {}) - }, []) - - // Mapping state to map upstream dropdown names to upstream real keys - const setNameKeyUpstreamArgsMap = useCallback(async (nameKeyUpstreamArgsMap: any) => { - await localForage.setItem('nameKeyUpstreamArgsMap', nameKeyUpstreamArgsMap) - }, []) - - const getNameKeyUpstreamArgsMap = useCallback(async () => { - const nameKeyUpstreamArgsMap = await localForage.getItem('nameKeyUpstreamArgsMap') - if (!nameKeyUpstreamArgsMap) { - return {} - } - return nameKeyUpstreamArgsMap - }, []) - - const clearNameKeyUpstreamArgsMap = useCallback(async () => { - await localForage.setItem('nameKeyUpstreamArgsMap', {}) - }, []) - - - - const fetchForagePieceById = useCallback(async (id: number) => { - const pieces = await localForage.getItem("pieces") - if (pieces !== null) { - return pieces[id] - } - }, []) - - - // UpstreamMap forage - const getForageUpstreamMap = useCallback(async () => { - const currentUpstreamMap = await localForage.getItem("upstreamMap") - if (!currentUpstreamMap) { - return {} - } - return currentUpstreamMap - }, []) - - const setForageUpstreamMap = useCallback(async (upstreamMap: any) => { - await localForage.setItem('upstreamMap', upstreamMap) - }, []) - - const clearForageUpstreamMap = useCallback(async () => { - await localForage.setItem('upstreamMap', {}) - }, []) - - // Forage forms data - const fetchForageDataById = useCallback(async (id: string) => { - const data = await localForage.getItem('formsData') - if (data === null) { - return {} - } - return data[id] - }, []) - - const setFormsForageData = useCallback(async (id: string, data: any) => { - var currentData = await localForage.getItem('formsData') - if (!currentData) { - currentData = {} - } - currentData[id] = data - await localForage.setItem('formsData', currentData) - }, []) - - const fetchFormsForageData = useCallback(async () => { - const data = await localForage.getItem('formsData') - if (data === null) { - return {} - } - return data - }, []) - - const removeFormsForageDataById = useCallback(async (id: string) => { - var currentData = await localForage.getItem('formsData') - if (!currentData) { - return - } - delete currentData[id] - await localForage.setItem('formsData', currentData) - }, []) - - const removeFormsForageDataNotInIds = useCallback(async (ids: string[]) => { - // Remove from forage "data" key the data that have keys different from the defined ids list - var currentData = await localForage.getItem('formsData') - if (!currentData) { - return - } - Object.entries(currentData).forEach(([nodeId, formData], index) => { - if (!ids.includes(nodeId) && nodeId !== workflowFormName) { - delete currentData[nodeId] - } - }); - localForage.setItem('formsData', currentData); - }, []) - - const clearForageData = useCallback(async () => { - await localForage.setItem('formsData', {}) - await clearForageUpstreamMap() - await clearForageCheckboxStates() - await clearNameKeyUpstreamArgsMap() - }, [clearForageUpstreamMap, clearForageCheckboxStates, clearNameKeyUpstreamArgsMap]) - - const setForageWorkflowNodes = useCallback(async (nodes: IWorkflowElement[]) => { - await localForage.setItem('workflowNodes', nodes) - }, []) - - const setForageWorkflowEdges = useCallback(async (edges: Edge[]) => { - await localForage.setItem('workflowEdges', edges) - }, []) - - const fetchForageWorkflowNodes = useCallback(async () => { - var workflowNodes = await localForage.getItem("workflowNodes") - if ((!workflowNodes) || (workflowNodes.length === 0)) { - workflowNodes = [] - } - return workflowNodes - }, []) - - const fetchForageWorkflowEdges = useCallback(async () => { - var workflowEdges = await localForage.getItem("workflowEdges") - if ((!workflowEdges) || (workflowEdges.length === 0)) { - workflowEdges = [] - } - return workflowEdges - }, []) - - useEffect(() => { - (async () => { - const forageNodes = await fetchForageWorkflowNodes() - await setForageWorkflowNodes(forageNodes) - setLoadingNodes(false) - })() - }, []) - - useEffect(() => { - (async () => { - const forageEdges = await fetchForageWorkflowEdges() - await setForageWorkflowEdges(forageEdges) - setLoadingEdges(false) - })() - }, []) - - // Update nodes in forage if nodes array is updated - useEffect(() => { - (async () => { - if (loadingNodes) { - return - } - await setForageWorkflowNodes(nodes) - })() - }, [nodes, setForageWorkflowNodes, loadingNodes]) - - // Update edges forage on edges change - useEffect(() => { - (async () => { - if (loadingEdges) { - return - } - await setForageWorkflowEdges(edges) - })() - }, [edges, setForageWorkflowEdges, loadingEdges]) - - const workflowsEditorBodyFromFlowchart = useCallback(async () => { - - const dag_dict: any = {} - const tasks_dict: any = {} - const nodeId2taskName: any = {} - const taskName2nodeId: any = {} - - const ui_schema: any = { - "nodes": {}, - "edges": [] - } - - const data = await fetchFormsForageData() - const upstreamMap = await getForageUpstreamMap() - const nodes = await fetchForageWorkflowNodes() - const edges = await fetchForageWorkflowEdges() - - const workflowFormData = 'workflowForm' in data ? data['workflowForm'] : null - dag_dict['workflow'] = workflowFormData?.config - const storageWorkflowData = workflowFormData?.storage - - const auxTaskDict: any = {} - for (let index = 0; index < nodes.length; index++) { - let element = nodes[index] - let taskIndex = 0 - let taskName = `task_${element.data.name}_${taskIndex}` - while (taskName in auxTaskDict) { - taskIndex += 1 - taskName = `task_${element.data.name}_${taskIndex}` - } - auxTaskDict[taskName] = true - nodeId2taskName[element.id] = taskName - taskName2nodeId[taskName] = element.id - } - - //var task_index = 1 - for (let index = 0; index < nodes.length; index++) { - const element = nodes[index] - const elementData = data[element.id] - - try { - var taskIndex = 0 - var taskName = `task_${element.data.name}_${taskIndex}` - while (taskName in tasks_dict) { - taskIndex += 1 - taskName = `task_${element.data.name}_${taskIndex}` - } - ui_schema['nodes'][taskName] = element - const taskDict: any = {} - - const { source, baseFolder, ...providerOptions } = storageWorkflowData || {} - const storageDict: any = { - "source": source || null, - "base_folder": baseFolder || null, - "mode": elementData?.storage?.storageAccessMode, - "provider_options": providerOptions || null - } - taskDict['workflow_shared_storage'] = storageDict - - const containerResources = { - requests: { - cpu: elementData?.containerResources?.cpu.min, - memory: elementData?.containerResources?.memory.min - }, - limits: { - cpu: elementData?.containerResources?.cpu.max, - memory: elementData?.containerResources?.memory.max - }, - use_gpu: elementData?.containerResources?.useGpu - } - taskDict['container_resources'] = containerResources - - const nodeId = element.id - taskDict['task_id'] = taskName - taskDict['piece'] = { - id: parseInt(nodeId.split('_')[0]), - name: element.data.name - } - const pieceInputKwargs: any = {} - if (nodeId in upstreamMap) { - for (const key in upstreamMap[nodeId]) { - const value = upstreamMap[nodeId][key] - const fromUpstream = value['fromUpstream'] - pieceInputKwargs[key] = { - fromUpstream: fromUpstream, - upstreamTaskId: fromUpstream ? nodeId2taskName[value['upstreamId']] : null, - upstreamArgument: fromUpstream ? value['upstreamArgument'] : null, - value: value['value'] - } - } - } - //console.log(pieceInputKwargs) - - taskDict['piece_input_kwargs'] = pieceInputKwargs - - tasks_dict[taskName] = taskDict - //task_index += 1 - } catch (err) { - console.log('Error', err) - } - - } - - // Organize dependencies - const dependencies_dict: any = {} - for (let index = 0; index < edges.length; index++) { - const edge: any = edges[index] - const source_task_name = nodeId2taskName[edge.source] - const target_task_name = nodeId2taskName[edge.target] - if (target_task_name in dependencies_dict) { - dependencies_dict[target_task_name].push(source_task_name) - } else { - dependencies_dict[target_task_name] = [source_task_name] - } - } - - // Fill in dependencies for each task - const keys = Object.keys(tasks_dict) - keys.forEach((key, index) => { - tasks_dict[key]['dependencies'] = dependencies_dict[key] ? dependencies_dict[key] : [] - }) - // Finalize dag dictionary - ui_schema['edges'] = edges - dag_dict['tasks'] = tasks_dict - dag_dict['ui_schema'] = ui_schema - - return dag_dict - }, [fetchFormsForageData, getForageUpstreamMap, fetchForageWorkflowNodes, fetchForageWorkflowEdges]) - - const value: IWorkflowsEditorContext = useMemo( - () => ({ - repositories, - repositoriesError: !!repositoriesError, - repositoriesLoading, - repositoryOperators, - search, - edges, - setEdges, - nodes, - setNodes, - handleSearch: (word: string) => setSearch(word), - fetchRepoById, - fetchForagePieceById, - setFormsForageData, - fetchForageDataById, - removeFormsForageDataById, - clearForageData, - removeFormsForageDataNotInIds, - handleCreateWorkflow, - fetchForageWorkflowEdges: () => fetchForageWorkflowEdges(), - fetchForageWorkflowNodes: () => fetchForageWorkflowNodes(), - workflowsEditorBodyFromFlowchart, - getForageUpstreamMap, - setForageUpstreamMap, - clearForageUpstreamMap, - nodeDirection, - setForageCheckboxStates, - getForageCheckboxStates, - setNameKeyUpstreamArgsMap, - getNameKeyUpstreamArgsMap, - clearNameKeyUpstreamArgsMap, - toggleNodeDirection: () => - setNodeDirection((current: any) => - current === 'vertical' ? 'horizontal' : 'vertical' - ) - }), - [ - fetchRepoById, - fetchForagePieceById, - setFormsForageData, - fetchForageDataById, - removeFormsForageDataById, - clearForageData, - removeFormsForageDataNotInIds, - handleCreateWorkflow, - fetchForageWorkflowEdges, - fetchForageWorkflowNodes, - workflowsEditorBodyFromFlowchart, - getForageUpstreamMap, - setForageUpstreamMap, - clearForageUpstreamMap, - nodeDirection, - repositories, - repositoriesError, - repositoriesLoading, - repositoryOperators, - search, - edges, - setEdges, - nodes, - setNodes, - setForageCheckboxStates, - getForageCheckboxStates, - setNameKeyUpstreamArgsMap, - getNameKeyUpstreamArgsMap, - clearNameKeyUpstreamArgsMap - ] - ) - - return ( - - {children} - - ) -} diff --git a/frontend/src/context/workflows/workflows.context.tsx b/frontend/src/context/workflows/workflows.context.tsx deleted file mode 100644 index cc7ba4f2..00000000 --- a/frontend/src/context/workflows/workflows.context.tsx +++ /dev/null @@ -1,219 +0,0 @@ -import { FC, useCallback, useEffect, useMemo, useState } from 'react' -import { toast } from 'react-toastify' -import { - IWorkflow, - IGetWorkflowResponseInterface, - useAuthenticatedDeleteWorkflowId, - useAuthenticatedGetWorkflows, - useAuthenticatedPostWorkflowRunId, - useAuthenticatedGetWorkflowId -} from 'services/requests/workflow' - - -import { - useAuthenticatedGetWorkflowRuns, - IGetWorkflowRunsResponseInterface, - useAuthenticatedGetWorkflowRunTasks, - useAuthenticatedGetWorkflowRunTaskLogs, - useAuthenticatedGetWorkflowRunTaskResult, - IGetWorkflowRunTasksResponseInterface, -} from 'services/requests/runs' - -import { createCustomContext } from 'utils' - -interface IWorkflowsContext { - workflows: IGetWorkflowResponseInterface - workflowsError: boolean - workflowsLoading: boolean - - handleDeleteWorkflow: (id: string) => Promise - handleRefreshWorkflows: () => void - handleFetchWorkflow: (id: string) => Promise - handleRunWorkflow: (id: string) => Promise - tablePage: number - setTablePage: (page: number) => void - tablePageSize: number - setTablePageSize: (pageSize: number) => void - runsTablePage: number - setRunsTablePage: (page: number) => void - runsTablePageSize: number - setRunsTablePageSize: (pageSize: number) => void - selectedWorkflow: IWorkflow | null - setSelectedWorkflow: (workflow: IWorkflow | null) => void - - workflowRuns: IGetWorkflowRunsResponseInterface - selectedWorkflowRunId: string | null - setSelectedWorkflowRunId: (workflowRunId: string | null) => void - - handleFetchWorkflowRunTasks: (page: number, pageSize: number) => Promise - handleRefreshWorkflowRuns: () => void - handleFetchWorkflowRunTaskLogs: (taskId: string, taskTryNumber: string) => Promise - handleFetchWorkflowRunTaskResult: (taskId: string, taskTryNumber: string) => Promise - -} - -export const [WorkflowsContext, useWorkflows] = - createCustomContext('Workflows Context') - -interface IWorkflowsProviderProps { - children?: React.ReactNode -} -/** - * Workflows provider. - */ -export const WorkflowsProvider: FC = ({ children }) => { - // Workflows table settings - const [tablePage, setTablePage] = useState(0); - const [tablePageSize, setTablePageSize] = useState(10); - - // Workflow runs table settings - const [runsTablePage, setRunsTablePage] = useState(0) - const [runsTablePageSize, setRunsTablePageSize] = useState(10) - - // Store data of user interaction with table rows - const [selectedWorkflow, setSelectedWorkflow] = useState(null); - const [selectedWorkflowRunId, setSelectedWorkflowRunId] = useState(null); - - // Requests Hooks - const { - data, - error: workflowsError, - isValidating: workflowsLoading, - mutate: workflowsRefresh - } = useAuthenticatedGetWorkflows(tablePage, tablePageSize) - - - const { - data: workflowRunsData, - error: workflowRunError, - mutate: workflowRunsRefresh - } = useAuthenticatedGetWorkflowRuns({ workflowId: selectedWorkflow?.id.toString() || '', page: runsTablePage, pageSize: runsTablePageSize }) - - const deleteWorkflow = useAuthenticatedDeleteWorkflowId() - const fetchWorkflowById = useAuthenticatedGetWorkflowId() - const runWorkflowById = useAuthenticatedPostWorkflowRunId() - const fetchWorkflowRunTasks = useAuthenticatedGetWorkflowRunTasks() - const fetchWorkflowRunTaskLogs = useAuthenticatedGetWorkflowRunTaskLogs() - const fetchWorkflowRunTaskResult = useAuthenticatedGetWorkflowRunTaskResult() - - useEffect(() => { - if (!!workflowsError) { - toast.error('Error loading workflows, try again later') - } - }, [workflowsError]) - - useEffect(() => { - if (!!workflowRunError) { - toast.error('Error loading workflow runs, try again later') - } - }, [workflowRunError]) - - - /** - * Workflows data - */ - const workflows: IGetWorkflowResponseInterface = useMemo(() => { - return data ?? { - data: [], - metadata: { - page: 0, - last_page: 0, - records: 0, - total: 0 - } - } - }, [data]) - - const workflowRuns: IGetWorkflowRunsResponseInterface = useMemo(() => { - return workflowRunsData ?? { - data: [], - metadata: { - page: 0, - last_page: 0, - records: 0, - total: 0 - } - } - }, [workflowRunsData]) - - // Requests handlers - const handleFetchWorkflowRunTasks = useCallback(async (page: number = 0, pageSize: number = 100) => { - const workflowId = selectedWorkflow?.id.toString() || '' - const runId = selectedWorkflowRunId || '' - return fetchWorkflowRunTasks({ workflowId, runId, page, pageSize }) - }, [fetchWorkflowRunTasks, selectedWorkflow, selectedWorkflowRunId]) - - const handleFetchWorkflowRunTaskLogs = useCallback(async (taskId: string, taskTryNumber: string) => { - const workflowId = selectedWorkflow?.id.toString() || '' - const runId = selectedWorkflowRunId || '' - return fetchWorkflowRunTaskLogs({ workflowId, runId, taskId, taskTryNumber }) - }, [fetchWorkflowRunTaskLogs, selectedWorkflow, selectedWorkflowRunId]) - - const handleFetchWorkflowRunTaskResult = useCallback(async (taskId: string, taskTryNumber: string) => { - const workflowId = selectedWorkflow?.id.toString() || '' - const runId = selectedWorkflowRunId || '' - return fetchWorkflowRunTaskResult({ workflowId, runId, taskId, taskTryNumber }) - }, [fetchWorkflowRunTaskResult, selectedWorkflow, selectedWorkflowRunId]) - - - const value: IWorkflowsContext = useMemo( - () => ({ - workflows, - workflowsError: !!workflowsError, - workflowsLoading, - handleDeleteWorkflow: (id: string) => deleteWorkflow({ id }), - handleRefreshWorkflows: () => workflowsRefresh(), - handleFetchWorkflow: (id: string) => fetchWorkflowById({ id }), - handleRunWorkflow: (id: string) => runWorkflowById({ id }), - handleFetchWorkflowRunTaskLogs: (taskId: string, taskTryNumber: string) => handleFetchWorkflowRunTaskLogs(taskId, taskTryNumber), - handleFetchWorkflowRunTaskResult: (taskId: string, taskTryNumber: string) => handleFetchWorkflowRunTaskResult(taskId, taskTryNumber), - tablePage, - setTablePage, - tablePageSize, - setTablePageSize, - runsTablePage, - setRunsTablePage, - runsTablePageSize, - setRunsTablePageSize, - selectedWorkflow, - setSelectedWorkflow, - workflowRuns, - selectedWorkflowRunId, - setSelectedWorkflowRunId, - handleRefreshWorkflowRuns: () => workflowRunsRefresh(), - handleFetchWorkflowRunTasks: handleFetchWorkflowRunTasks - }), - [ - workflows, - workflowsError, - workflowsLoading, - deleteWorkflow, - workflowsRefresh, - fetchWorkflowById, - runWorkflowById, - tablePage, - setTablePage, - tablePageSize, - setTablePageSize, - runsTablePage, - setRunsTablePage, - runsTablePageSize, - setRunsTablePageSize, - selectedWorkflow, - setSelectedWorkflow, - workflowRuns, - selectedWorkflowRunId, - setSelectedWorkflowRunId, - workflowRunsRefresh, - handleFetchWorkflowRunTasks, - handleFetchWorkflowRunTaskLogs, - handleFetchWorkflowRunTaskResult - ] - ) - - return ( - - {children} - - ) -} diff --git a/frontend/src/context/workspaces/api/acceptWorkspaceInvite.ts b/frontend/src/context/workspaces/api/acceptWorkspaceInvite.ts new file mode 100644 index 00000000..14f509a7 --- /dev/null +++ b/frontend/src/context/workspaces/api/acceptWorkspaceInvite.ts @@ -0,0 +1,34 @@ +// TODO move to /runs +import { type AxiosResponse } from "axios"; +import { dominoApiClient } from "services/clients/domino.client"; + +interface AcceptWorkspaceInviteParams { + workspaceId: string; +} + +const acceptWorkspaceInviteUrl = (workspaceId: string) => + `/workspaces/${workspaceId}/invites/accept`; + +/** + * Run workflow by id using /workflow/run/:id + * @returns workflow run result + */ +const acceptWorkspaceInvite: ( + params: AcceptWorkspaceInviteParams, +) => Promise = async (params) => { + return await dominoApiClient.post( + acceptWorkspaceInviteUrl(params.workspaceId), + null, + ); +}; + +/** + * Run workflow by id fetcher fn + * @param params `{ id: string }` + */ +export const useAuthenticatedAcceptWorkspaceInvite = () => { + const fetcher = async (params: AcceptWorkspaceInviteParams) => + await acceptWorkspaceInvite(params).then((data) => data); + + return fetcher; +}; diff --git a/frontend/src/context/workspaces/api/deleteUserWorkspace.ts b/frontend/src/context/workspaces/api/deleteUserWorkspace.ts new file mode 100644 index 00000000..a783e657 --- /dev/null +++ b/frontend/src/context/workspaces/api/deleteUserWorkspace.ts @@ -0,0 +1,34 @@ +// TODO move to /runs +import { type AxiosResponse } from "axios"; +import { dominoApiClient } from "services/clients/domino.client"; + +interface RemoveUserWorkspaceParams { + workspaceId: string; + userId: string; +} + +const removeUserWorkspaceUrl = (workspaceId: string, userId: string) => + `/workspaces/${workspaceId}/users/${userId}`; + +/** + * Run workflow by id using /workflow/run/:id + * @returns workflow run result + */ +const removeUserWorkspace: ( + params: RemoveUserWorkspaceParams, +) => Promise = async (params) => { + return await dominoApiClient.delete( + removeUserWorkspaceUrl(params.workspaceId, params.userId), + ); +}; + +/** + * Run workflow by id fetcher fn + * @param params `{ id: string }` + */ +export const useAuthenticatedRemoveUserWorkspace = () => { + const fetcher = async (params: RemoveUserWorkspaceParams) => + await removeUserWorkspace(params).then((data) => data); + + return fetcher; +}; diff --git a/frontend/src/context/workspaces/api/deleteWorkspace.ts b/frontend/src/context/workspaces/api/deleteWorkspace.ts new file mode 100644 index 00000000..99d60ac4 --- /dev/null +++ b/frontend/src/context/workspaces/api/deleteWorkspace.ts @@ -0,0 +1,33 @@ +import { type AxiosResponse } from "axios"; +import { useCallback } from "react"; +import { dominoApiClient } from "services/clients/domino.client"; + +type IDeleteWorkspacesResponseInterface = unknown; + +export interface IDeleteWorkspacesParams { + id: string; +} + +/** + * Delete workspace using delete /workspaces/:id + * @returns ? + */ +const deleteWorkspace: ( + params: IDeleteWorkspacesParams, +) => Promise> = async ( + params, +) => { + return await dominoApiClient.delete(`/workspaces/${params.id}`); +}; + +export const useAuthenticatedDeleteWorkspaces = () => { + const fetcher = useCallback( + async (params: IDeleteWorkspacesParams) => + await deleteWorkspace(params).then((data) => { + return data; + }), + [], + ); + + return fetcher; +}; diff --git a/frontend/src/context/workspaces/api/getWorkspaceId.ts b/frontend/src/context/workspaces/api/getWorkspaceId.ts new file mode 100644 index 00000000..33a69b72 --- /dev/null +++ b/frontend/src/context/workspaces/api/getWorkspaceId.ts @@ -0,0 +1,44 @@ +import { type AxiosResponse } from "axios"; +import { dominoApiClient } from "services/clients/domino.client"; +import useSWR from "swr"; + +import { type IGetWorkspaceIdResponseInterface } from "../types/workspaces"; + +interface IGetWorkspaceIdParams { + id: string; +} + +/** + * Get workspaces using GET /workspaces/ + * @param id workspace id + * @returns workspace + */ +const getWorkspaceId: ( + params: IGetWorkspaceIdParams, +) => Promise> = async ( + params, +) => { + return await dominoApiClient.get(`/workspaces/${params.id}`); +}; + +/** + * Authenticated fetcher function that gets workspace by id + * @param params `{ id: string }` + * @returns workspace fetcher fn + */ +export const useAuthenticatedGetWorkspaceIdFetcher = () => { + return async (params: IGetWorkspaceIdParams) => + await getWorkspaceId(params).then((data) => data.data); +}; + +/** + * Get workspace data + * @returns workspace data as swr response + */ +export const useAuthenticatedGetWorkspace = (params: IGetWorkspaceIdParams) => { + const fetcher = useAuthenticatedGetWorkspaceIdFetcher(); + return useSWR(`/workspaces/${params.id}`, async () => await fetcher(params), { + revalidateOnFocus: false, + revalidateOnReconnect: false, + }); +}; diff --git a/frontend/src/context/workspaces/api/getWorkspaceMembers.ts b/frontend/src/context/workspaces/api/getWorkspaceMembers.ts new file mode 100644 index 00000000..d5a06313 --- /dev/null +++ b/frontend/src/context/workspaces/api/getWorkspaceMembers.ts @@ -0,0 +1,66 @@ +import { type AxiosResponse } from "axios"; +import { useAuthentication } from "context/authentication"; +import { useCallback } from "react"; +import { dominoApiClient } from "services/clients/domino.client"; +import useSWR from "swr"; + +import { type IGetWorkspaceUsersResponse } from "../types/workspaces"; + +interface IGetWorkspaceMembers { + workspaceId: string; + page: number; + pageSize: number; +} + +/** + * Get workspaces using GET /workspaces + * @returns workspaces + */ +const getWorkspaceUsers: ( + workspaceId: string, + page: number, + pageSize: number, +) => Promise> = async ( + workspaceId, + page, + pageSize, +) => { + return await dominoApiClient.get( + `/workspaces/${workspaceId}/users?page=${page}&page_size=${pageSize}`, + ); +}; + +/** + * Get workspaces + * @returns workspaces as swr response + */ +export const useAuthenticatedGetWorkspaceUsers = ( + params: IGetWorkspaceMembers, +) => { + const fetcher = useCallback(async (params: IGetWorkspaceMembers) => { + return await getWorkspaceUsers( + params.workspaceId, + params.page, + params.pageSize, + ).then((data) => data.data); + }, []); + + const auth = useAuthentication(); + + if (!params.page) { + params.page = 0; + } + if (!params.pageSize) { + params.pageSize = 10; + } + return useSWR( + auth.isLogged && params.workspaceId + ? `/workspaces/${params.workspaceId}/users?page=${params.page}&page_size=${params.pageSize}` + : null, + async () => await fetcher(params), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, + ); +}; diff --git a/frontend/src/context/workspaces/api/getWorkspaces.ts b/frontend/src/context/workspaces/api/getWorkspaces.ts new file mode 100644 index 00000000..e0f7805a --- /dev/null +++ b/frontend/src/context/workspaces/api/getWorkspaces.ts @@ -0,0 +1,38 @@ +import { type AxiosResponse } from "axios"; +import { useAuthentication } from "context/authentication"; +import { useCallback } from "react"; +import { dominoApiClient } from "services/clients/domino.client"; +import useSWR from "swr"; + +import { type IGetWorkspacesResponseInterface } from "../types/workspaces"; + +/** + * Get workspaces using GET /workspaces + * @returns workspaces + */ +const getWorkspaces: () => Promise< + AxiosResponse +> = async () => { + return await dominoApiClient.get("/workspaces"); +}; + +/** + * Get workspaces + * @returns workspaces as swr response + */ +export const useAuthenticatedGetWorkspaces = () => { + const fetcher = useCallback(async () => { + return await getWorkspaces().then((data) => data.data); + }, []); + + const auth = useAuthentication(); + + return useSWR( + auth.isLogged ? `/workspaces` : null, + async () => await fetcher(), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, + ); +}; diff --git a/frontend/src/context/workspaces/api/index.ts b/frontend/src/context/workspaces/api/index.ts new file mode 100644 index 00000000..4ea560e5 --- /dev/null +++ b/frontend/src/context/workspaces/api/index.ts @@ -0,0 +1,11 @@ +export * from "./getWorkspaceId"; +export * from "./getWorkspaces"; +export * from "./postPiecesRepositories"; +export * from "./postWorkspaces"; +export * from "./deleteWorkspace"; +export * from "./patchWorkspace"; +export * from "./acceptWorkspaceInvite"; +export * from "./rejectWorkspaceInvite"; +export * from "./inviteWorkspace"; +export * from "./deleteUserWorkspace"; +export * from "./getWorkspaceMembers"; diff --git a/frontend/src/context/workspaces/api/inviteWorkspace.ts b/frontend/src/context/workspaces/api/inviteWorkspace.ts new file mode 100644 index 00000000..577279cc --- /dev/null +++ b/frontend/src/context/workspaces/api/inviteWorkspace.ts @@ -0,0 +1,36 @@ +// TODO move to /runs +import { type AxiosResponse } from "axios"; +import { dominoApiClient } from "services/clients/domino.client"; + +interface InviteWorkspaceParams { + workspaceId: string; + userEmail: string; + permission: string; +} + +const inviteWorkspaceUrl = (workspaceId: string) => + `/workspaces/${workspaceId}/invites`; + +/** + * Run workflow by id using /workflow/run/:id + * @returns workflow run result + */ +const inviteWorkspace: ( + params: InviteWorkspaceParams, +) => Promise = async (params) => { + return await dominoApiClient.post(inviteWorkspaceUrl(params.workspaceId), { + user_email: params.userEmail, + permission: params.permission, + }); +}; + +/** + * Run workflow by id fetcher fn + * @param params `{ id: string }` + */ +export const useAuthenticatedWorkspaceInvite = () => { + const fetcher = async (params: InviteWorkspaceParams) => + await inviteWorkspace(params).then((data) => data); + + return fetcher; +}; diff --git a/frontend/src/context/workspaces/api/patchWorkspace.ts b/frontend/src/context/workspaces/api/patchWorkspace.ts new file mode 100644 index 00000000..7f58e942 --- /dev/null +++ b/frontend/src/context/workspaces/api/patchWorkspace.ts @@ -0,0 +1,45 @@ +// TODO move to /runs +import { type AxiosResponse } from "axios"; +import { useWorkspaces } from "context/workspaces"; +import { dominoApiClient } from "services/clients/domino.client"; + +interface PatchWorkspaceParams { + workspaceId: string; + payload: { + name?: string | null; + github_access_token?: string | null; + }; +} + +const patchWorkspaceUrl = (workspaceId: string) => `/workspaces/${workspaceId}`; + +/** + * Run workflow by id using /workflow/run/:id + * @returns workflow run result + */ +const patchWorkspace: ( + params: PatchWorkspaceParams, +) => Promise = async (params) => { + return await dominoApiClient.patch( + patchWorkspaceUrl(params.workspaceId), + params.payload, + ); +}; + +/** + * Run workflow by id fetcher fn + * @param params `{ id: string }` + */ +export const useAuthenticatedPatchWorkspace = () => { + const { workspace } = useWorkspaces(); + + if (!workspace) + throw new Error( + "Impossible to run workflows without specifying a workspace", + ); + + const fetcher = async (params: PatchWorkspaceParams) => + await patchWorkspace(params).then((data) => data); + + return fetcher; +}; diff --git a/frontend/src/context/workspaces/api/postPiecesRepositories.ts b/frontend/src/context/workspaces/api/postPiecesRepositories.ts new file mode 100644 index 00000000..93b8886d --- /dev/null +++ b/frontend/src/context/workspaces/api/postPiecesRepositories.ts @@ -0,0 +1,40 @@ +import { type AxiosResponse } from "axios"; +import { dominoApiClient } from "services/clients/domino.client"; + +import { + type IPostWorkspaceRepositoryParams, + type IPostWorkspaceRepositoryPayload, + type IPostWorkspaceRepositoryResponseInterface, +} from "../types/workspaces"; + +/** + * Create workspacesidPiecesrepositories using POST /workspacesidPiecesrepositories + * @returns ? + */ +const postPiecesRepository: ( + params: IPostWorkspaceRepositoryParams, +) => Promise> = async ( + params, +) => { + return await dominoApiClient.post("/pieces-repositories", params.data); +}; + +/** + * Create authenticated workspacesidPiecesrepositories + * @param params `{ id: string, data: Record }`` + * @returns crate workspacesidPiecesrepositories function + */ +export const useAuthenticatedPostPiecesRepository = (params: { + workspace: string; +}) => { + if (!params?.workspace) + throw new Error("Impossible to add repositories without a workspace!"); + + const fetcher = async (payload: IPostWorkspaceRepositoryPayload) => + await postPiecesRepository({ + id: params.workspace, + data: payload, + }).then((data) => data.data); + + return fetcher; +}; diff --git a/frontend/src/context/workspaces/api/postWorkspaces.ts b/frontend/src/context/workspaces/api/postWorkspaces.ts new file mode 100644 index 00000000..cdb12f69 --- /dev/null +++ b/frontend/src/context/workspaces/api/postWorkspaces.ts @@ -0,0 +1,38 @@ +import { type AxiosResponse } from "axios"; +import { useCallback } from "react"; +import { dominoApiClient } from "services/clients/domino.client"; + +type IPostWorkspacesResponseInterface = Record; + +export interface IPostWorkspacesParams { + name: string; +} + +/** + * Create workspace using POST /workspaces + * @returns ? + */ +const postWorkspaces: ( + params: IPostWorkspacesParams, +) => Promise> = async ( + params, +) => { + return await dominoApiClient.post(`/workspaces`, params); +}; + +/** + * Create authenticated workspaces + * @param params `{ name: string }` + * @returns crate workspaces function + */ +export const useAuthenticatedPostWorkspaces = () => { + const fetcher = useCallback( + async (params: IPostWorkspacesParams) => + await postWorkspaces(params).then((data) => { + return data.data; + }), + [], + ); + + return fetcher; +}; diff --git a/frontend/src/context/workspaces/api/rejectWorkspaceInvite.ts b/frontend/src/context/workspaces/api/rejectWorkspaceInvite.ts new file mode 100644 index 00000000..132398e5 --- /dev/null +++ b/frontend/src/context/workspaces/api/rejectWorkspaceInvite.ts @@ -0,0 +1,34 @@ +// TODO move to /runs +import { type AxiosResponse } from "axios"; +import { dominoApiClient } from "services/clients/domino.client"; + +interface RejectWorkspaceInviteParams { + workspaceId: string; +} + +const rejectWorkspaceInviteUrl = (workspaceId: string) => + `/workspaces/${workspaceId}/invites/reject`; + +/** + * Run workflow by id using /workflow/run/:id + * @returns workflow run result + */ +const rejectWorkspaceInvite: ( + params: RejectWorkspaceInviteParams, +) => Promise = async (params) => { + return await dominoApiClient.post( + rejectWorkspaceInviteUrl(params.workspaceId), + null, + ); +}; + +/** + * Run workflow by id fetcher fn + * @param params `{ id: string }` + */ +export const useAuthenticatedRejectWorkspaceInvite = () => { + const fetcher = async (params: RejectWorkspaceInviteParams) => + await rejectWorkspaceInvite(params).then((data) => data); + + return fetcher; +}; diff --git a/frontend/src/context/workspaces/index.tsx b/frontend/src/context/workspaces/index.tsx new file mode 100644 index 00000000..8a09b2fa --- /dev/null +++ b/frontend/src/context/workspaces/index.tsx @@ -0,0 +1,257 @@ +import { type FC, useCallback, useMemo, useState } from "react"; +import { toast } from "react-toastify"; +import { createCustomContext } from "utils"; + +import { + useAuthenticatedGetWorkspaces, + useAuthenticatedPostWorkspaces, + useAuthenticatedDeleteWorkspaces, + useAuthenticatedAcceptWorkspaceInvite, + useAuthenticatedRejectWorkspaceInvite, + useAuthenticatedWorkspaceInvite, + useAuthenticatedRemoveUserWorkspace, + useAuthenticatedGetWorkspaceUsers, +} from "./api"; +import { type IWorkspaceSummary } from "./types/workspaces"; + +interface IWorkspacesContext { + workspaces: IWorkspaceSummary[]; + workspacesError: boolean; + workspacesLoading: boolean; + handleRefreshWorkspaces: () => void; + + workspace: IWorkspaceSummary | null; + handleChangeWorkspace: (id: string) => void; + handleCreateWorkspace: (name: string) => Promise; + handleDeleteWorkspace: (id: string) => void; + handleUpdateWorkspace: (workspace: IWorkspaceSummary) => void; + handleAcceptWorkspaceInvite: (id: string) => void; + handleRejectWorkspaceInvite: (id: string) => void; + handleInviteUserWorkspace: ( + id: string, + userEmail: string, + permission: string, + ) => void; + handleRemoveUserWorkspace: (workspaceId: string, userId: string) => void; + workspaceUsers: any; + workspaceUsersRefresh: () => void; + workspaceUsersTablePageSize: number; + workspaceUsersTablePage: number; + setWorkspaceUsersTablePageSize: (pageSize: number) => void; + setWorkspaceUsersTablePage: (page: number) => void; +} + +export const [WorkspacesContext, useWorkspaces] = + createCustomContext("Workspaces Context"); + +interface IWorkspacesProviderProps { + children: React.ReactNode; +} + +export const WorkspacesProvider: FC = ({ + children, +}) => { + const [workspace, setWorkspace] = useState( + localStorage.getItem("workspace") + ? // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + (JSON.parse(localStorage.getItem("workspace")!) as IWorkspaceSummary) + : null, + ); + + const [workspaceUsersTablePageSize, setWorkspaceUsersTablePageSize] = + useState(5); + const [workspaceUsersTablePage, setWorkspaceUsersTablePage] = + useState(0); + + // Requests hooks + const { + data, + error: workspacesError, + isValidating: workspacesLoading, + mutate: workspacesRefresh, + } = useAuthenticatedGetWorkspaces(); + + const { data: workspaceUsers, mutate: workspaceUsersRefresh } = + useAuthenticatedGetWorkspaceUsers( + workspace + ? { + workspaceId: workspace.id, + page: workspaceUsersTablePage, + pageSize: workspaceUsersTablePageSize, + } + : { + workspaceId: "", + page: workspaceUsersTablePage, + pageSize: workspaceUsersTablePageSize, + }, + ); + + const postWorkspace = useAuthenticatedPostWorkspaces(); + const deleteWorkspace = useAuthenticatedDeleteWorkspaces(); + + const acceptWorkspaceInvite = useAuthenticatedAcceptWorkspaceInvite(); + const rejectWorkspaceInvite = useAuthenticatedRejectWorkspaceInvite(); + const inviteWorkspace = useAuthenticatedWorkspaceInvite(); + const removeUserWorkspace = useAuthenticatedRemoveUserWorkspace(); + + // Memoized data + const workspaces: IWorkspaceSummary[] = useMemo(() => data ?? [], [data]); + + // Handlers + const handleRemoveUserWorkspace = useCallback( + (workspaceId: string, userId: string) => { + if (!workspaceId || !userId) { + toast.error( + "Workspace and user must be defined to remove user from workspace.", + ); + } + removeUserWorkspace({ workspaceId, userId }) + .then(() => { + toast.success(`User removed successfully from workspace.`); + void workspacesRefresh(); + }) + .catch((error) => { + console.log("Removing user error:", error.response.data.detail); + toast.error(error.response.data.detail); + }); + }, + [removeUserWorkspace, workspacesRefresh], + ); + + const handleInviteUserWorkspace = useCallback( + (id: string, userEmail: string, permission: string) => { + if (!id) { + return false; + } + inviteWorkspace({ + workspaceId: id, + userEmail, + permission, + }) + .then(() => { + toast.success(`User invited successfully`); + void workspaceUsersRefresh(); + }) + .catch((error) => { + console.log("Inviting user error:", error.response.data.detail); + toast.error(error.response.data.detail); + }); + }, + [inviteWorkspace, workspaceUsersRefresh()], + ); + + const handleAcceptWorkspaceInvite = useCallback( + async (id: string) => { + acceptWorkspaceInvite({ workspaceId: id }) + .then(() => { + // toast.success(`Workspace invitation accepted successfully`) + void workspacesRefresh(); + }) + .catch((error) => { + // todo custom msg + console.log("Accepting workspace invitation error:", error); + toast.error("Error accepting workspace invitation, try again later"); + }); + }, + [acceptWorkspaceInvite, workspacesRefresh], + ); + + const handleRejectWorkspaceInvite = useCallback( + async (id: string) => { + rejectWorkspaceInvite({ workspaceId: id }) + .then(() => { + toast.error(`You have rejected the workspace invitation.`); + void workspacesRefresh(); + }) + .catch((error) => { + // todo custom msg + console.log("Rejecting workspace invitation error:", error); + toast.error("Error rejecting workspace invitation, try again later"); + }); + }, + [rejectWorkspaceInvite, workspacesRefresh], + ); + + const handleCreateWorkspace = useCallback( + async (name: string) => + await postWorkspace({ name }) + .then((data) => { + toast.success(`Workflow ${name} created successfully`); + void workspacesRefresh(); + return data; + }) + .catch(() => { + toast.error("Error creating workspace, try again later"); + }), + [postWorkspace, workspacesRefresh], + ); + const handleUpdateWorkspace = useCallback((workspace: IWorkspaceSummary) => { + setWorkspace(workspace); + localStorage.setItem("workspace", JSON.stringify(workspace)); + }, []); + + const handleChangeWorkspace = useCallback( + (id: string) => { + const next = + workspaces.filter((workspace) => workspace.id === id)?.[0] ?? null; + // setWorkspace(next) + // localStorage.setItem('workspace', JSON.stringify(next)) + handleUpdateWorkspace(next); + }, + [workspaces, handleUpdateWorkspace], + ); + + const handleDeleteWorkspace = useCallback( + (id: string) => { + deleteWorkspace({ id }) + .then(() => { + const storageWorkspace = JSON.parse( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + localStorage.getItem("workspace")!, + ); + if (storageWorkspace && storageWorkspace.id === id) { + localStorage.removeItem("workspace"); + setWorkspace(null); + } + void workspacesRefresh(); + }) + .catch((error) => { + console.log("Deleting workspace error:", error); + if (error.response.status === 403) { + toast.error("You don't have permission to delete this workspace."); + return; + } + toast.error("Error deleting workspace, try again later"); + }); + }, + [deleteWorkspace, workspacesRefresh], + ); + + return ( + await workspacesRefresh(), + workspace, + handleChangeWorkspace, + handleCreateWorkspace, + handleDeleteWorkspace, + handleUpdateWorkspace, + handleAcceptWorkspaceInvite, + handleRejectWorkspaceInvite, + handleInviteUserWorkspace, + handleRemoveUserWorkspace, + workspaceUsers, + workspaceUsersRefresh, + workspaceUsersTablePageSize, + workspaceUsersTablePage, + setWorkspaceUsersTablePageSize, + setWorkspaceUsersTablePage, + }} + > + {children} + + ); +}; diff --git a/frontend/src/context/workspaces/types/index.ts b/frontend/src/context/workspaces/types/index.ts new file mode 100644 index 00000000..5b84fc9b --- /dev/null +++ b/frontend/src/context/workspaces/types/index.ts @@ -0,0 +1 @@ +export * from "./workspaces"; diff --git a/frontend/src/context/workspaces/types/workspaces.ts b/frontend/src/context/workspaces/types/workspaces.ts new file mode 100644 index 00000000..1b792a4e --- /dev/null +++ b/frontend/src/context/workspaces/types/workspaces.ts @@ -0,0 +1,68 @@ +export enum repositorySource { + github = "github", +} + +// Workspace status enum with values (pending, accepted and rejected) +export enum workspaceStatus { + PENDING = "pending", + ACCEPTED = "accepted", + REJECTED = "rejected", +} + +interface IPaginationMetadata { + page: number; + records: number; + total: number; + last_page: number; +} + +export interface IWorkspaceSummary { + id: string; + workspace_name: string; + user_permission: string; + status: workspaceStatus; + github_access_token_filled: boolean; +} + +export interface IWorkspaceDetails { + id: string; + workspace_name: string; + github_access_token_filled: string; + // users: { user_id: string, permission: string }[] + // Pieces_repositories: { + // repository_id: string + // repository_name: string + // repository_source: ERepositorySource | string + // }[] +} + +export type IGetWorkspacesResponseInterface = IWorkspaceSummary[]; +export type IGetWorkspaceIdResponseInterface = IWorkspaceSummary; +export interface IGetWorkspaceUsersResponse { + data: [ + { + user_id: number; + user_email: string; + user_permission: string; + status: workspaceStatus; + }, + ]; + metadata: IPaginationMetadata; +} + +/** + * @todo type properly + */ +export type IPostWorkspaceRepositoryResponseInterface = Record; + +export interface IPostWorkspaceRepositoryPayload { + workspace_id: string; + source: repositorySource | string; + path: string; + version: string; + url: string; +} +export interface IPostWorkspaceRepositoryParams { + id: string; + data: IPostWorkspaceRepositoryPayload; +} diff --git a/frontend/src/context/workspaces/workspace-settings.context.tsx b/frontend/src/context/workspaces/workspace-settings.context.tsx deleted file mode 100644 index 09f7fb59..00000000 --- a/frontend/src/context/workspaces/workspace-settings.context.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { FC, useCallback, useState } from 'react' -import { toast } from 'react-toastify' -import { - IGetOperatorsRepositoriesReleasesParams, - IGetOperatorsRepositoriesReleasesResponseInterface, - IOperatorRepository, - useAuthenticatedGetOperatorRepositories -} from 'services/requests/piece' -import { useAuthenticatedGetOperatorRepositoriesReleases } from 'services/requests/piece/get-operators-repositories-releases.request' -import { useAuthenticatedDeleteRepository } from 'services/requests/repository' - -import { - IPostWorkspaceRepositoryPayload, - IPostWorkspaceRepositoryResponseInterface, - IWorkspaceSummary, - useAuthenticatedGetWorkspace, - useAuthenticatedPostOperatorsRepository -} from 'services/requests/workspaces' -import { createCustomContext } from 'utils' - -import { useWorkspaces } from './workspaces.context' - -interface IWorkspaceSettingsContext { - workspace: IWorkspaceSummary | null - - workspaceData: IWorkspaceSummary | undefined - workspaceDataError: boolean - workspaceDataLoading: boolean - handleRefreshWorkspaceData: () => void - - repositories: IOperatorRepository[] - repositoriesError: boolean - repositoriesLoading: boolean - handleRefreshRepositories: () => void - - handleAddRepository: ( - params: Omit - ) => Promise - - handleFetchRepoReleases: ( - params: IGetOperatorsRepositoriesReleasesParams - ) => Promise - handleDeleteRepository: (id: string) => Promise - selectedRepositoryId: number | null - setSelectedRepositoryId: (id: number | null) => void - - defaultRepositories: IOperatorRepository[] - defaultRepositoriesError: boolean - defaultRepositoriesLoading: boolean - handleRefreshDefaultRepositories: () => void -} - -export const [WorkspaceSettingsContext, useWorkspaceSettings] = - createCustomContext('Workspace Settings Context') - - -interface IWorkspaceSettingsProviderProps { - children: React.ReactNode -} -export const WorkspaceSettingsProvider: FC = ({ children }) => { - const { workspace } = useWorkspaces() - - const [selectedRepositoryId, setSelectedRepositoryId] = useState(null) - - // Requests hooks - const { - data: workspaceData, - error: workspaceDataError, - isValidating: workspaceDataLoading, - mutate: refreshWorkspaceData - } = useAuthenticatedGetWorkspace({ id: workspace?.id ?? '' }) - - /** - * @todo add pagination - */ - const { - data: repositories, - error: repositoriesError, - isValidating: repositoriesLoading, - mutate: refreshRepositories - } = useAuthenticatedGetOperatorRepositories({}) - - const { - data: defaultRepositories, - error: defaultRepositoriesError, - isValidating: defaultRepositoriesLoading, - mutate: refreshDefaultRepositories - } = useAuthenticatedGetOperatorRepositories({ source: "default"}) - - const postRepository = useAuthenticatedPostOperatorsRepository({workspace: workspace?.id ?? ''}) - const handleFetchRepoReleases = useAuthenticatedGetOperatorRepositoriesReleases() - const handleDeleteRepository = useAuthenticatedDeleteRepository() - - // Handlers - const handleAddRepository = useCallback( - (payload: Omit) => - postRepository({ ...payload, workspace_id: workspace?.id ?? '' }) - .then((data) => { - toast.success(`Repository added successfully!`) - refreshWorkspaceData() - return data - }) - .catch((e) => { - if (e.response?.status === 403){ - toast.error(`You don't have permission to add repositories to this workspace.`) - return - } - toast.error(`Error adding repository, try again later.`) - }), - [postRepository, refreshWorkspaceData, workspace?.id] - ) - - return ( - refreshRepositories(), - handleRefreshWorkspaceData: () => refreshWorkspaceData(), - handleAddRepository, - handleFetchRepoReleases, - selectedRepositoryId, - setSelectedRepositoryId, - handleDeleteRepository, - defaultRepositories: defaultRepositories?.data ?? [], - defaultRepositoriesError: !!defaultRepositoriesError, - defaultRepositoriesLoading, - handleRefreshDefaultRepositories: () => refreshDefaultRepositories() - }} - > - {children} - - ) -} diff --git a/frontend/src/context/workspaces/workspaces.context.tsx b/frontend/src/context/workspaces/workspaces.context.tsx deleted file mode 100644 index 4b8c9153..00000000 --- a/frontend/src/context/workspaces/workspaces.context.tsx +++ /dev/null @@ -1,215 +0,0 @@ -import { FC, useCallback, useMemo, useState } from 'react' -import { toast } from 'react-toastify' - -import { - IWorkspaceSummary, - useAuthenticatedGetWorkspaces, - useAuthenticatedPostWorkspaces, - useAuthenticatedDeleteWorkspaces, - useAuthenticatedAcceptWorkspaceInvite, - useAuthenticatedRejectWorkspaceInvite, - useAuthenticatedWorkspaceInvite, - useAuthenticatedRemoveUserWorkspace, - useAuthenticatedGetWorkspaceUsers -} from 'services/requests/workspaces' - -import { createCustomContext } from 'utils' - -interface IWorkspacesContext { - workspaces: IWorkspaceSummary[] - workspacesError: boolean - workspacesLoading: boolean - handleRefreshWorkspaces: () => void - - workspace: IWorkspaceSummary | null - handleChangeWorkspace: (id: string) => void - handleCreateWorkspace: (name: string) => Promise - handleDeleteWorkspace: (id: string) => void - handleUpdateWorkspace: (workspace: IWorkspaceSummary) => void - handleAcceptWorkspaceInvite: (id: string) => void - handleRejectWorkspaceInvite: (id: string) => void, - handleInviteUserWorkspace: (id: string, userEmail: string, permission: string) => void - handleRemoveUserWorkspace: (workspaceId: string, userId: string) => void - workspaceUsers: any - workspaceUsersRefresh: () => void - workspaceUsersTablePageSize: number - workspaceUsersTablePage: number - setWorkspaceUsersTablePageSize: (pageSize: number) => void - setWorkspaceUsersTablePage: (page: number) => void - -} - -export const [WorkspacesContext, useWorkspaces] = - createCustomContext('Workspaces Context') - -interface IWorkspacesProviderProps { - children: React.ReactNode -} - -export const WorkspacesProvider: FC = ({ children }) => { - const [workspace, setWorkspace] = useState( - !!localStorage.getItem('workspace') - ? (JSON.parse(localStorage.getItem('workspace')!) as IWorkspaceSummary) - : null - ) - - const [workspaceUsersTablePageSize, setWorkspaceUsersTablePageSize] = useState(5); - const [workspaceUsersTablePage, setWorkspaceUsersTablePage] = useState(0); - - // Requests hooks - const { - data, - error: workspacesError, - isValidating: workspacesLoading, - mutate: workspacesRefresh - } = useAuthenticatedGetWorkspaces() - - const { - data: workspaceUsers, - mutate: workspaceUsersRefresh - } = useAuthenticatedGetWorkspaceUsers( - workspace ? - { - workspaceId: workspace.id, - page: workspaceUsersTablePage, - pageSize: workspaceUsersTablePageSize - } : { workspaceId: '', page: workspaceUsersTablePage, pageSize: workspaceUsersTablePageSize } - ) - - const postWorkspace = useAuthenticatedPostWorkspaces() - const deleteWorkspace = useAuthenticatedDeleteWorkspaces() - - const acceptWorkspaceInvite = useAuthenticatedAcceptWorkspaceInvite() - const rejectWorkspaceInvite = useAuthenticatedRejectWorkspaceInvite() - const inviteWorkspace = useAuthenticatedWorkspaceInvite() - const removeUserWorkspace = useAuthenticatedRemoveUserWorkspace() - - // Memoized data - const workspaces: IWorkspaceSummary[] = useMemo(() => data ?? [], [data]) - - // Handlers - const handleRemoveUserWorkspace = useCallback((workspaceId: string, userId: string) => { - if (!workspaceId || !userId) { - toast.error("Workspace and user must be defined to remove user from workspace.") - } - removeUserWorkspace({workspaceId: workspaceId, userId: userId}).then(() => { - toast.success(`User removed successfully from workspace.`) - workspacesRefresh() - }).catch((error) => { - console.log('Removing user error:', error.response.data.detail) - toast.error(error.response.data.detail) - }) - }, [removeUserWorkspace, workspacesRefresh]) - - const handleInviteUserWorkspace = useCallback((id: string, userEmail: string, permission: string) => { - if (!id) { - return false - } - inviteWorkspace({workspaceId: id, userEmail: userEmail, permission: permission}).then(() => { - toast.success(`User invited successfully`) - workspaceUsersRefresh() - }).catch((error) => { - console.log('Inviting user error:', error.response.data.detail) - toast.error(error.response.data.detail) - }) - }, [inviteWorkspace, workspaceUsersRefresh()]) - - const handleAcceptWorkspaceInvite = useCallback(async(id: string) => { - acceptWorkspaceInvite({workspaceId: id}).then(() => { - //toast.success(`Workspace invitation accepted successfully`) - workspacesRefresh() - }).catch((error) => { - // todo custom msg - console.log('Accepting workspace invitation error:', error) - toast.error('Error accepting workspace invitation, try again later') - }) - }, [acceptWorkspaceInvite, workspacesRefresh]) - - const handleRejectWorkspaceInvite = useCallback(async(id: string) => { - rejectWorkspaceInvite({workspaceId: id}).then(() => { - toast.error(`You have rejected the workspace invitation.`) - workspacesRefresh() - }).catch((error) => { - // todo custom msg - console.log('Rejecting workspace invitation error:', error) - toast.error('Error rejecting workspace invitation, try again later') - }) - }, [rejectWorkspaceInvite, workspacesRefresh]) - - - const handleCreateWorkspace = useCallback( - (name: string) => - postWorkspace({ name }) - .then((data) => { - toast.success(`Workflow ${name} created successfully`) - workspacesRefresh() - return data - }) - .catch(() => { - toast.error('Error creating workspace, try again later') - }), - [postWorkspace, workspacesRefresh] - ) - const handleUpdateWorkspace = useCallback((workspace: IWorkspaceSummary) => { - setWorkspace(workspace) - localStorage.setItem('workspace', JSON.stringify(workspace)) - }, []) - - const handleChangeWorkspace = useCallback( - (id: string) => { - const next = - workspaces.filter((workspace) => workspace.id === id)?.[0] ?? null - //setWorkspace(next) - //localStorage.setItem('workspace', JSON.stringify(next)) - handleUpdateWorkspace(next) - }, - [workspaces, handleUpdateWorkspace] - ) - - const handleDeleteWorkspace = useCallback((id: string)=>{ - deleteWorkspace({id}).then(() => { - const storageWorkspace = JSON.parse(localStorage.getItem('workspace')!) - if (storageWorkspace && storageWorkspace.id === id) { - localStorage.removeItem('workspace') - setWorkspace(null) - } - workspacesRefresh() - } - ).catch((error) => { - console.log('Deleting workspace error:', error) - if (error.response.status === 403) { - toast.error("You don't have permission to delete this workspace.") - return - } - toast.error('Error deleting workspace, try again later') - }) - }, [deleteWorkspace, workspacesRefresh]) - - return ( - workspacesRefresh(), - workspace, - handleChangeWorkspace, - handleCreateWorkspace, - handleDeleteWorkspace, - handleUpdateWorkspace, - handleAcceptWorkspaceInvite, - handleRejectWorkspaceInvite, - handleInviteUserWorkspace, - handleRemoveUserWorkspace, - workspaceUsers, - workspaceUsersRefresh, - workspaceUsersTablePageSize, - workspaceUsersTablePage, - setWorkspaceUsersTablePageSize, - setWorkspaceUsersTablePage - }} - > - {children} - - ) -} diff --git a/frontend/src/features/auth/pages/signIn/signInPage.tsx b/frontend/src/features/auth/pages/signIn/signInPage.tsx new file mode 100644 index 00000000..c9a30cf3 --- /dev/null +++ b/frontend/src/features/auth/pages/signIn/signInPage.tsx @@ -0,0 +1,147 @@ +import { Box, Button, Grid, Typography, Link as LinkMui } from "@mui/material"; +import PublicLayout from "components/PublicLayout"; +import TextInput from "components/TextInput"; +import { useAuthentication } from "context/authentication"; +import { type FC, useCallback } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { Link } from "react-router-dom"; +import { yupResolver } from "utils"; +import * as yup from "yup"; + +/** + * Sign in component + */ + +interface ISignIn { + email: string; + password: string; +} + +const validationSignIn: yup.ObjectSchema = yup.object().shape({ + email: yup.string().email().required(), + password: yup.string().required(), +}); + +export const SignInPage: FC = () => { + const { authenticate, authLoading } = useAuthentication(); + + const resolver = yupResolver(validationSignIn); + + const methods = useForm({ + reValidateMode: "onChange", + resolver, + }); + + const handleSubmit = useCallback( + async (data: ISignIn) => { + await authenticate(data.email, data.password); + }, + [authenticate], + ); + + return ( + + + + logo + + + + + Welcome Back + + + { + await handleSubmit(data); + })} + noValidate + sx={{ mt: 1 }} + > + + + + + + + + + Forgot password? + + + + + + + {"Don't have an account? Sign Up"} + + + + + + {"Copyright © "} + + Tauffer Consulting + + {" 2023."} + + + + + ); +}; + +export default SignInPage; diff --git a/frontend/src/features/auth/pages/signUp/signUpPage.tsx b/frontend/src/features/auth/pages/signUp/signUpPage.tsx new file mode 100644 index 00000000..a10f5962 --- /dev/null +++ b/frontend/src/features/auth/pages/signUp/signUpPage.tsx @@ -0,0 +1,157 @@ +import { + Box, + Button, + Grid, + Typography, + CircularProgress, + Link as LinkMui, +} from "@mui/material"; +import PublicLayout from "components/PublicLayout"; +import TextInput from "components/TextInput"; +import { useAuthentication } from "context/authentication"; +import { type FC, useCallback } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { Link } from "react-router-dom"; +import { yupResolver } from "utils"; +import * as yup from "yup"; + +/** + * Sign up component + * @TODO: differentiate more from the login page? + */ + +interface ISignUp { + email: string; + password: string; +} + +const validationSignUp: yup.ObjectSchema = yup.object().shape({ + email: yup.string().email().required(), + password: yup.string().required(), +}); + +export const SignUpPage: FC = () => { + const { register, authLoading } = useAuthentication(); + + const resolver = yupResolver(validationSignUp); + + const methods = useForm({ + reValidateMode: "onChange", + resolver, + }); + + const handleSubmit = useCallback( + async (data: ISignUp) => { + await register(data.email, data.password); + }, + [register], + ); + + return ( + + + + logo + + + + + Create an account + + + { + await handleSubmit(data); + })} + noValidate + sx={{ mt: 1 }} + > + + + + + + + + + Forgot password? + + + + + + + Do you have an account? Sign In + + + + + + {"Copyright © "} + + Tauffer Consulting + + {" 2023."} + + + + + ); +}; + +export default SignUpPage; diff --git a/frontend/src/features/workflowEditor/components/DrawerMenu/index.tsx b/frontend/src/features/workflowEditor/components/DrawerMenu/index.tsx new file mode 100644 index 00000000..d0538d28 --- /dev/null +++ b/frontend/src/features/workflowEditor/components/DrawerMenu/index.tsx @@ -0,0 +1,78 @@ +import { + ChevronLeft as ChevronLeftIcon, + ChevronRight as ChevronRightIcon, +} from "@mui/icons-material"; +import { + AppBar, + Box, + Divider, + IconButton, + ListItem, + Typography, + useTheme, +} from "@mui/material"; +import { + Drawer, + DrawerHeader, +} from "components/PrivateLayout/header/drawerMenu.style"; +import { type FC, type ReactNode, useState } from "react"; + +import SidebarAddNode from "./sidebarAddNode"; + +interface PermanentDrawerRightWorkflowsProps { + isOpen?: boolean; + handleClose: () => void; + children?: ReactNode; + sidePanel?: ReactNode; + setOrientation: React.Dispatch< + React.SetStateAction<"horizontal" | "vertical"> + >; + orientation: "vertical" | "horizontal"; +} + +export const PermanentDrawerRightWorkflows: FC< + PermanentDrawerRightWorkflowsProps +> = ({ setOrientation, orientation }) => { + const theme = useTheme(); + const [openDrawer, setOpenDrawer] = useState(true); + + return ( + + + + + {openDrawer && ( + + Pieces + + )} + { + setOpenDrawer(!openDrawer); + }} + edge="start" + > + {openDrawer ? : } + + + {openDrawer && ( + <> + + + + + + + + )} + + + + ); +}; diff --git a/frontend/src/features/workflowEditor/components/DrawerMenu/pieceDocsPopover.tsx b/frontend/src/features/workflowEditor/components/DrawerMenu/pieceDocsPopover.tsx new file mode 100644 index 00000000..2017bb86 --- /dev/null +++ b/frontend/src/features/workflowEditor/components/DrawerMenu/pieceDocsPopover.tsx @@ -0,0 +1,193 @@ +import CloseIcon from "@mui/icons-material/Close"; +import DragHandleIcon from "@mui/icons-material/DragHandle"; +import { Popover, IconButton, Typography } from "@mui/material"; +import React from "react"; +import Draggable from "react-draggable"; + +function renderPieceProperties( + piece: Piece, + key: "input_schema" | "output_schema" | "secrets_schema", +) { + const schema = piece[key]; + const properties = schema?.properties ?? {}; + return Object.entries(properties).map(([key, value]) => { + const argument = value; + let typeName: string = "allOf" in argument ? "enum" : argument.type; + let valuesOptions: string[] = []; + + if ("allOf" in argument && argument.allOf.length > 0) { + typeName = "enum"; + const typeClass = argument.allOf[0].$ref.split("/").pop() as string; + valuesOptions = (schema?.definitions?.[typeClass] as EnumDefinition).enum; + } + + return ( + + {key} [{typeName}] - {argument.description} + {valuesOptions && valuesOptions.length > 0 && ( + <> + {" Options: "} + {valuesOptions.join(", ")} + + )} + + ); + }); +} + +interface PieceDocsPopoverProps { + piece: Piece; + popoverOpen: boolean; + handlePopoverClose: ( + event: React.MouseEvent, + reason: any, + ) => void; +} + +const PieceDocsPopover: React.FC = ({ + piece, + popoverOpen, + handlePopoverClose, +}) => ( + + +
+
+ +
+ + {piece.name} + +
+ { + handlePopoverClose(event, "closeButtonClick"); + }} + > + + +
+
+
+ + {piece.description} + + + {piece.source_url ? ( + + source code + + ) : ( + No source code available + )} + + + Input + + {renderPieceProperties(piece, "input_schema")} + + Output + + {renderPieceProperties(piece, "output_schema")} + {piece.secrets_schema && ( + + Secrets + + )} + {piece.secrets_schema && renderPieceProperties(piece, "secrets_schema")} +
+
+
+); + +export default PieceDocsPopover; diff --git a/frontend/src/features/workflowEditor/components/DrawerMenu/sidebarAddNode.tsx b/frontend/src/features/workflowEditor/components/DrawerMenu/sidebarAddNode.tsx new file mode 100644 index 00000000..9278551a --- /dev/null +++ b/frontend/src/features/workflowEditor/components/DrawerMenu/sidebarAddNode.tsx @@ -0,0 +1,130 @@ +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import { + Accordion, + AccordionDetails, + AccordionSummary, + Alert, + Box, + ToggleButton, + ToggleButtonGroup, + Typography, +} from "@mui/material"; +import { useWorkflowsEditor } from "features/workflowEditor/context"; +import { type FC, useState } from "react"; + +import PiecesSidebarNode from "./sidebarNode"; + +/** + * @todo cleanup comments when no longer needed + * @todo move pieces rules to create workflow context + * @todo improve loading/error/empty states + */ + +interface Props { + setOrientation: React.Dispatch< + React.SetStateAction<"horizontal" | "vertical"> + >; + orientation: "vertical" | "horizontal"; +} + +const SidebarAddNode: FC = ({ setOrientation, orientation }) => { + const { repositories, repositoriesLoading, repositoryPieces } = + useWorkflowsEditor(); + + const [piecesMap, setPiecesMap] = useState>({}); + const [expandedRepos, setExpandedRepos] = useState([]); + + /** controls if an accordion is loading Pieces */ + const [loadingPieces, setLoadingPieces] = useState(false); + + return ( + + {repositoriesLoading && ( + Loading repositories... + )} + {!repositoriesLoading && ( + { + console.log("value", value); + if (value) setOrientation(value); + }} + > + + horizontal + + + vertical + + + )} + {!repositoriesLoading && + repositories.map((repo) => ( + { + if (loadingPieces) return; + setLoadingPieces(repo.id); + + // Check if the repo is currently expanded + const isExpanded = expandedRepos.includes(repo.id); + + // If the repo is already expanded, remove it from the expandedRepos array + // Otherwise, add it to the expandedRepos array + setExpandedRepos( + isExpanded + ? (prev) => prev.filter((id) => id !== repo.id) + : (prev) => [...prev, repo.id], + ); + + // If the repo is not currently expanded, load its pieces + if (!isExpanded) { + setPiecesMap((prev) => ({ + ...prev, + [repo.id]: repositoryPieces[repo.id], + })); + } + + setLoadingPieces(false); + }} + > + }> + + {repo.label} + + + + {!!loadingPieces && loadingPieces === repo.id && ( + Loading Pieces... + )} + {expandedRepos.includes(repo.id) && + piecesMap[repo.id]?.length && + piecesMap[repo.id].map((piece) => ( + + ))} + + + ))} + + ); +}; + +export default SidebarAddNode; diff --git a/frontend/src/features/workflowEditor/components/DrawerMenu/sidebarNode.tsx b/frontend/src/features/workflowEditor/components/DrawerMenu/sidebarNode.tsx new file mode 100644 index 00000000..bfdc8e53 --- /dev/null +++ b/frontend/src/features/workflowEditor/components/DrawerMenu/sidebarNode.tsx @@ -0,0 +1,73 @@ +import HelpIcon from "@mui/icons-material/Help"; +import { Box, Typography, IconButton } from "@mui/material"; +import React, { type FC, useState } from "react"; + +import PieceDocsPopover from "./pieceDocsPopover"; + +const PiecesSidebarNode: FC<{ piece: Piece }> = ({ piece }) => { + const [popoverOpen, setPopoverOpen] = useState(false); + + // Drag and drop from sidebar to Workflow area + const onDragStart = ( + event: React.DragEvent, + nodeData: any, + ) => { + const data = JSON.stringify(nodeData.nodeData); + event.dataTransfer.setData("application/reactflow", data); + event.dataTransfer.effectAllowed = "move"; + }; + + // Help popover + const handlePopoverOpen = () => { + setPopoverOpen(true); + }; + + const handlePopoverClose = ( + event: React.MouseEvent, + reason: any, + ) => { + if (reason && reason === "backdropClick") return; + setPopoverOpen(false); + }; + + return ( + { + onDragStart(event, { nodeData: piece }); + }} + draggable + > +
+ + {piece?.name ?? "-"} + + + + + +
+ + +
+ ); +}; + +export default PiecesSidebarNode; diff --git a/frontend/src/features/workflowEditor/components/SidebarForm/ContainerResourceForm.tsx b/frontend/src/features/workflowEditor/components/SidebarForm/ContainerResourceForm.tsx new file mode 100644 index 00000000..6bc4a237 --- /dev/null +++ b/frontend/src/features/workflowEditor/components/SidebarForm/ContainerResourceForm.tsx @@ -0,0 +1,129 @@ +import { Grid, Typography } from "@mui/material"; +import CheckboxInput from "components/CheckboxInput"; +import NumberInput from "components/NumberInput"; +import { type IContainerResourceFormData } from "features/workflowEditor/context/types"; +import React from "react"; +import * as yup from "yup"; + +// TODO check if these values make sense +const minAcceptedMemory = 128; +const minAcceptedCpu = 100; +const maxAcceptedMemory = 12800; +const maxAcceptedCpu = 10000; + +export const defaultContainerResources: IContainerResourceFormData = { + useGpu: false, + memory: { + min: 128, + max: 128, + }, + cpu: { + min: 100, + max: 100, + }, +}; + +export const ContainerResourceFormSchema: yup.ObjectSchema = + yup.object().shape({ + cpu: yup.object().shape({ + min: yup + .number() + .integer() + .typeError("Must must be a number") + .max(maxAcceptedCpu) + .min(minAcceptedCpu) + .required(), + max: yup + .number() + .integer() + .typeError("Must be a number") + .max(maxAcceptedCpu) + .when("min", ([min], schema) => schema.min(min)) + .required(), + }), + memory: yup.object().shape({ + min: yup + .number() + .integer() + .max(maxAcceptedMemory) + .min(minAcceptedMemory) + .required(), + max: yup + .number() + .integer() + .typeError("Must be a number") + .max(maxAcceptedMemory) + .when("min", ([min], schema) => schema.min(min)) + .required(), + }), + useGpu: yup.boolean().required(), + }); + +const ContainerResourceForm: React.FC = () => { + return ( + + + + Container Resources + + + + + + + + + + + + + + + + + + + ); +}; + +export default ContainerResourceForm; diff --git a/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/arrayInput.tsx b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/arrayInput.tsx new file mode 100644 index 00000000..7328cc54 --- /dev/null +++ b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/arrayInput.tsx @@ -0,0 +1,328 @@ +import AddIcon from "@mui/icons-material/Add"; +import DeleteIcon from "@mui/icons-material/Delete"; +import { Card, CardContent, IconButton, Grid } from "@mui/material"; +import CheckboxInput from "components/CheckboxInput"; +import CodeEditorInput from "components/CodeEditorInput"; +import DatetimeInput from "components/DatetimeInput"; +import NumberInput from "components/NumberInput"; +import SelectInput from "components/SelectInput"; +import TextInput from "components/TextInput"; +import { + type InputArray, + type IWorkflowPieceData, +} from "features/workflowEditor/context/types"; +import { getFromUpstream } from "features/workflowEditor/utils"; +import React, { useCallback, useMemo, useState } from "react"; +import { + type Control, + type FieldArrayWithId, + useFieldArray, + useWatch, +} from "react-hook-form"; + +import { type ArrayOption } from "../upstreamOptions"; + +import { disableCheckboxOptions } from "./disableCheckboxOptions"; +import ObjectInputComponent from "./objectInput"; +import SelectUpstreamInput from "./selectUpstreamInput"; + +interface ArrayInputItemProps { + formId: string; + inputKey: string; + schema: any; + control: Control; + definitions?: any; + upstreamOptions: ArrayOption; +} + +const ArrayInput: React.FC = ({ + inputKey, + schema, + upstreamOptions, + definitions, + control, +}) => { + const name: `inputs.${string}.value` = `inputs.${inputKey}.value`; + const { + fields: data, + append, + remove, + } = useFieldArray({ + name, + control, + }); + const fields = data as unknown as Array>; + const formsData = useWatch({ name }); + + const [enumOptions, setEnumOptions] = useState([]); + + const subItemSchema = useMemo(() => { + let subItemSchema: any = schema?.items; + if (schema?.items?.$ref) { + const subItemSchemaName = schema.items.$ref.split("/").pop(); + subItemSchema = definitions?.[subItemSchemaName]; + } + return subItemSchema; + }, [definitions, schema]); + + const disableUpstream = useMemo(() => { + return disableCheckboxOptions(subItemSchema); + }, [subItemSchema]); + + const isFromUpstream = useCallback( + (index: number) => { + return formsData?.[index]?.fromUpstream ?? false; + }, + [formsData], + ); + + const elementType = useMemo(() => { + if (subItemSchema?.allOf && subItemSchema.allOf.length > 0) { + const typeClass = subItemSchema.allOf[0].$ref.split("/").pop(); + const valuesOptions: string[] = definitions?.[typeClass].enum; + setEnumOptions(valuesOptions); + return "SelectInput"; + } else if (subItemSchema?.type === "number" && !subItemSchema?.format) { + return "NumberInput"; + } else if (subItemSchema?.type === "integer" && !subItemSchema?.format) { + return "NumberInputInt"; + } else if (subItemSchema?.type === "boolean" && !subItemSchema?.format) { + return "CheckboxInput"; + } else if ( + subItemSchema?.type === "string" && + !subItemSchema?.format && + !subItemSchema?.widget + ) { + return "TextInput"; + } else if ( + subItemSchema?.type === "string" && + subItemSchema?.format === "date" + ) { + return "DateInput"; + } else if ( + subItemSchema?.type === "string" && + subItemSchema?.format === "time" + ) { + return "TimeInput"; + } else if ( + subItemSchema?.type === "string" && + subItemSchema?.format === "date-time" + ) { + return "DatetimeInput"; + } else if ( + subItemSchema?.type === "string" && + subItemSchema?.widget === "codeeditor" + ) { + return "CodeEditorInput"; + } else if (subItemSchema?.type === "object") { + return "ObjectInput"; + } else { + return "Unknown"; + } + }, [subItemSchema, definitions]); + + const handleAddInput = useCallback(() => { + function empty(object: Record, fromUpstream = false) { + Object.keys(object).forEach(function (k) { + if (object[k] && typeof object[k] === "object") { + return empty(object[k]); + } + if (fromUpstream) { + object[k] = getFromUpstream(schema, definitions, k); + } else { + object[k] = ""; + } + }); + return object; + } + + const defaultValue = schema.default[0]; + const isObject = typeof defaultValue === "object"; + let defaultObj = { + fromUpstream: getFromUpstream(schema), + upstreamArgument: "", + upstreamId: "", + upstreamValue: "", + value: "", + } as unknown; + + if (isObject) { + const emptyObjValue = empty({ ...defaultValue }); + const emptyObjFromUpstream = empty({ ...defaultValue }, true); + defaultObj = { + fromUpstream: emptyObjFromUpstream, + upstreamArgument: emptyObjValue, + upstreamId: emptyObjValue, + upstreamValue: emptyObjValue, + value: defaultValue, + } as unknown; + } + + append([defaultObj] as any); + }, [append, definitions, schema]); + + return ( + +
+ + + + {schema?.title} +
+ + {fields?.map((fieldWithId, index) => { + const { id } = fieldWithId; + const fromUpstream = isFromUpstream(index); + return ( + + + { + remove(index); + }} + aria-label="Delete" + > + + + + + {fromUpstream && elementType !== "ObjectInput" && ( + + + + )} + {!fromUpstream && elementType === "SelectInput" && ( + + + + )} + {!fromUpstream && elementType === "NumberInput" && ( + + + + )} + {!fromUpstream && elementType === "NumberInputInt" && ( + + + + )} + {!fromUpstream && elementType === "CheckboxInput" && ( + + + + )} + {!fromUpstream && elementType === "TextInput" && ( + + + + )} + {!fromUpstream && elementType === "DateInput" && ( + + + + )} + {!fromUpstream && elementType === "TimeInput" && ( + + + + )} + {!fromUpstream && elementType === "DatetimeInput" && ( + + + + )} + {!fromUpstream && elementType === "CodeEditorInput" && ( + + + + )} + {!fromUpstream && elementType === "Unknown" && ( + +
+ Unknown widget type for {subItemSchema?.title} +
+
+ )} + + {elementType !== "ObjectInput" && ( + + + + )} + + {elementType === "ObjectInput" && ( + + + + )} +
+ ); + })} +
+
+ ); +}; + +export default React.memo(ArrayInput); diff --git a/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/disableCheckboxOptions.ts b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/disableCheckboxOptions.ts new file mode 100644 index 00000000..e3d7aa2e --- /dev/null +++ b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/disableCheckboxOptions.ts @@ -0,0 +1,30 @@ +function getFromUpstreamType(schema: InputSchemaProperty): FromUpstream { + if (schema?.from_upstream) { + return schema?.from_upstream; + } + + return "allowed"; +} + +export function disableCheckboxOptions(schema: InputSchemaProperty): boolean { + let disable: boolean = false; + const fromUpstream = getFromUpstreamType(schema); + + if (fromUpstream === "allowed") { + disable = false; + } + + if (fromUpstream === "always") { + disable = true; + } + + if (fromUpstream === "never") { + disable = true; + } + + if ("allOf" in schema && schema.allOf.length > 0) { + disable = true; + } + + return disable; +} diff --git a/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/index.tsx b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/index.tsx new file mode 100644 index 00000000..8dfff74c --- /dev/null +++ b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/index.tsx @@ -0,0 +1,204 @@ +import { Box, Grid } from "@mui/material"; +import CheckboxInput from "components/CheckboxInput"; +import CodeEditorInput from "components/CodeEditorInput"; +import DatetimeInput from "components/DatetimeInput"; +import NumberInput from "components/NumberInput"; +import SelectInput from "components/SelectInput"; +import TextInput from "components/TextInput"; +import { type IWorkflowPieceData } from "features/workflowEditor/context/types"; +import React, { useMemo } from "react"; +import { type Control, useWatch } from "react-hook-form"; + +import { type ArrayOption, type Option } from "../upstreamOptions"; + +import ArrayInput from "./arrayInput"; +import { disableCheckboxOptions } from "./disableCheckboxOptions"; +import SelectUpstreamInput from "./selectUpstreamInput"; + +interface PieceFormItemProps { + formId: string; + schema: InputSchemaProperty; + itemKey: string; + control: Control; + definitions?: Definitions; + upstreamOptions: Option[] | ArrayOption; +} + +const PieceFormItem: React.FC = ({ + formId, + upstreamOptions, + itemKey, + schema, + definitions, + control, +}) => { + const disableUpstream = useMemo(() => { + return disableCheckboxOptions(schema); + }, [schema]); + + const checkedFromUpstream = useWatch({ + name: `inputs.${itemKey}.fromUpstream`, + }); + + let inputElement: React.ReactNode = null; + + if (checkedFromUpstream) { + let options: Option[] = []; + if ("type" in schema && schema.type === "array") { + options = (upstreamOptions as ArrayOption).array; + } else { + options = upstreamOptions as Option[]; + } + + inputElement = ( + + ); + } else if ("allOf" in schema && schema.allOf.length > 0) { + const typeClass = schema.allOf[0].$ref.split("/").pop() as string; + let enumOptions: string[] = []; + + if (definitions?.[typeClass] && definitions[typeClass].type === "string") { + enumOptions = (definitions[typeClass] as EnumDefinition).enum; + } + + inputElement = ( + + label={itemKey} + emptyValue + name={`inputs.${itemKey}.value`} + options={enumOptions} + /> + ); + } else if ( + "type" in schema && + (schema.type === "number" || schema.type === "float") + ) { + inputElement = ( + + name={`inputs.${itemKey}.value`} + type="float" + label={schema.title} + defaultValue={schema?.default ?? 10.5} + /> + ); + } else if ("type" in schema && schema.type === "integer") { + inputElement = ( + + name={`inputs.${itemKey}.value`} + type="int" + label={schema.title} + defaultValue={schema?.default ?? 10} + /> + ); + } else if ("type" in schema && schema.type === "boolean") { + inputElement = ( + + name={`inputs.${itemKey}.value`} + label={schema.title} + /> + ); + } else if ("type" in schema && schema.type === "array") { + inputElement = ( + + ); + } else if ( + "type" in schema && + "format" in schema && + schema.type === "string" && + schema.format === "date" + ) { + inputElement = ( + + name={`inputs.${itemKey}.value`} + label={schema.title} + type="date" + /> + ); + } else if ( + "type" in schema && + "format" in schema && + schema.type === "string" && + schema.format === "time" + ) { + inputElement = ( + + name={`inputs.${itemKey}.value`} + label={schema.title} + type="time" + /> + ); + } else if ( + "type" in schema && + "format" in schema && + schema.type === "string" && + schema.format === "date-time" + ) { + inputElement = ( + + name={`inputs.${itemKey}.value`} + label={schema.title} + type="date-time" + /> + ); + } else if ( + "type" in schema && + "widget" in schema && + schema.type === "string" && + schema.widget === "codeeditor" + ) { + inputElement = ( + name={`inputs.${itemKey}.value`} /> + ); + } else if ( + "type" in schema && + !("format" in schema) && + schema.type === "string" + ) { + inputElement = ( + + variant="outlined" + name={`inputs.${itemKey}.value`} + label={schema.title} + /> + ); + } else { + inputElement = ( +
+ Unknown widget type for {schema.title} +
+ ); + } + + return ( + + + {inputElement} + + + + + + + ); +}; + +export default React.memo(PieceFormItem); diff --git a/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/objectInput.tsx b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/objectInput.tsx new file mode 100644 index 00000000..e52c2af8 --- /dev/null +++ b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/objectInput.tsx @@ -0,0 +1,131 @@ +import { Grid } from "@mui/material"; +import CheckboxInput from "components/CheckboxInput"; +import SelectInput from "components/SelectInput"; +import TextInput from "components/TextInput"; +import React, { useCallback, useMemo, useState } from "react"; +import { useWatch } from "react-hook-form"; +import { getDefinition } from "utils"; + +import { type Option } from "../upstreamOptions"; + +import { disableCheckboxOptions } from "./disableCheckboxOptions"; +import SelectUpstreamInput from "./selectUpstreamInput"; + +interface Prop { + name: `inputs.${string}.value.${number}`; + schema: ArrayObjectProperty; + definitions: Definitions; + upstreamOptions: Option[]; +} + +const ObjectInputComponent: React.FC = ({ + schema, + name, + upstreamOptions, + definitions, +}) => { + const formsData = useWatch({ name }); + + const itensSchema = useMemo(() => { + return (getDefinition(schema, definitions) as ObjectDefinition).properties; + }, [schema, definitions]); + + const [enumOptions, setEnumOptions] = useState([]); + + const isFromUpstream = useCallback( + (key: string) => { + return (formsData?.fromUpstream[key] ?? false) as boolean; + }, + [formsData], + ); + + const defaultValues = useMemo(() => { + const defaultValues = schema.default[0]; + + return (defaultValues ?? {}) as Record; + }, [schema]); + + const elementType = useMemo(() => { + const getElementType = function (key: string) { + const schemaDefinition = getDefinition(schema, definitions); + if ("properties" in schemaDefinition) { + const itemSchemaDefinition = getDefinition( + schemaDefinition.properties[key], + definitions, + ); + if ("enum" in itemSchemaDefinition) { + const valuesOptions = itemSchemaDefinition.enum; + setEnumOptions(valuesOptions); + return "SelectInput"; + } + return "TextInput"; + } else { + return "TextInput"; + } + }; + + return Object.keys(defaultValues).reduce>( + (acc, cur) => { + acc[cur] = getElementType(cur); + return acc; + }, + {}, + ); + }, [defaultValues, schema, definitions]); + + return ( + <> + {Object.entries(defaultValues).map(([key]) => { + const fromUpstream = isFromUpstream(key); + const disableUpstream = disableCheckboxOptions(itensSchema[key] as any); + return ( + + {fromUpstream ? ( + + + + ) : ( + + {elementType[key] === "TextInput" && ( + + )} + {elementType[key] === "SelectInput" && ( + + )} + + )} + + + + + ); + })} + + ); +}; + +export default ObjectInputComponent; diff --git a/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/selectUpstreamInput.tsx b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/selectUpstreamInput.tsx new file mode 100644 index 00000000..23b17da3 --- /dev/null +++ b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/PieceFormItem/selectUpstreamInput.tsx @@ -0,0 +1,110 @@ +import { + FormControl, + FormHelperText, + InputLabel, + MenuItem, + Select, + type SelectChangeEvent, +} from "@mui/material"; +import { type IWorkflowPieceData } from "features/workflowEditor/context/types"; +import React, { useCallback } from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import { fetchFromObject } from "utils"; + +import { type Option } from "../upstreamOptions"; + +type ObjectName = `inputs.${string}.value.${number}.upstreamValue.${string}`; +type Name = `inputs.${string}`; +type Props = + | { + label: string; + name: Name; + options: Option[]; + object?: false; + } + | { + label: string; + name: ObjectName; + options: Option[]; + object: true; + }; + +const SelectUpstreamInput: React.FC = ({ + options, + label, + name, + object, +}) => { + const { + setValue, + control, + formState: { errors }, + } = useFormContext(); + + const handleSelectChange = useCallback( + (event: SelectChangeEvent, onChange: (e: any) => void) => { + const value = event.target.value; + const upstream = options.find((op) => op?.value === value) as Option; + let nameArgument = ""; + let nameId = ""; + + if (object) { + nameArgument = name.replace(`.upstreamValue.`, ".upstreamArgument."); + nameId = name.replace(`.upstreamValue.`, ".upstreamId."); + } else { + nameArgument = `${name}.upstreamArgument`; + nameId = `${name}.upstreamId`; + } + + setValue( + nameArgument as `inputs.${string}.upstreamArgument`, + upstream.argument, + ); + setValue(nameId as `inputs.${string}.upstreamId`, upstream.id); + onChange(event); + }, + [name, object, options, setValue], + ); + + const error = fetchFromObject( + errors, + object ? name : `${name}.upstreamValue`, + ); + + return ( + + + {label} + + ( + + )} + /> + {error?.message} + + ); +}; + +export default React.memo(SelectUpstreamInput); diff --git a/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/index.tsx b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/index.tsx new file mode 100644 index 00000000..4ab53ed6 --- /dev/null +++ b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/index.tsx @@ -0,0 +1,70 @@ +import { useWorkflowsEditor } from "features/workflowEditor/context"; +import { type IWorkflowPieceData } from "features/workflowEditor/context/types"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useFormContext } from "react-hook-form"; + +import PieceFormItem from "./PieceFormItem"; +import { type UpstreamOptions, getUpstreamOptions } from "./upstreamOptions"; + +interface PieceFormProps { + formId: string; + schema: any; +} + +const PieceForm: React.FC = ({ formId, schema }) => { + const { fetchForageWorkflowEdges, getForageWorkflowPieces } = + useWorkflowsEditor(); + const { control } = useFormContext(); + const [upstreamOptions, setUpstreamOptions] = useState({}); + + const shouldRender = useMemo(() => { + return schema?.properties; + }, [schema]); + + const handleUpstreamOptions = useCallback(async () => { + if (!shouldRender) { + return; + } + + const workflowPieces = await getForageWorkflowPieces(); + const workflowEdges = await fetchForageWorkflowEdges(); + + const upstreamOptions = getUpstreamOptions( + formId, + schema, + workflowPieces, + workflowEdges, + ); + setUpstreamOptions(upstreamOptions); + }, [ + fetchForageWorkflowEdges, + formId, + getForageWorkflowPieces, + schema, + shouldRender, + ]); + + useEffect(() => { + void handleUpstreamOptions(); + }, [handleUpstreamOptions]); + + if (!shouldRender) return null; + return ( +
+ {Object.keys(schema.properties).map((key) => ( +
+ +
+ ))} +
+ ); +}; + +export default React.memo(PieceForm); diff --git a/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/upstreamOptions.ts b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/upstreamOptions.ts new file mode 100644 index 00000000..7e5fe3c8 --- /dev/null +++ b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/upstreamOptions.ts @@ -0,0 +1,106 @@ +import { generateTaskName, getUuidSlice } from "utils"; + +export interface Option { + id: string; + argument: string; + value: string; +} + +export interface ArrayOption { + array: Option[]; + items: Option[]; +} + +export type UpstreamOptions = Record; + +const getInputType = (schema: Record) => { + let type = schema.format ? schema.format : schema.type; + if ("allOf" in schema || "oneOf" in schema || "anyOf" in schema) { + type = "enum"; + } + return type === "number" ? "float" : (type as string); +}; + +const getOptions = ( + upstreamPieces: Record, + type: string, +): Option[] | ArrayOption => { + const options: Option[] = []; + + Object.keys(upstreamPieces).forEach((upstreamId) => { + const upPieces = upstreamPieces[upstreamId]; + + for (const upPiece of upPieces) { + const upSchema = upPiece.output_schema.properties; + + for (const property in upSchema) { + const upType = getInputType(upSchema[property]); + + if (upType === type || (upType === "string" && type === "object")) { + const value = `${upPiece?.name} (${getUuidSlice(upPiece.id)}) - ${ + upSchema[property].title + }`; + const upstreamArgument = property; + const taskName = generateTaskName(upPiece.name, upPiece.id); + options.push({ id: taskName, argument: upstreamArgument, value }); + } + } + } + }); + + return options; +}; + +export const getUpstreamOptions = ( + formId: string, + schema: any, + workflowPieces: any, + workflowEdges: any, +): UpstreamOptions => { + const upstreamPieces: Record = {}; + const upstreamOptions: UpstreamOptions = {}; + + for (const ed of workflowEdges) { + if (ed.target === formId) { + if (Array.isArray(upstreamPieces[formId])) { + upstreamPieces[formId].push({ + ...workflowPieces[ed.source], + id: ed.source, + }); + } else { + upstreamPieces[formId] = []; + upstreamPieces[formId].push({ + ...workflowPieces[ed.source], + id: ed.source, + }); + } + } + } + + Object.keys(schema.properties).forEach((key) => { + const currentSchema = schema.properties[key]; + const currentType = getInputType(currentSchema); + + if (currentType === "array") { + let itemsSchema = currentSchema?.items; + if (currentSchema?.items?.$ref) { + const subItemSchemaName = currentSchema.items.$ref.split("/").pop(); + itemsSchema = schema.definitions?.[subItemSchemaName]; + } + + const itemsType = getInputType(itemsSchema); + + const array = getOptions(upstreamPieces, currentType); + const items = getOptions(upstreamPieces, itemsType); + + // eslint-disable-next-line @typescript-eslint/consistent-type-assertions + upstreamOptions[key] = { array, items } as ArrayOption; + } else { + const options = getOptions(upstreamPieces, currentType); + + upstreamOptions[key] = options; + } + }); + + return upstreamOptions; +}; diff --git a/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/validation.ts b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/validation.ts new file mode 100644 index 00000000..4249994f --- /dev/null +++ b/frontend/src/features/workflowEditor/components/SidebarForm/PieceForm/validation.ts @@ -0,0 +1,195 @@ +import * as yup from "yup"; + +const defaultValidation = { + fromUpstream: yup.boolean(), // ? allowed | never | always + upstreamArgument: yup.string().when("fromUpstream", ([fromUpstream]) => { + if (fromUpstream) { + return yup.string().required(); + } + return yup.string(); + }), + upstreamId: yup.string().when("fromUpstream", ([fromUpstream]) => { + if (fromUpstream) { + return yup.string().required(); + } + return yup.string(); + }), + upstreamValue: yup.string().when("fromUpstream", ([fromUpstream]) => { + if (fromUpstream) { + return yup.string().required(); + } + return yup.string(); + }), +}; + +const validationObject = () => { + return yup.lazy((value) => { + const rawValidationObject = Object.entries( + value.fromUpstream, + ).reduce( + (objValidation, [key, fromUpstream]) => { + if (fromUpstream) { + objValidation.fromUpstream[key] = yup.boolean().required(); + objValidation.upstreamArgument[key] = yup.string().required(); + objValidation.upstreamId[key] = yup.string().required(); + objValidation.upstreamValue[key] = yup.string().required(); + objValidation.value[key] = yup.mixed().notRequired(); + } else { + objValidation.fromUpstream[key] = yup.boolean().required(); + objValidation.upstreamArgument[key] = yup.mixed().notRequired(); + objValidation.upstreamId[key] = yup.mixed().notRequired(); + objValidation.upstreamValue[key] = yup.mixed().notRequired(); + objValidation.value[key] = yup.string().required(); + } + + return objValidation; + }, + { + fromUpstream: {}, + upstreamArgument: {}, + upstreamId: {}, + upstreamValue: {}, + value: {}, + }, + ); + + const validationObject = Object.entries(rawValidationObject).reduce( + (acc, [key, obj]) => { + return { ...acc, [key]: yup.object(obj) }; + }, + {}, + ); + + return yup.object().shape(validationObject); + }); +}; + +function getValidationValueBySchemaType(schema: any) { + let inputSchema; + + if (schema.type === "number" && !schema.format) { + inputSchema = yup.object({ + ...defaultValidation, + value: yup.number().when("fromUpstream", ([fromUpstream]) => { + if (fromUpstream) { + return yup.mixed().notRequired(); + } + return yup.number().typeError("Must must be a number").required(); + }), + }); + } else if (schema.type === "integer" && !schema.format) { + inputSchema = yup.object({ + ...defaultValidation, + value: yup.number().when("fromUpstream", ([fromUpstream]) => { + if (fromUpstream) { + return yup.mixed().notRequired(); + } + return yup + .number() + .integer() + .typeError("Must must be a number") + .required(); + }), + }); + } else if (schema.type === "boolean" && !schema.format) { + inputSchema = yup.object({ + ...defaultValidation, + value: yup.boolean().when("fromUpstream", ([fromUpstream]) => { + if (fromUpstream) { + return yup.mixed().notRequired(); + } + return yup.boolean().required(); + }), + }); + } else if (schema.type === "string" && schema.format === "date") { + inputSchema = yup.object({ + ...defaultValidation, + value: yup.string().when("fromUpstream", ([fromUpstream]) => { + if (fromUpstream) { + return yup.mixed().notRequired(); + } + return yup.string().required(); + }), + }); + } else if (schema.type === "string" && schema?.format === "time") { + inputSchema = yup.object({ + ...defaultValidation, + value: yup.string().when("fromUpstream", ([fromUpstream]) => { + if (fromUpstream) { + return yup.mixed().notRequired(); + } + return yup.string().required(); + }), + }); + } else if (schema.type === "string" && schema?.format === "date-time") { + inputSchema = yup.object({ + ...defaultValidation, + value: yup.string().when("fromUpstream", ([fromUpstream]) => { + if (fromUpstream) { + return yup.mixed().notRequired(); + } + return yup.string().required(); + }), + }); + } else if (schema.type === "string" && schema?.widget === "codeeditor") { + inputSchema = yup.object({ + ...defaultValidation, + value: yup.string().when("fromUpstream", ([fromUpstream]) => { + if (fromUpstream) { + return yup.mixed().notRequired(); + } + return yup.string(); + }), + }); + } else if (schema.type === "string" && !schema.format) { + inputSchema = yup.object({ + ...defaultValidation, + value: yup.string().when("fromUpstream", ([fromUpstream]) => { + if (fromUpstream) { + return yup.mixed().notRequired(); + } + return yup.string().required(); + }), + }); + } else if (schema.type === "object") { + inputSchema = validationObject(); + } else { + inputSchema = yup.mixed().notRequired(); + } + + return inputSchema; +} + +export function createInputsSchemaValidation(schema: any) { + if (!schema?.properties) { + return yup.mixed().notRequired(); + } + + const validationSchema = Object.entries(schema.properties).reduce( + (acc, cur: [string, any]) => { + const [key, subSchema] = cur; + let inputSchema; + + if (subSchema.type === "array") { + let subItemSchema: any = subSchema?.items; + if (subSchema?.items?.$ref) { + const subItemSchemaName = subSchema.items.$ref.split("/").pop(); + subItemSchema = schema.definitions?.[subItemSchemaName]; + } + inputSchema = yup.object({ + ...defaultValidation, + value: yup + .array() + .of(getValidationValueBySchemaType(subItemSchema) as any), + }); + } else { + inputSchema = getValidationValueBySchemaType(subSchema); + } + + return { ...acc, [key]: inputSchema }; + }, + {}, + ); + + return yup.object().shape(validationSchema); +} diff --git a/frontend/src/features/workflowEditor/components/SidebarForm/StorageForm.tsx b/frontend/src/features/workflowEditor/components/SidebarForm/StorageForm.tsx new file mode 100644 index 00000000..7e022112 --- /dev/null +++ b/frontend/src/features/workflowEditor/components/SidebarForm/StorageForm.tsx @@ -0,0 +1,75 @@ +import { + FormControl, + FormHelperText, + Grid, + InputLabel, + MenuItem, + Select, + Typography, +} from "@mui/material"; +import { + type IStorageFormData, + type IWorkflowPieceData, + type StorageAccessModes, + storageAccessModes, +} from "features/workflowEditor/context/types"; +import React from "react"; +import { Controller, useFormContext } from "react-hook-form"; +import * as yup from "yup"; + +export const storageFormSchema = yup.object().shape({ + storageAccessMode: yup + .mixed() + .oneOf(Object.values(storageAccessModes)) + .required(), +}); + +export const defaultStorage: IStorageFormData = { + storageAccessMode: storageAccessModes.None, +}; + +const StorageForm: React.FC = () => { + const { formState, control } = useFormContext(); + + return ( + + + + Storage + + + + + Storage Access Mode + ( + + )} + /> + + + {formState.errors.storage?.storageAccessMode?.message} + + + + + ); +}; + +export default StorageForm; diff --git a/frontend/src/features/workflowEditor/components/SidebarForm/index.tsx b/frontend/src/features/workflowEditor/components/SidebarForm/index.tsx new file mode 100644 index 00000000..c47d304b --- /dev/null +++ b/frontend/src/features/workflowEditor/components/SidebarForm/index.tsx @@ -0,0 +1,262 @@ +import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; +import { + Drawer, + Grid, + Typography, + Accordion, + AccordionSummary, + AccordionDetails, +} from "@mui/material"; +import { useWorkflowsEditor } from "features/workflowEditor/context"; +import { type IWorkflowPieceData } from "features/workflowEditor/context/types"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { yupResolver } from "utils"; +import * as yup from "yup"; + +import ContainerResourceForm, { + ContainerResourceFormSchema, +} from "./ContainerResourceForm"; +import PieceForm from "./PieceForm"; +import { createInputsSchemaValidation } from "./PieceForm/validation"; +import StorageForm, { storageFormSchema } from "./StorageForm"; + +interface ISidebarPieceFormProps { + formId: string; + schema: PieceSchema; + title: string; + open: boolean; + onClose: (event: any) => void; +} + +const SidebarPieceForm: React.FC = (props) => { + const { schema, formId, open, onClose, title } = props; + + const { + setForageWorkflowPiecesData, + fetchForageWorkflowPiecesDataById, + setForageWorkflowPiecesOutputSchema, + clearDownstreamDataById, + } = useWorkflowsEditor(); + + const SidebarPieceFormSchema = useMemo(() => { + return yup.object().shape({ + storage: storageFormSchema, + containerResources: ContainerResourceFormSchema, + inputs: createInputsSchemaValidation(schema), + }); + }, [schema]); + + const resolver = yupResolver(SidebarPieceFormSchema); + + const [formLoaded, setFormLoaded] = useState(false); + + const methods = useForm({ + resolver, + mode: "onChange", + }); + const { trigger, reset } = methods; + const data = methods.watch(); + + const loadData = useCallback(async () => { + setFormLoaded(false); + const data = await fetchForageWorkflowPiecesDataById(formId); + if (data) { + reset(data); // put forage data on form if exist + } else { + reset(); + } + void trigger(); + setFormLoaded(true); + }, [formId, fetchForageWorkflowPiecesDataById, reset, trigger]); + + const updateOutputSchema = useCallback(async () => { + if (schema?.properties) { + const outputSchemaProperty = Object.keys(schema.properties).find( + (key) => { + const inputSchema = schema.properties[key]; + return ( + "items" in inputSchema && + "$ref" in inputSchema.items && + inputSchema.items.$ref === "#/definitions/OutputModifierModel" + ); + }, + ); + + if (outputSchemaProperty && data?.inputs?.[outputSchemaProperty]?.value) { + const formsData = data.inputs[outputSchemaProperty].value; + const newProperties = formsData.reduce( + ( + acc: any, + cur: { value: { type: string; name: string; description: any } }, + ) => { + let defaultValue: any = ""; + let newProperties = {}; + + if (cur.value.type === "integer") { + defaultValue = 1; + } else if (cur.value.type === "float") { + defaultValue = 1.1; + } else if (cur.value.type === "boolean") { + defaultValue = false; + } + + if (cur.value.type === "array") { + newProperties = { + [cur.value.name]: { + items: { + type: "string", + }, + description: cur.value.description, + title: cur.value.name, + type: cur.value.type, + }, + }; + } else { + newProperties = { + [cur.value.name]: { + default: defaultValue, + description: cur.value.description, + title: cur.value.name, + type: cur.value.type, + }, + }; + } + return { ...acc, ...newProperties }; + }, + {}, + ); + + await setForageWorkflowPiecesOutputSchema(formId, newProperties); + await clearDownstreamDataById(formId); + } + } + }, [ + schema, + data.inputs, + setForageWorkflowPiecesOutputSchema, + formId, + clearDownstreamDataById, + ]); + + const saveData = useCallback(async () => { + if (formId && open) { + await setForageWorkflowPiecesData(formId, data as IWorkflowPieceData); + await updateOutputSchema(); + } + }, [formId, open, setForageWorkflowPiecesData, data, updateOutputSchema]); + + // load forage + useEffect(() => { + if (open) { + void loadData(); + } else { + reset(); + } + }, [open, reset, loadData]); + + // save on forage + useEffect(() => { + void saveData(); + }, [saveData]); + + return ( + +
+ + {title} + + + +
+ + + + Input Arguments + + + + + Upstream + + + + + + {formLoaded && ( + + + + + + +
+ + + }> + + Advanced Options + + + + +
+ + + + + + )} + +
+ +
+
+ + ); +}; + +export default SidebarPieceForm; diff --git a/frontend/src/features/workflowEditor/components/SidebarSettingsForm/index.tsx b/frontend/src/features/workflowEditor/components/SidebarSettingsForm/index.tsx new file mode 100644 index 00000000..1c6a29b5 --- /dev/null +++ b/frontend/src/features/workflowEditor/components/SidebarSettingsForm/index.tsx @@ -0,0 +1,280 @@ +import { Drawer, Grid, Typography, TextField } from "@mui/material"; +import DatetimeInput from "components/DatetimeInput"; +import SelectInput from "components/SelectInput"; +import TextInput from "components/TextInput"; +import dayjs from "dayjs"; +import { useWorkflowsEditor } from "features/workflowEditor/context"; +import { + type EndDateTypes, + type IWorkflowSettings, + type ScheduleIntervals, + type StorageSourcesAWS, + type StorageSourcesLocal, + endDateTypes, + scheduleIntervals, + storageSourcesAWS, + storageSourcesLocal, +} from "features/workflowEditor/context/types"; +import { useCallback, useEffect, useState } from "react"; +import { FormProvider, useForm } from "react-hook-form"; +import { yupResolver } from "utils"; +import * as yup from "yup"; + +interface ISidebarSettingsFormProps { + open: boolean; + onClose: (event: any) => void; +} + +const defaultSettingsData: IWorkflowSettings = { + config: { + name: "", + scheduleInterval: scheduleIntervals.None, + startDate: dayjs(new Date()).toISOString(), + endDateType: endDateTypes.Never, + }, + storage: { + storageSource: storageSourcesLocal.None, + baseFolder: "", + bucket: "", + }, +}; + +const storageSourceOptions = + import.meta.env.DOMINO_DEPLOY_MODE === "local-compose" + ? [ + { + label: "None", + value: "None", + }, + { + label: "Local", + value: "Local", + }, + ] + : [ + { + label: "None", + value: "None", + }, + { + label: "AWS S3", + value: "AWS S3", + }, + ]; + +type ValidationSchema = yup.ObjectSchema; + +// TODO check yup validation +export const WorkflowSettingsFormSchema: ValidationSchema = yup.object().shape({ + config: yup.object().shape({ + name: yup + .string() + .matches(/^[\w]*$/, "Name can only have letters and numbers.") + .required(), + scheduleInterval: yup + .mixed() + .oneOf(Object.values(scheduleIntervals)) + .required(), + startDate: yup.string().required(), + endDate: yup.string(), + endDateType: yup + .mixed() + .oneOf(Object.values(endDateTypes)) + .required(), + }), + storage: yup.object().shape({ + storageSource: yup.lazy((value) => { + if (value === storageSourcesAWS.AWS_S3) { + return yup + .mixed() + .oneOf(Object.values(storageSourcesAWS)) + .required(); + } + return yup + .mixed() + .oneOf(Object.values(storageSourcesLocal)) + .required(); + }), + baseFolder: yup.string(), + bucket: yup.string(), + }), +}); + +const SidebarSettingsForm = (props: ISidebarSettingsFormProps) => { + const { open, onClose } = props; + + const { fetchWorkflowSettingsData, setWorkflowSettingsData } = + useWorkflowsEditor(); + + const resolver = yupResolver(WorkflowSettingsFormSchema); + const methods = useForm({ mode: "onChange", resolver }); + const { register, watch, reset, trigger, getValues } = methods; + const formData = watch(); + + const [loaded, setLoaded] = useState(false); + + const validate = useCallback(() => { + if (loaded) void trigger(); + }, [loaded, trigger]); + + useEffect(() => { + validate(); + }, [validate]); + + const loadData = useCallback(async () => { + const data = await fetchWorkflowSettingsData(); + if (Object.keys(data).length === 0) { + reset(defaultSettingsData); + } else { + reset(data); + } + setLoaded(true); + }, [reset, fetchWorkflowSettingsData]); + + const saveData = useCallback(async () => { + if (open) { + await setWorkflowSettingsData(formData); + } + }, [formData, open, setWorkflowSettingsData]); + + useEffect(() => { + if (open) { + void loadData(); + } + }, [open, loadData]); + + useEffect(() => { + void saveData(); + }, [saveData]); + + if (Object.keys(formData).length === 0) { + return null; + } + + return ( + + + + + Settings + + + + + + + + + + + + + + + + + {getValues().config.endDateType === + endDateTypes.UserDefined && ( + + + + )} + + + + + + + + Storage + + + + + + + + {formData.storage.storageSource === storageSourcesAWS.AWS_S3 ? ( + <> + + + + + + + + ) : null} + + + + + + ); +}; +export default SidebarSettingsForm; diff --git a/frontend/src/features/workflowEditor/components/WorkflowEditor.tsx b/frontend/src/features/workflowEditor/components/WorkflowEditor.tsx new file mode 100644 index 00000000..7673001a --- /dev/null +++ b/frontend/src/features/workflowEditor/components/WorkflowEditor.tsx @@ -0,0 +1,398 @@ +import { Settings as SettingsSuggestIcon } from "@mui/icons-material"; +import ClearIcon from "@mui/icons-material/Clear"; +import DownloadIcon from "@mui/icons-material/Download"; +import SaveIcon from "@mui/icons-material/Save"; +import { Button, Grid, Paper } from "@mui/material"; +import { AxiosError } from "axios"; +import Loading from "components/Loading"; +import { + type WorkflowPanelRef, + WorkflowPanel, + type DefaultNode, +} from "components/WorkflowPanel"; +import { useWorkspaces } from "context/workspaces"; +import { useWorkflowsEditor } from "features/workflowEditor/context"; +import { type DragEvent, useCallback, useRef, useState } from "react"; +import { toast } from "react-toastify"; +import { type Edge, type Node, type XYPosition } from "reactflow"; +import { yupResolver, useInterval } from "utils"; +import { v4 as uuidv4 } from "uuid"; +import * as yup from "yup"; + +import { type IWorkflowPieceData, storageAccessModes } from "../context/types"; +import { containerResourcesSchema } from "../schemas/containerResourcesSchemas"; +import { extractDefaultInputValues, extractDefaultValues } from "../utils"; + +import { PermanentDrawerRightWorkflows } from "./DrawerMenu"; +import SidebarPieceForm from "./SidebarForm"; +import { ContainerResourceFormSchema } from "./SidebarForm/ContainerResourceForm"; +import { createInputsSchemaValidation } from "./SidebarForm/PieceForm/validation"; +import { storageFormSchema } from "./SidebarForm/StorageForm"; +import SidebarSettingsForm, { + WorkflowSettingsFormSchema, +} from "./SidebarSettingsForm"; + +/** + * Create workflow tab + // TODO refactor/simplify inner files + // TODO handle runtime errors + // TODO make it look good + */ +const getId = (module_name: string) => { + return `${module_name}_${uuidv4()}`; +}; + +export const WorkflowsEditorComponent: React.FC = () => { + const workflowPanelRef = useRef(null); + const [sidebarSettingsDrawer, setSidebarSettingsDrawer] = useState(false); + const [sidebarPieceDrawer, setSidebarPieceDrawer] = useState(false); + const [formId, setFormId] = useState(""); + const [formTitle, setFormTitle] = useState(""); + const [formSchema, setFormSchema] = useState({}); + const [menuOpen, setMenuOpen] = useState(false); + const [loading, setBackdropIsOpen] = useState(false); + const [orientation, setOrientation] = useState<"horizontal" | "vertical">( + "horizontal", + ); + + const { workspace } = useWorkspaces(); + + const saveDataToLocalForage = useCallback(async () => { + if (workflowPanelRef?.current) { + await Promise.allSettled([ + setWorkflowEdges(workflowPanelRef.current.edges ?? []), + setWorkflowNodes(workflowPanelRef.current.nodes ?? []), + ]); + } + }, [workflowPanelRef.current]); + + useInterval(saveDataToLocalForage, 3000); + + const { + clearForageData, + workflowsEditorBodyFromFlowchart, + fetchWorkflowForage, + handleCreateWorkflow, + fetchForagePieceById, + fetchForageWorkflowNodes, + fetchForageWorkflowEdges, + setForageWorkflowPieces, + getForageWorkflowPieces, + removeForageWorkflowPiecesById, + removeForageWorkflowPieceDataById, + fetchWorkflowPieceById, + setForageWorkflowPiecesData, + clearDownstreamDataById, + setWorkflowEdges, + setWorkflowNodes, + } = useWorkflowsEditor(); + + const validateWorkflowSettings = useCallback(async (payload: any) => { + const resolver = yupResolver(WorkflowSettingsFormSchema); + const validatedData = await resolver(payload.workflowSettingsData); + if (!Object.keys(validatedData.errors).length) { + // do something + } else { + throw new Error("Please review your workflow settings."); + } + }, []); + + const validateWorkflowPiecesData = useCallback( + async (payload: any) => { + const validationSchema = yup.object().shape( + Object.entries(payload.workflowPieces).reduce((acc, [key, value]) => { + return { + [key]: yup.object({ + storage: storageFormSchema, + containerResources: ContainerResourceFormSchema, + inputs: createInputsSchemaValidation((value as any).input_schema), + }), + ...acc, + }; + }, {}), + ) as any; + + const resolver = yupResolver(validationSchema); + + const validatedData = await resolver(payload.workflowPiecesData); + + if (!Object.keys(validatedData.errors).length) { + workflowPanelRef?.current?.setNodes((nodes) => + nodes.map((n) => { + n = { ...n, data: { ...n.data, validationError: false } }; + return n; + }), + ); + } else { + const nodeIds = Object.keys(validatedData.errors); + workflowPanelRef?.current?.setNodes((nodes) => [ + ...nodes.map((n) => { + if (nodeIds.includes(n.id)) { + n = { ...n, data: { ...n.data, validationError: true } }; + } + + return n; + }), + ]); + + throw new Error("Please review the errors on your workflow."); + } + }, + [workflowPanelRef], + ); + + const handleSaveWorkflow = useCallback(async () => { + try { + await saveDataToLocalForage(); + setBackdropIsOpen(true); + if (!workspace?.id) { + throw new Error("No selected Workspace"); + } + const payload = await fetchWorkflowForage(); + + await validateWorkflowPiecesData(payload); + await validateWorkflowSettings(payload); + + const data = await workflowsEditorBodyFromFlowchart(); + + await handleCreateWorkflow({ workspace_id: workspace?.id, ...data }); + + toast.success("Workflow created successfully."); + setBackdropIsOpen(false); + } catch (err) { + setBackdropIsOpen(false); + if (err instanceof AxiosError) { + toast.error(JSON.stringify(err?.response?.data)); + } else if (err instanceof Error) { + console.log(err); + toast.error( + "Error while creating workflow, check your workflow settings and tasks.", + ); + } + } + }, [ + fetchWorkflowForage, + handleCreateWorkflow, + validateWorkflowPiecesData, + validateWorkflowSettings, + workflowsEditorBodyFromFlowchart, + workspace?.id, + ]); + + const handleClear = useCallback(async () => { + await clearForageData(); + workflowPanelRef.current?.setEdges([]); + workflowPanelRef.current?.setNodes([]); + }, [clearForageData]); + + const onNodesDelete = useCallback( + async (nodes: any) => { + for (const node of nodes) { + await removeForageWorkflowPiecesById(node.id); + await removeForageWorkflowPieceDataById(node.id); + } + }, + [removeForageWorkflowPieceDataById, removeForageWorkflowPiecesById], + ); + + const onEdgesDelete = useCallback( + async (edges: Edge[]) => { + for (const edge of edges) { + await clearDownstreamDataById(edge.source); + } + }, + [clearDownstreamDataById], + ); + + // Node double click open drawer with forms + const onNodeDoubleClick = useCallback( + async (_e: any, node: Node) => { + const pieceNode = await fetchWorkflowPieceById(node.id); + setFormSchema(pieceNode?.input_schema); + setFormId(node.id); + setFormTitle(() => { + return pieceNode?.name ? pieceNode.name : ""; + }); + setSidebarPieceDrawer(true); + }, + [fetchWorkflowPieceById], + ); + + const onLoad = useCallback(async () => { + // // Fetch old state from forage to avoid loosing flowchart when refreshing/leaving page + const workflowNodes = await fetchForageWorkflowNodes(); + const workflowEdges = await fetchForageWorkflowEdges(); + + return { nodes: workflowNodes, edges: workflowEdges }; + }, [fetchForageWorkflowNodes, fetchForageWorkflowEdges]); + + const onDrop = useCallback( + async (event: DragEvent, position: XYPosition) => { + event.preventDefault(); + const nodeData = event.dataTransfer.getData("application/reactflow"); + const { ...data } = JSON.parse(nodeData); + + const newNodeData: DefaultNode["data"] = { + name: data.name, + style: data.style, + validationError: false, + orientation, + }; + + const newNode = { + id: getId(data.id), + type: "CustomNode", + position, + data: newNodeData, + }; + + const piece = await fetchForagePieceById(data.id); + const defaultInputs = extractDefaultInputValues( + piece as unknown as Piece, + ); + const defaultContainerResources = extractDefaultValues( + containerResourcesSchema as any, + ); + + const currentWorkflowPieces = await getForageWorkflowPieces(); + const newWorkflowPieces = { + ...currentWorkflowPieces, + [newNode.id]: piece, + }; + await setForageWorkflowPieces(newWorkflowPieces); + + const defaultWorkflowPieceData: IWorkflowPieceData = { + storage: { storageAccessMode: storageAccessModes.ReadWrite }, + containerResources: defaultContainerResources, + inputs: defaultInputs, + }; + + await setForageWorkflowPiecesData(newNode.id, defaultWorkflowPieceData); + return newNode; + }, + [ + orientation, + fetchForagePieceById, + setForageWorkflowPieces, + getForageWorkflowPieces, + setForageWorkflowPiecesData, + ], + ); + + // Left drawers controls + const toggleSidebarPieceDrawer = (open: boolean) => (event: any) => { + if ( + event.type === "keydown" && + (event.key === "Tab" || event.key === "Shift") + ) { + return; + } + setSidebarPieceDrawer(open); + }; + + const toggleSidebarSettingsDrawer = (open: boolean) => (event: any) => { + if ( + event.type === "keydown" && + (event.key === "Tab" || event.key === "Shift") + ) { + return; + } + setSidebarSettingsDrawer(open); + }; + + return ( + <> + {loading && } + + + + + + + + + + + + + + + + + + + + + + { + setMenuOpen(!menuOpen); + }} + /> + + + + + + ); +}; diff --git a/frontend/src/features/workflowEditor/context/index.tsx b/frontend/src/features/workflowEditor/context/index.tsx new file mode 100644 index 00000000..4844df27 --- /dev/null +++ b/frontend/src/features/workflowEditor/context/index.tsx @@ -0,0 +1,32 @@ +import PiecesProvider from "./pieces"; +import ReactWorkflowPersistenceProvider from "./reactWorkflowPersistence"; +import WorkflowPiecesProvider from "./workflowPieces"; +import WorkflowPiecesDataProvider from "./workflowPiecesData"; +import WorkflowsEditorProviderItem, { + useWorkflowsEditor, +} from "./workflowsEditor"; +import WorkflowSettingsDataProvider from "./workflowSettingsData"; + +export { useWorkflowsEditor }; + +const WorkflowsEditorProviderWrapper: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { + return ( + + + + + + + {children} + + + + + + + ); +}; + +export default WorkflowsEditorProviderWrapper; diff --git a/frontend/src/features/workflowEditor/context/pieces.tsx b/frontend/src/features/workflowEditor/context/pieces.tsx new file mode 100644 index 00000000..c2af1864 --- /dev/null +++ b/frontend/src/features/workflowEditor/context/pieces.tsx @@ -0,0 +1,104 @@ +import { + type IGetRepoPiecesResponseInterface, + useAuthenticatedGetPieceRepositories, + useFetchAuthenticatedGetRepoIdPieces, +} from "features/workflows/api"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { toast } from "react-toastify"; +import localForage from "services/config/localForage.config"; +import { createCustomContext } from "utils"; + +export interface IPiecesContext { + repositories: PieceRepository[]; + repositoriesError: boolean; + repositoriesLoading: boolean; + repositoryPieces: PiecesRepository; + + search: string; + handleSearch: (word: string) => void; + + fetchRepoById: (params: { + id: string; + }) => Promise; + fetchForagePieceById: (id: number) => Promise; +} + +export const [PiecesContext, usesPieces] = + createCustomContext("Pieces Context"); + +const PiecesProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const [search, handleSearch] = useState(""); + const [repositoryPieces, setRepositoryPieces] = useState( + {}, + ); + + const fetchRepoById = useFetchAuthenticatedGetRepoIdPieces(); + + const { + data, + error: repositoriesError, + isValidating: repositoriesLoading, + // mutate: repositoriesRefresh + } = useAuthenticatedGetPieceRepositories({}); + + const repositories: PieceRepository[] = useMemo( + () => data?.data.filter((repo) => repo.name.includes(search)) ?? [], + [data, search], + ); + + const fetchForagePieceById = useCallback(async (id: number) => { + const pieces = await localForage.getItem("pieces"); + if (pieces !== null) { + return pieces[id]; + } + }, []); + + useEffect(() => { + const updateRepositoriesPieces = async () => { + const repositoryPiecesAux: PiecesRepository = {}; + const foragePieces: PieceForageSchema = {}; + for (const repo of repositories) { + fetchRepoById({ id: repo.id }) + .then((pieces: any) => { + repositoryPiecesAux[repo.id] = []; + for (const op of pieces) { + repositoryPiecesAux[repo.id].push(op); + foragePieces[op.id] = op; + } + setRepositoryPieces(repositoryPiecesAux); + void localForage.setItem("pieces", foragePieces); + }) + .catch((e) => { + console.log(e); + }); + // Set piece item to storage -> {piece_id: Piece} + } + }; + void updateRepositoriesPieces(); + }, [repositories, fetchRepoById]); + + useEffect(() => { + if (repositoriesError) { + toast.error("Error loading repositories, try again later"); + } + }, [repositoriesError]); + + const value: IPiecesContext = { + fetchForagePieceById, + fetchRepoById, + handleSearch, + repositories, + repositoriesError, + repositoriesLoading, + repositoryPieces, + search, + }; + + return ( + {children} + ); +}; + +export default PiecesProvider; diff --git a/frontend/src/features/workflowEditor/context/reactWorkflowPersistence.tsx b/frontend/src/features/workflowEditor/context/reactWorkflowPersistence.tsx new file mode 100644 index 00000000..3f12d1ba --- /dev/null +++ b/frontend/src/features/workflowEditor/context/reactWorkflowPersistence.tsx @@ -0,0 +1,65 @@ +import { type IWorkflowElement } from "features/workflows/types"; +import React, { useCallback } from "react"; +import { type Node, type Edge } from "reactflow"; +import localForage from "services/config/localForage.config"; +import { createCustomContext } from "utils"; + +export interface IReactWorkflowPersistenceContext { + setWorkflowEdges: (edges: Edge[]) => Promise; + setWorkflowNodes: (edges: Node[]) => Promise; + fetchForageWorkflowEdges: () => Promise; + fetchForageWorkflowNodes: () => Promise; + clearReactWorkflowPersistence: () => Promise; +} + +export const [ReactWorkflowPersistenceContext, useReactWorkflowPersistence] = + createCustomContext( + "ReactWorkflowPersistence Context", + ); + +const ReactWorkflowPersistenceProvider: React.FC<{ + children: React.ReactNode; +}> = ({ children }) => { + const setWorkflowEdges = useCallback(async (edges: Edge[]) => { + await localForage.setItem("workflowEdges", edges); + }, []); + const setWorkflowNodes = useCallback(async (nodes: Node[]) => { + await localForage.setItem("workflowNodes", nodes); + }, []); + + const fetchForageWorkflowEdges = useCallback(async () => { + let workflowEdges = await localForage.getItem("workflowEdges"); + if (!workflowEdges || workflowEdges.length === 0) { + workflowEdges = []; + } + return workflowEdges; + }, []); + const fetchForageWorkflowNodes = useCallback(async () => { + let workflowEdges = await localForage.getItem("workflowNodes"); + if (!workflowEdges || workflowEdges.length === 0) { + workflowEdges = []; + } + return workflowEdges; + }, []); + + const clearReactWorkflowPersistence = useCallback(async () => { + await localForage.setItem("workflowEdges", []); + await localForage.setItem("workflowNodes", []); + }, []); + + const value: IReactWorkflowPersistenceContext = { + setWorkflowEdges, + setWorkflowNodes, + fetchForageWorkflowEdges, + fetchForageWorkflowNodes, + clearReactWorkflowPersistence, + }; + + return ( + + {children} + + ); +}; + +export default ReactWorkflowPersistenceProvider; diff --git a/frontend/src/features/workflowEditor/context/types/containerResources.ts b/frontend/src/features/workflowEditor/context/types/containerResources.ts new file mode 100644 index 00000000..5a162d69 --- /dev/null +++ b/frontend/src/features/workflowEditor/context/types/containerResources.ts @@ -0,0 +1,11 @@ +export interface IContainerResourceFormData { + useGpu: boolean; + cpu: { + min: number; + max: number; + }; + memory: { + min: number; + max: number; + }; +} diff --git a/frontend/src/features/workflowEditor/context/types/index.ts b/frontend/src/features/workflowEditor/context/types/index.ts new file mode 100644 index 00000000..9808678a --- /dev/null +++ b/frontend/src/features/workflowEditor/context/types/index.ts @@ -0,0 +1,5 @@ +export * from "./containerResources"; +export * from "./storage"; +export * from "./workflowPieceData"; +export * from "./input"; +export * from "./settings"; diff --git a/frontend/src/features/workflowEditor/context/types/input.ts b/frontend/src/features/workflowEditor/context/types/input.ts new file mode 100644 index 00000000..2edb4035 --- /dev/null +++ b/frontend/src/features/workflowEditor/context/types/input.ts @@ -0,0 +1,24 @@ +import { type Dayjs } from "dayjs"; + +type Value = string | number | boolean | Dayjs; +interface BaseInput { + fromUpstream: boolean; // ? allowed | never | always + upstreamArgument: string; + upstreamId: string; + upstreamValue: string; + value: Value; +} + +export interface ObjectInput { + fromUpstream: Record; + upstreamArgument: Record; + upstreamId: Record; + upstreamValue: Record; + value: Record; +} + +export type InputArray = BaseInput & { + value: BaseInput[] | ObjectInput[]; +}; + +export type Input = BaseInput; diff --git a/frontend/src/features/workflowEditor/context/types/settings.ts b/frontend/src/features/workflowEditor/context/types/settings.ts new file mode 100644 index 00000000..dceb832f --- /dev/null +++ b/frontend/src/features/workflowEditor/context/types/settings.ts @@ -0,0 +1,50 @@ +export enum scheduleIntervals { + None = "none", + Once = "once", + Hourly = "hourly", + Daily = "daily", + Weekly = "weekly", + Monthly = "monthly", + Yearly = "yearly", +} + +export type ScheduleIntervals = `${scheduleIntervals}`; + +export enum endDateTypes { + Never = "never", + UserDefined = "User defined", +} + +export type EndDateTypes = `${endDateTypes}`; + +export enum storageSourcesAWS { + None = "None", + AWS_S3 = "AWS S3", +} + +export type StorageSourcesAWS = `${storageSourcesAWS}`; + +export enum storageSourcesLocal { + None = "None", + Local = "Local", +} + +export type StorageSourcesLocal = `${storageSourcesLocal}`; + +export interface IWorkflowSettingsConfig { + name: string; + scheduleInterval: ScheduleIntervals; + startDate: string; + endDate?: string; + endDateType: EndDateTypes; +} + +export interface IWorkflowSettingsStorage { + storageSource: StorageSourcesAWS | StorageSourcesLocal; + baseFolder?: string; + bucket?: string; +} +export interface IWorkflowSettings { + config: IWorkflowSettingsConfig; + storage: IWorkflowSettingsStorage; +} diff --git a/frontend/src/features/workflowEditor/context/types/storage.ts b/frontend/src/features/workflowEditor/context/types/storage.ts new file mode 100644 index 00000000..88708693 --- /dev/null +++ b/frontend/src/features/workflowEditor/context/types/storage.ts @@ -0,0 +1,11 @@ +export enum storageAccessModes { + None = "None", + Read = "Read", + ReadWrite = "Read/Write", +} +// Equivalent to type StorageAccessModes = "None" | "Read" | "Read/Write" +export type StorageAccessModes = `${storageAccessModes}`; + +export interface IStorageFormData { + storageAccessMode: StorageAccessModes; +} diff --git a/frontend/src/features/workflowEditor/context/types/workflowPieceData.ts b/frontend/src/features/workflowEditor/context/types/workflowPieceData.ts new file mode 100644 index 00000000..6046a3e1 --- /dev/null +++ b/frontend/src/features/workflowEditor/context/types/workflowPieceData.ts @@ -0,0 +1,72 @@ +import { type IWorkflowElement } from "features/workflows/types"; +import { type Edge } from "reactflow"; + +import { type IContainerResourceFormData } from "./containerResources"; +import { type InputArray, type Input } from "./input"; +import { + type EndDateTypes, + type ScheduleIntervals, + type StorageSourcesAWS, + type StorageSourcesLocal, +} from "./settings"; +import { type IStorageFormData, type StorageAccessModes } from "./storage"; + +export interface IWorkflowPieceData { + storage: IStorageFormData; + containerResources: IContainerResourceFormData; + inputs: Record; +} + +interface WorkflowBaseSettings { + name: string; + start_date: string; // ISOFormat + select_end_date: EndDateTypes; + schedule_interval: ScheduleIntervals; + + end_date?: string; // ISOFormat + catchup?: boolean; + generate_report?: boolean; + description?: string; +} + +interface UiSchema { + nodes: Record; + edges: Edge[]; +} + +interface WorkflowSharedStorageDataModel { + source: StorageSourcesLocal | StorageSourcesAWS; + base_folder?: string; + mode: StorageAccessModes; + provider_options?: Record; +} + +interface SystemRequirementsModel { + cpu: number; + memory: number; +} +interface ContainerResourcesDataModel { + requests: SystemRequirementsModel; + limits: SystemRequirementsModel; + use_gpu: boolean; +} + +export interface TasksDataModel { + workflow_shared_storage: WorkflowSharedStorageDataModel; + container_resources: ContainerResourcesDataModel; + task_id: string; + piece: { + id: number; + name: string; + }; + piece_input_kwargs: Record; + dependencies?: string[]; +} + +type TasksDict = Record; + +export interface CreateWorkflowRequest { + workflow: WorkflowBaseSettings; + tasks: TasksDict; + ui_schema: UiSchema; +} diff --git a/frontend/src/features/workflowEditor/context/workflowPieces.tsx b/frontend/src/features/workflowEditor/context/workflowPieces.tsx new file mode 100644 index 00000000..8d0f92f2 --- /dev/null +++ b/frontend/src/features/workflowEditor/context/workflowPieces.tsx @@ -0,0 +1,83 @@ +import React, { useCallback } from "react"; +import localForage from "services/config/localForage.config"; +import { createCustomContext } from "utils"; + +export interface IWorkflowPieceContext { + setForageWorkflowPieces: (workflowPieces: any) => Promise; // TODO add type + getForageWorkflowPieces: () => Promise>; // TODO add type + removeForageWorkflowPiecesById: (id: string) => Promise; + fetchWorkflowPieceById: (id: string) => Promise; // TODO add type + clearForageWorkflowPieces: () => Promise; + setForageWorkflowPiecesOutputSchema: ( + id: string, + properties: any, + ) => Promise; +} + +export const [WorkflowPiecesContext, useWorkflowPiece] = + createCustomContext("WorkflowsPieces Context"); + +const WorkflowPiecesProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const setForageWorkflowPieces = useCallback(async (workflowPieces: any) => { + await localForage.setItem("workflowPieces", workflowPieces); + }, []); + + const setForageWorkflowPiecesOutputSchema = useCallback( + async (id: any, properties: any) => { + const workflowPieces = await localForage.getItem("workflowPieces"); + if (workflowPieces?.[id]) { + workflowPieces[id].output_schema.properties = properties; + void localForage.setItem("workflowPieces", workflowPieces); + } + }, + [], + ); + + const clearForageWorkflowPieces = useCallback(async () => { + await localForage.setItem("workflowPieces", {}); + }, []); + + const getForageWorkflowPieces = useCallback(async () => { + const workflowPieces = await localForage.getItem("workflowPieces"); + if (!workflowPieces) { + return {}; + } + return workflowPieces; + }, []); + + const removeForageWorkflowPiecesById = useCallback(async (id: string) => { + const workflowPieces = await localForage.getItem("workflowPieces"); + if (!workflowPieces) { + return; + } + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete workflowPieces[id]; + await localForage.setItem("workflowPieces", workflowPieces); + }, []); + + const fetchWorkflowPieceById = useCallback(async (id: string) => { + const workflowPieces = await localForage.getItem("workflowPieces"); + if (workflowPieces !== null) { + return workflowPieces[id]; + } + }, []); + + const value: IWorkflowPieceContext = { + fetchWorkflowPieceById, + getForageWorkflowPieces, + removeForageWorkflowPiecesById, + setForageWorkflowPieces, + clearForageWorkflowPieces, + setForageWorkflowPiecesOutputSchema, + }; + + return ( + + {children} + + ); +}; + +export default WorkflowPiecesProvider; diff --git a/frontend/src/features/workflowEditor/context/workflowPiecesData.tsx b/frontend/src/features/workflowEditor/context/workflowPiecesData.tsx new file mode 100644 index 00000000..7d6074b4 --- /dev/null +++ b/frontend/src/features/workflowEditor/context/workflowPiecesData.tsx @@ -0,0 +1,138 @@ +import React, { useCallback } from "react"; +import localForage from "services/config/localForage.config"; +import { createCustomContext, getUuid } from "utils"; + +import { type IWorkflowPieceData } from "./types"; + +type ForagePiecesData = Record; + +export interface IWorkflowPiecesDataContext { + setForageWorkflowPiecesData: ( + id: string, + pieceData: IWorkflowPieceData, + ) => Promise; + fetchForageWorkflowPiecesData: () => Promise; + fetchForageWorkflowPiecesDataById: ( + id: string, + ) => Promise; + removeForageWorkflowPieceDataById: (id: string) => Promise; + clearForageWorkflowPiecesData: () => Promise; + clearDownstreamDataById: (id: string) => Promise; +} + +export const [WorkflowPiecesDataContext, useWorkflowPiecesData] = + createCustomContext("WorkflowPiecesData Context"); + +const WorkflowPiecesDataProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + const setForageWorkflowPiecesData = useCallback( + async (id: string, pieceData: IWorkflowPieceData) => { + let currentData = + await localForage.getItem("workflowPiecesData"); + if (!currentData) { + currentData = {}; + } + currentData[id] = pieceData; + await localForage.setItem("workflowPiecesData", currentData); + }, + [], + ); + + const fetchForageWorkflowPiecesData = useCallback(async () => { + const workflowPiecesData = + await localForage.getItem("workflowPiecesData"); + + return workflowPiecesData ?? {}; + }, []); + + const fetchForageWorkflowPiecesDataById = useCallback(async (id: string) => { + const workflowPiecesData = + await localForage.getItem("workflowPiecesData"); + + if (!workflowPiecesData?.[id]) { + return; + } + + return workflowPiecesData[id]; + }, []); + + const clearDownstreamDataById = useCallback(async (id: string) => { + const hashId = getUuid(id).replaceAll("-", ""); + const workflowPieceData = + await localForage.getItem("workflowPiecesData"); + + if (!workflowPieceData) { + return; + } + + Object.values(workflowPieceData).forEach((wpd) => { + Object.values(wpd.inputs).forEach((input) => { + if (input.upstreamId.includes(hashId)) { + input.upstreamArgument = ""; + input.upstreamId = ""; + input.upstreamValue = ""; + } else if (Array.isArray(input.value)) { + input.value.forEach((item) => { + if ( + typeof item.upstreamId === "string" && + item.upstreamId.includes(hashId) + ) { + item.upstreamArgument = ""; + item.upstreamId = ""; + item.upstreamValue = ""; + } else if (typeof item.upstreamId === "object") { + Object.keys(item.upstreamId).forEach((key) => { + const obj = item as any; + if (obj.upstreamId[key].includes(hashId)) { + obj.upstreamArgument[key] = ""; + obj.upstreamId[key] = ""; + obj.upstreamValue[key] = ""; + } + }); + } + }); + } + }); + }); + await localForage.setItem("workflowPiecesData", workflowPieceData); + }, []); + + const removeForageWorkflowPieceDataById = useCallback( + async (id: string) => { + const workflowPieceData = + await localForage.getItem("workflowPiecesData"); + + if (!workflowPieceData) { + return; + } + // eslint-disable-next-line @typescript-eslint/no-dynamic-delete + delete workflowPieceData[id]; + await localForage.setItem("workflowPiecesData", workflowPieceData); + + await clearDownstreamDataById(id); + }, + [clearDownstreamDataById], + ); + + const clearForageWorkflowPiecesData = useCallback(async () => { + await localForage.setItem("workflowPiecesData", {}); + }, []); + + const value: IWorkflowPiecesDataContext = { + setForageWorkflowPiecesData, + fetchForageWorkflowPiecesData, + fetchForageWorkflowPiecesDataById, + removeForageWorkflowPieceDataById, + clearForageWorkflowPiecesData, + clearDownstreamDataById, + }; + + return ( + + {children} + + ); +}; + +export default WorkflowPiecesDataProvider; diff --git a/frontend/src/features/workflowEditor/context/workflowSettingsData.tsx b/frontend/src/features/workflowEditor/context/workflowSettingsData.tsx new file mode 100644 index 00000000..d8fd2ed0 --- /dev/null +++ b/frontend/src/features/workflowEditor/context/workflowSettingsData.tsx @@ -0,0 +1,49 @@ +import React, { useCallback } from "react"; +import localForage from "services/config/localForage.config"; +import { createCustomContext } from "utils"; + +import { type IWorkflowSettings } from "./types"; + +export interface IWorkflowSettingsContext { + fetchWorkflowSettingsData: () => Promise; + setWorkflowSettingsData: (data: any) => Promise; + clearWorkflowSettingsData: () => Promise; +} + +export const [WorkflowSettingsDataContext, useWorkflowSettings] = + createCustomContext("WorkflowSettingsContext"); + +const WorkflowSettingsDataProvider: React.FC<{ children: React.ReactNode }> = ({ + children, +}) => { + // Forage forms data + const fetchWorkflowSettingsData = useCallback(async () => { + const data = await localForage.getItem("workflowSettingsData"); + if (data === null) { + return {}; + } + return data; + }, []); + + const setWorkflowSettingsData = useCallback(async (data: any) => { + await localForage.setItem("workflowSettingsData", data); + }, []); + + const clearWorkflowSettingsData = useCallback(async () => { + await localForage.setItem("workflowSettingsData", {}); + }, []); + + const value = { + fetchWorkflowSettingsData, + setWorkflowSettingsData, + clearWorkflowSettingsData, + }; + + return ( + + {children} + + ); +}; + +export default WorkflowSettingsDataProvider; diff --git a/frontend/src/features/workflowEditor/context/workflowsEditor.tsx b/frontend/src/features/workflowEditor/context/workflowsEditor.tsx new file mode 100644 index 00000000..713fb351 --- /dev/null +++ b/frontend/src/features/workflowEditor/context/workflowsEditor.tsx @@ -0,0 +1,312 @@ +import { useWorkspaces } from "context/workspaces"; +import { + type IPostWorkflowParams, + useAuthenticatedPostWorkflow, +} from "features/workflows/api"; +import { type IPostWorkflowResponseInterface } from "features/workflows/types"; +import React, { type FC, useCallback } from "react"; +import { createCustomContext, generateTaskName, getIdSlice } from "utils"; + +import { usesPieces, type IPiecesContext } from "./pieces"; +import { + useReactWorkflowPersistence, + type IReactWorkflowPersistenceContext, +} from "./reactWorkflowPersistence"; +import { type CreateWorkflowRequest, type TasksDataModel } from "./types"; +import { useWorkflowPiece, type IWorkflowPieceContext } from "./workflowPieces"; +import { + useWorkflowPiecesData, + type IWorkflowPiecesDataContext, +} from "./workflowPiecesData"; +import { + type IWorkflowSettingsContext, + useWorkflowSettings, +} from "./workflowSettingsData"; + +interface IWorkflowsEditorContext + extends IPiecesContext, + IReactWorkflowPersistenceContext, + IWorkflowSettingsContext, + IWorkflowPieceContext, + IWorkflowPiecesDataContext { + fetchWorkflowForage: () => any; // TODO add type + workflowsEditorBodyFromFlowchart: () => Promise; // TODO add type + handleCreateWorkflow: ( + params: IPostWorkflowParams, + ) => Promise; + clearForageData: () => Promise; +} + +export const [WorkflowsEditorContext, useWorkflowsEditor] = + createCustomContext("WorkflowsEditor Context"); + +const WorkflowsEditorProvider: FC<{ children?: React.ReactNode }> = ({ + children, +}) => { + const { workspace } = useWorkspaces(); + const postWorkflow = useAuthenticatedPostWorkflow(); + + const { + repositories, + repositoriesError, + repositoriesLoading, + repositoryPieces, + fetchForagePieceById, + fetchRepoById, + search, + handleSearch, + } = usesPieces(); + + const { + setWorkflowEdges, + setWorkflowNodes, + fetchForageWorkflowEdges, + fetchForageWorkflowNodes, + clearReactWorkflowPersistence, + } = useReactWorkflowPersistence(); + + const { + setForageWorkflowPieces, + setForageWorkflowPiecesOutputSchema, + fetchWorkflowPieceById, + getForageWorkflowPieces, + removeForageWorkflowPiecesById, + clearForageWorkflowPieces, + } = useWorkflowPiece(); + + const { + fetchForageWorkflowPiecesData, + fetchForageWorkflowPiecesDataById, + setForageWorkflowPiecesData, + clearForageWorkflowPiecesData, + removeForageWorkflowPieceDataById, + clearDownstreamDataById, + } = useWorkflowPiecesData(); + + const { + fetchWorkflowSettingsData, + setWorkflowSettingsData, + clearWorkflowSettingsData, + } = useWorkflowSettings(); + + const handleCreateWorkflow = useCallback( + async (payload: IPostWorkflowParams) => { + return await postWorkflow({ + ...payload, + workspace_id: workspace?.id ?? "", + }); + }, + [postWorkflow, workspace], + ); + + const fetchWorkflowForage = useCallback(async () => { + const workflowPieces = await getForageWorkflowPieces(); + const workflowPiecesData = await fetchForageWorkflowPiecesData(); + const workflowSettingsData = await fetchWorkflowSettingsData(); + + return { + workflowPieces, + workflowPiecesData, + workflowSettingsData, + }; + }, [ + fetchForageWorkflowPiecesData, + fetchWorkflowSettingsData, + getForageWorkflowPieces, + ]); + + const workflowsEditorBodyFromFlowchart = useCallback(async () => { + const workflowPiecesData = await fetchForageWorkflowPiecesData(); + const workflowSettingsData = await fetchWorkflowSettingsData(); + const workflowNodes = await fetchForageWorkflowNodes(); + const workflowEdges = await fetchForageWorkflowEdges(); + + const workflow: CreateWorkflowRequest["workflow"] = { + name: workflowSettingsData.config.name, + schedule_interval: workflowSettingsData.config.scheduleInterval, + select_end_date: workflowSettingsData.config.endDateType, + start_date: workflowSettingsData.config.startDate, + end_date: workflowSettingsData.config.endDate, + }; + + const ui_schema: CreateWorkflowRequest["ui_schema"] = { + nodes: {}, + edges: workflowEdges, + }; + + const tasks: CreateWorkflowRequest["tasks"] = {}; + + for (const element of workflowNodes) { + const elementData = workflowPiecesData[element.id]; + + const numberId = getIdSlice(element.id); + const taskName = generateTaskName(element.data.name, element.id); + + ui_schema.nodes[taskName] = element; + + const dependencies = workflowEdges.reduce( + (acc: string[], edge: { target: any; source: any }) => { + if (edge.target === element.id) { + const task = workflowNodes.find( + (n: { id: any }) => n.id === edge.source, + ); + if (task) { + const upTaskName = generateTaskName(task.data.name, task.id); + acc.push(upTaskName); + } + } + + return acc; + }, + [], + ); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { storageSource, baseFolder, ...providerOptions } = + workflowSettingsData.storage || {}; + + const workflowSharedStorage = { + source: storageSource, + ...(baseFolder !== "" ? { base_folder: baseFolder } : {}), + ...{ mode: elementData?.storage?.storageAccessMode }, + provider_options: { + ...(providerOptions && providerOptions.bucket !== "" + ? { bucket: providerOptions.bucket } + : {}), + }, + }; + + const pieceInputKwargs = Object.entries(elementData.inputs).reduce< + Record + >((acc, [key, value]) => { + if (Array.isArray(value.value)) { + acc[key] = { + fromUpstream: value.fromUpstream, + upstreamTaskId: value.fromUpstream ? value.upstreamId : null, + upstreamArgument: value.fromUpstream + ? value.upstreamArgument + : null, + value: value.value.map((value) => { + return { + fromUpstream: value.fromUpstream, + upstreamTaskId: value.fromUpstream ? value.upstreamId : null, + upstreamArgument: value.fromUpstream + ? value.upstreamArgument + : null, + value: value.value, + }; + }), + }; + + return acc; + } + + acc[key] = { + fromUpstream: value.fromUpstream, + upstreamTaskId: value.fromUpstream ? value.upstreamId : null, + upstreamArgument: value.fromUpstream ? value.upstreamArgument : null, + value: value.value, + }; + + return acc; + }, {}); + + const taskDataModel: TasksDataModel = { + task_id: taskName, + piece: { + id: numberId, + name: element.data.name, + }, + dependencies, + piece_input_kwargs: pieceInputKwargs, + workflow_shared_storage: workflowSharedStorage, + container_resources: { + requests: { + cpu: elementData.containerResources.cpu.min, + memory: elementData.containerResources.memory.min, + }, + limits: { + cpu: elementData.containerResources.cpu.max, + memory: elementData.containerResources.memory.max, + }, + use_gpu: elementData.containerResources.useGpu, + }, + }; + + tasks[taskName] = taskDataModel; + } + + return { + workflow, + tasks, + ui_schema, + }; + }, [ + fetchForageWorkflowEdges, + fetchForageWorkflowNodes, + fetchForageWorkflowPiecesData, + fetchWorkflowSettingsData, + ]); + + const clearForageData = useCallback(async () => { + await Promise.allSettled([ + clearReactWorkflowPersistence(), + clearForageWorkflowPieces(), + clearForageWorkflowPiecesData(), + clearWorkflowSettingsData(), + ]); + }, [ + clearReactWorkflowPersistence, + clearForageWorkflowPieces, + clearForageWorkflowPiecesData, + clearWorkflowSettingsData, + ]); + + const value: IWorkflowsEditorContext = { + repositories, + repositoryPieces, + repositoriesError, + repositoriesLoading, + fetchRepoById, + fetchForagePieceById, + search, + handleSearch, + + setWorkflowEdges, + setWorkflowNodes, + fetchForageWorkflowEdges, + fetchForageWorkflowNodes, + clearReactWorkflowPersistence, + + setForageWorkflowPieces, + setForageWorkflowPiecesOutputSchema, + fetchWorkflowPieceById, + getForageWorkflowPieces, + removeForageWorkflowPiecesById, + clearForageWorkflowPieces, + + setForageWorkflowPiecesData, + fetchForageWorkflowPiecesData, + fetchForageWorkflowPiecesDataById, + removeForageWorkflowPieceDataById, + clearForageWorkflowPiecesData, + clearDownstreamDataById, + + setWorkflowSettingsData, + fetchWorkflowSettingsData, + clearWorkflowSettingsData, + + handleCreateWorkflow, + fetchWorkflowForage, + workflowsEditorBodyFromFlowchart, + clearForageData, + }; + + return ( + + {children} + + ); +}; + +export default WorkflowsEditorProvider; diff --git a/frontend/src/features/workflowEditor/pages/index.ts b/frontend/src/features/workflowEditor/pages/index.ts new file mode 100644 index 00000000..28528430 --- /dev/null +++ b/frontend/src/features/workflowEditor/pages/index.ts @@ -0,0 +1 @@ +export * from "./workflowEditorPage"; diff --git a/frontend/src/features/workflowEditor/pages/workflowEditorPage.tsx b/frontend/src/features/workflowEditor/pages/workflowEditorPage.tsx new file mode 100644 index 00000000..6e987c9f --- /dev/null +++ b/frontend/src/features/workflowEditor/pages/workflowEditorPage.tsx @@ -0,0 +1,22 @@ +import { Grid } from "@mui/material"; +import PrivateLayout from "components/PrivateLayout"; + +import { WorkflowsEditorComponent } from "../components/WorkflowEditor"; +import WorkflowsEditorProviderWrapper from "../context"; +/** + * Workflows editor page + */ + +export const WorkflowsEditorPage: React.FC = () => { + return ( + + + + + + + + + + ); +}; diff --git a/frontend/src/features/workflowEditor/schemas/containerResourcesSchemas.ts b/frontend/src/features/workflowEditor/schemas/containerResourcesSchemas.ts new file mode 100644 index 00000000..42e989c2 --- /dev/null +++ b/frontend/src/features/workflowEditor/schemas/containerResourcesSchemas.ts @@ -0,0 +1,50 @@ +export const containerResourcesSchema = { + type: "object", + properties: { + cpu: { + type: "object", + title: "CPU", + properties: { + min: { + title: "Min - Thousandth of a core (m)", + type: "number", + default: 100, // todo: min value we will accept ? + // "maximum": 10000, // todo: max value we will accept ? + minimum: 50, // todo: min value we will accept ? + }, + max: { + title: "Max - Thousandth of a core (m)", + type: "number", + default: 100, + // "maximum": 10000, // todo: max value we will accept ? + minimum: 50, // todo: min value we will accept ? + }, + }, + }, + memory: { + type: "object", + title: "Memory", + properties: { + min: { + title: "Min - Mebibyte (Mi)", + type: "number", + default: 128, + // 'maximum': 15258, // todo: max value we will accept ? + minimum: 32, + }, + max: { + title: "Max - Mibibyte (Mi)", + type: "number", + default: 128, + // "maximum": 15258, // todo: max value we will accept ? + minimum: 32, + }, + }, + }, + useGpu: { + title: "Use GPU", + type: "boolean", + default: false, + }, + }, +}; diff --git a/frontend/src/features/workflowEditor/utils/getFromUpstream.ts b/frontend/src/features/workflowEditor/utils/getFromUpstream.ts new file mode 100644 index 00000000..91292d0a --- /dev/null +++ b/frontend/src/features/workflowEditor/utils/getFromUpstream.ts @@ -0,0 +1,68 @@ +function isEnum( + schema: SimpleInputSchemaProperty | InputSchemaProperty | Definition, +): boolean { + if ("allOf" in schema || "enum" in schema) { + return true; + } + return false; +} + +function getFromUpstream( + itemSchema: SimpleInputSchemaProperty | InputSchemaProperty, +): boolean; +function getFromUpstream( + itemSchema: InputSchemaProperty | EnumDefinition, + definitions: Definitions, + key: string, +): boolean; + +function getFromUpstream( + itemSchema: SimpleInputSchemaProperty | InputSchemaProperty | EnumDefinition, + definitions?: any, + key?: string, +): boolean { + // Enum type cant be from upstream + if (isEnum(itemSchema)) { + return false; + } + + if (definitions && "items" in itemSchema && "$ref" in itemSchema.items) { + const name = itemSchema.items.$ref.split("/").pop() as string; + const definition = (definitions as Definitions)[name]; + + // Enum type cant be from upstream + if (isEnum(definition)) { + return false; + } else if (definition.type === "object") { + const schema = definition.properties[key as string]; + + if ("allOf" in schema || "enum" in schema) { + return false; + } + + switch (schema?.from_upstream) { + case "always": + return true; + + case "allowed": + case "never": + default: + return false; + } + } + } + + switch ( + (itemSchema as SimpleInputSchemaProperty | InputSchemaProperty) + ?.from_upstream + ) { + case "always": + return true; + + case "allowed": + case "never": + default: + return false; + } +} +export { getFromUpstream }; diff --git a/frontend/src/features/workflowEditor/utils/index.ts b/frontend/src/features/workflowEditor/utils/index.ts new file mode 100644 index 00000000..3fca25de --- /dev/null +++ b/frontend/src/features/workflowEditor/utils/index.ts @@ -0,0 +1,2 @@ +export * from "./getFromUpstream"; +export * from "./jsonSchema"; diff --git a/frontend/src/features/workflowEditor/utils/jsonSchema.ts b/frontend/src/features/workflowEditor/utils/jsonSchema.ts new file mode 100644 index 00000000..ed6c962d --- /dev/null +++ b/frontend/src/features/workflowEditor/utils/jsonSchema.ts @@ -0,0 +1,104 @@ +// Extract default values from Schema + +import { type IWorkflowPieceData } from "../context/types"; + +import { getFromUpstream } from "./getFromUpstream"; + +export const extractDefaultInputValues = (pieceSchema: Piece) => { + const schema = pieceSchema.input_schema.properties; + const definitions = pieceSchema.input_schema.definitions; + const defaultData = extractDefaultValues(pieceSchema.input_schema); + + const defaultInputs: IWorkflowPieceData["inputs"] = {}; + for (const key in defaultData) { + const fromUpstream = getFromUpstream(schema[key]); + + let defaultValues = defaultData[key]; + if (Array.isArray(defaultData[key])) { + const auxDefaultValues = []; + for (const element of defaultData[key]) { + let newValue: any = {}; + if (typeof element === "object") { + newValue = { + fromUpstream: {}, + upstreamId: {}, + upstreamArgument: {}, + upstreamValue: {}, + value: {}, + }; + for (const [objKey, objValue] of Object.entries(element)) { + const fromUpstream = getFromUpstream( + schema[key], + definitions, + objKey, + ); + + newValue.fromUpstream = { + ...newValue.fromUpstream, + [objKey]: fromUpstream, + }; + newValue.upstreamId = { + ...newValue.upstreamId, + [objKey]: "", + }; + newValue.upstreamArgument = { + ...newValue.upstreamArgument, + [objKey]: "", + }; + newValue.upstreamValue = { + ...newValue.upstreamValue, + [objKey]: "", + }; + newValue.value = { + ...newValue.value, + [objKey]: objValue, + }; + } + auxDefaultValues.push(newValue); + } else { + newValue = { + fromUpstream, + upstreamId: "", + upstreamArgument: "", + upstreamValue: "", + value: element, + }; + auxDefaultValues.push(newValue); + } + } + defaultValues = auxDefaultValues; + } + defaultInputs[key] = { + fromUpstream, + upstreamId: "", + upstreamArgument: "", + upstreamValue: "", + value: defaultValues ?? null, + }; + } + return defaultInputs; +}; + +export const extractDefaultValues = ( + schema: PieceSchema, + output: any | null = null, +) => { + output = output === null ? {} : output; + + if (schema) { + const properties = schema.properties; + for (const [key, value] of Object.entries(properties)) { + if (value?.from_upstream === "always") { + output[key] = ""; + } + + if ("default" in value) { + output[key] = value.default; + } else if ("properties" in value) { + output[key] = extractDefaultValues(value as any, output[key]); + } + } + } + + return output; +}; diff --git a/frontend/src/features/workflows/api/index.ts b/frontend/src/features/workflows/api/index.ts new file mode 100644 index 00000000..523a1a7c --- /dev/null +++ b/frontend/src/features/workflows/api/index.ts @@ -0,0 +1,4 @@ +export * from "./runs"; +export * from "./workflow"; +export * from "./piece"; +export * from "./repository"; diff --git a/frontend/src/features/workflows/api/piece/getPieceRepositories.request.ts b/frontend/src/features/workflows/api/piece/getPieceRepositories.request.ts new file mode 100644 index 00000000..b47d3bdb --- /dev/null +++ b/frontend/src/features/workflows/api/piece/getPieceRepositories.request.ts @@ -0,0 +1,73 @@ +import { type AxiosResponse } from "axios"; +import { useWorkspaces } from "context/workspaces"; +import { dominoApiClient } from "services/clients/domino.client"; +import useSWR from "swr"; + +import { type IGetPiecesRepositoriesResponseInterface } from "./piece.interface"; + +interface IGetPieceRepositoryFilters { + page?: number; + page_size?: number; + name__like?: string; + path__like?: string; + version?: string; + source?: "github" | "default"; +} + +const getPiecesRepositoriesUrl = ( + workspace: string, + filters: IGetPieceRepositoryFilters, +) => { + const query = new URLSearchParams(); + query.set("workspace_id", workspace); + for (const [key, value] of Object.entries(filters)) { + query.set(key, value); + } + return `/pieces-repositories?${query.toString()}`; +}; + +/** + * Get Piece using GET /pieces-repositories + * @returns Piece + */ +const getPiecesRepositories: ( + workspace: string, + filters: IGetPieceRepositoryFilters, +) => Promise> = async ( + workspace, + filters, +) => { + // + return await dominoApiClient.get( + getPiecesRepositoriesUrl(workspace, filters), + ); +}; + +/** + * Get pieces repositories for current workspace + * @returns pieces repositories as swr response + */ +export const useAuthenticatedGetPieceRepositories = ( + filters: IGetPieceRepositoryFilters, +) => { + const { workspace } = useWorkspaces(); + + if (!workspace) + throw new Error( + "Impossible to fetch pieces repositories without specifying a workspace", + ); + + const fetcher = async (filters: IGetPieceRepositoryFilters) => + await getPiecesRepositories(workspace.id, filters).then( + (data) => data.data, + ); + + return useSWR( + getPiecesRepositoriesUrl(workspace.id, filters), + async () => await fetcher(filters), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, + ); +}; diff --git a/frontend/src/features/workflows/api/piece/getPieceRepositoryPieces.ts b/frontend/src/features/workflows/api/piece/getPieceRepositoryPieces.ts new file mode 100644 index 00000000..b5fbefca --- /dev/null +++ b/frontend/src/features/workflows/api/piece/getPieceRepositoryPieces.ts @@ -0,0 +1,35 @@ +import { type AxiosResponse } from "axios"; +import { useCallback } from "react"; +import { dominoApiClient } from "services/clients/domino.client"; + +import { type IGetRepoPiecesResponseInterface } from "./piece.interface"; + +interface IGetRepoPiecesParams { + id: string; +} + +/** + * Get pieces for selected repository using GET /pieces-repositories/{id}/pieces + * @param token auth token (string) + * @param id repo id + * @returns pieces + */ +const getRepoIdPieces: (args: { + params: IGetRepoPiecesParams; +}) => Promise> = async ({ + params, +}) => { + return await dominoApiClient.get(`pieces-repositories/${params.id}/pieces`); +}; + +/** + * Get pieces by repo id authenticated fetcher function + * @param params `{ id: string }` + * @returns pieces from repo + */ +export const useFetchAuthenticatedGetRepoIdPieces = () => { + const fetcher = useCallback(async (params: IGetRepoPiecesParams) => { + return await getRepoIdPieces({ params }).then((data) => data.data); + }, []); + return fetcher; +}; diff --git a/frontend/src/features/workflows/api/piece/getPiecesRepositoriesReleases.request.ts b/frontend/src/features/workflows/api/piece/getPiecesRepositoriesReleases.request.ts new file mode 100644 index 00000000..a0b573e3 --- /dev/null +++ b/frontend/src/features/workflows/api/piece/getPiecesRepositoriesReleases.request.ts @@ -0,0 +1,51 @@ +import { type AxiosResponse } from "axios"; +import { useWorkspaces } from "context/workspaces"; +import { dominoApiClient } from "services/clients/domino.client"; + +import { + type IGetPiecesRepositoriesReleasesParams, + type IGetPiecesRepositoriesReleasesResponseInterface, +} from "./piece.interface"; + +/** + * Get Piece repository releases using GET /pieces-repositories/releases + * @param token auth token (string) + * @returns Piece repository + */ +const getPiecesRepositoriesReleases: ( + params: IGetPiecesRepositoriesReleasesParams, +) => Promise< + AxiosResponse +> = async ({ source, path, workspaceId }) => { + const search = new URLSearchParams(); + search.set("source", source); + search.set("path", path); + if (workspaceId) { + search.set("workspace_id", workspaceId); + } + + return await dominoApiClient.get( + `/pieces-repositories/releases?${search.toString()}`, + ); +}; + +/** + * Get releases for a given Piece repository + * @returns pieces repositories releases + */ +export const useAuthenticatedGetPieceRepositoriesReleases = () => { + const { workspace } = useWorkspaces(); + + if (!workspace) + throw new Error( + "Impossible to fetch pieces repositories without specifying a workspace", + ); + + return async (params: IGetPiecesRepositoriesReleasesParams) => + await getPiecesRepositoriesReleases({ + ...params, + workspaceId: workspace.id, + }).then((data) => { + return data.data; + }); +}; diff --git a/frontend/src/features/workflows/api/piece/index.ts b/frontend/src/features/workflows/api/piece/index.ts new file mode 100644 index 00000000..867aec15 --- /dev/null +++ b/frontend/src/features/workflows/api/piece/index.ts @@ -0,0 +1,4 @@ +export * from "./getPieceRepositories.request"; +export * from "./getPieceRepositoryPieces"; +export * from "./getPiecesRepositoriesReleases.request"; +export * from "./piece.interface"; diff --git a/frontend/src/features/workflows/api/piece/piece.interface.ts b/frontend/src/features/workflows/api/piece/piece.interface.ts new file mode 100644 index 00000000..ddebddcf --- /dev/null +++ b/frontend/src/features/workflows/api/piece/piece.interface.ts @@ -0,0 +1,44 @@ +import { type repositorySource } from "context/workspaces/types"; + +interface IPaginationMetadata { + page: number; + records: number; + total: number; + last_page: number; +} + +/** + * Get Piece Repositories response interface + */ +export interface IGetPiecesRepositoriesResponseInterface { + data: PieceRepository[]; + metadata: IPaginationMetadata; +} + +/** + * Get Piece Repositories id Pieces + */ +export type IGetRepoPiecesResponseInterface = Piece[]; + +/** + * Piece repository metadata + */ +export interface IPieceRepositoryMetadata { + version: string; + last_modified: string; +} + +/** + * Get Pieces Repositories Releases response interface + */ +export type IGetPiecesRepositoriesReleasesResponseInterface = + IPieceRepositoryMetadata[]; + +/** + * Get Pieces Repositories Releases request params + */ +export interface IGetPiecesRepositoriesReleasesParams { + source: repositorySource; + path: string; + workspaceId?: string; +} diff --git a/frontend/src/features/workflows/api/repository/deletePieceRepository.request.ts b/frontend/src/features/workflows/api/repository/deletePieceRepository.request.ts new file mode 100644 index 00000000..33bb30d1 --- /dev/null +++ b/frontend/src/features/workflows/api/repository/deletePieceRepository.request.ts @@ -0,0 +1,32 @@ +// TODO move to /runs +import { type AxiosResponse } from "axios"; +import { useWorkspaces } from "context/workspaces"; +import { dominoApiClient } from "services/clients/domino.client"; + +const deleteRepositoryUrl = (id: string) => `/pieces-repositories/${id}`; + +/** + * Run workflow by id using /workflow/run/:id + * @returns workflow run result + */ +const deleteRepository: (id: string) => Promise = async (id) => { + return await dominoApiClient.delete(deleteRepositoryUrl(id)); +}; + +/** + * Run workflow by id fetcher fn + * @param params `{ id: string }` + */ +export const useAuthenticatedDeleteRepository = () => { + const { workspace } = useWorkspaces(); + + if (!workspace) + throw new Error( + "Impossible to run workflows without specifying a workspace", + ); + + const fetcher = async (id: string) => + await deleteRepository(id).then((data) => data); + + return fetcher; +}; diff --git a/frontend/src/features/workflows/api/repository/getPieceRepositorySecrets.request.ts b/frontend/src/features/workflows/api/repository/getPieceRepositorySecrets.request.ts new file mode 100644 index 00000000..e85759a5 --- /dev/null +++ b/frontend/src/features/workflows/api/repository/getPieceRepositorySecrets.request.ts @@ -0,0 +1,62 @@ +import { type AxiosResponse } from "axios"; +import { useWorkspaces } from "context/workspaces"; +import { dominoApiClient } from "services/clients/domino.client"; +import useSWR from "swr"; + +import { type IPieceRepositorySecretsData } from "./pieceRepository.interface"; + +interface IGetRepositorySecretsParams { + repositoryId: string; +} + +const getRepositorySecretsUrl = (repositoryId: string) => + `/pieces-repositories/${repositoryId}/secrets`; + +/** + * Get workflows using GET /workflows + * @returns workflow + */ +const getRepositorySecrets: ( + repositoryId: string, +) => Promise> = async ( + repositoryId, +) => { + return await dominoApiClient.get(getRepositorySecretsUrl(repositoryId)); +}; + +export const useAuthenticatedGetWorkflowRunTasksFetcher = () => { + const { workspace } = useWorkspaces(); + + if (!workspace) + throw new Error( + "Impossible to fetch workflows without specifying a workspace", + ); + + return async (params: IGetRepositorySecretsParams) => + await getRepositorySecrets(params.repositoryId).then((data) => data.data); +}; + +/** + * Get workflow runs + * @returns runs as swr response + */ +export const useAuthenticatedGetRepositorySecrets = ( + params: IGetRepositorySecretsParams, +) => { + const { workspace } = useWorkspaces(); + if (!workspace) + throw new Error( + "Impossible to fetch workflows without specifying a workspace", + ); + + const fetcher = useAuthenticatedGetWorkflowRunTasksFetcher(); + + return useSWR( + params.repositoryId ? getRepositorySecretsUrl(params.repositoryId) : null, + async () => await fetcher(params), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, + ); +}; diff --git a/frontend/src/features/workflows/api/repository/index.ts b/frontend/src/features/workflows/api/repository/index.ts new file mode 100644 index 00000000..91a74f87 --- /dev/null +++ b/frontend/src/features/workflows/api/repository/index.ts @@ -0,0 +1,3 @@ +export * from "./getPieceRepositorySecrets.request"; +export * from "./patchPieceRepositorySecret.request"; +export * from "./deletePieceRepository.request"; diff --git a/frontend/src/features/workflows/api/repository/patchPieceRepositorySecret.request.ts b/frontend/src/features/workflows/api/repository/patchPieceRepositorySecret.request.ts new file mode 100644 index 00000000..9c8c452b --- /dev/null +++ b/frontend/src/features/workflows/api/repository/patchPieceRepositorySecret.request.ts @@ -0,0 +1,46 @@ +// TODO move to /runs +import { type AxiosResponse } from "axios"; +import { useWorkspaces } from "context/workspaces"; +import { dominoApiClient } from "services/clients/domino.client"; + +interface PatchRepositorySecretParams { + repositoryId: string; + secretId: string; + payload: { + value: string | null; + }; +} + +const patchRepositorySecretUrl = (repositoryId: string, secretId: string) => + `/pieces-repositories/${repositoryId}/secrets/${secretId}`; + +/** + * Run workflow by id using /workflow/run/:id + * @returns workflow run result + */ +const patchRepositorySecret: ( + params: PatchRepositorySecretParams, +) => Promise = async (params) => { + return await dominoApiClient.patch( + patchRepositorySecretUrl(params.repositoryId, params.secretId), + params.payload, + ); +}; + +/** + * Run workflow by id fetcher fn + * @param params `{ id: string }` + */ +export const useAuthenticatedPatchRepositorySecret = () => { + const { workspace } = useWorkspaces(); + + if (!workspace) + throw new Error( + "Impossible to run workflows without specifying a workspace", + ); + + const fetcher = async (params: PatchRepositorySecretParams) => + await patchRepositorySecret(params).then((data) => data); + + return fetcher; +}; diff --git a/frontend/src/features/workflows/api/repository/pieceRepository.interface.ts b/frontend/src/features/workflows/api/repository/pieceRepository.interface.ts new file mode 100644 index 00000000..224fdef7 --- /dev/null +++ b/frontend/src/features/workflows/api/repository/pieceRepository.interface.ts @@ -0,0 +1,5 @@ +export interface IPieceRepositorySecretsData { + id: number; + name: string; + is_filled: boolean; +} diff --git a/frontend/src/features/workflows/api/runs/getWorkflowRunTaskLogs.ts b/frontend/src/features/workflows/api/runs/getWorkflowRunTaskLogs.ts new file mode 100644 index 00000000..ee8601b0 --- /dev/null +++ b/frontend/src/features/workflows/api/runs/getWorkflowRunTaskLogs.ts @@ -0,0 +1,82 @@ +import { type AxiosResponse } from "axios"; +import { useWorkspaces } from "context/workspaces"; +import { dominoApiClient } from "services/clients/domino.client"; +import useSWR from "swr"; + +export interface IGetWorkflowRunTaskLogsParams { + workflowId: string; + runId: string; + taskId: string; + taskTryNumber: string; +} + +const getWorkflowRunTaskLogsUrl = ( + workspace: string, + workflowId: string, + runId: string, + taskId: string, + taskTryNumber: string, +) => { + if (workspace && workflowId && runId && taskId && taskTryNumber) { + return `/workspaces/${workspace}/workflows/${workflowId}/runs/${runId}/tasks/${taskId}/${taskTryNumber}/logs`; + } +}; + +/** + * Get workflows using GET /workflows + * @returns workflow + */ +const getWorkflowRunTaskLogs: ( + workspace: string, + workflowId: string, + runId: string, + taskId: string, + taskTryNumber: string, +) => Promise | undefined> = async ( + workspace, + workflowId, + runId, + taskId, + taskTryNumber, +) => { + const url = getWorkflowRunTaskLogsUrl( + workspace, + workflowId, + runId, + taskId, + taskTryNumber, + ); + if (url) return await dominoApiClient.get(url); +}; + +/** + * Get workflow runs + * @returns runs as swr response + */ +export const useAuthenticatedGetWorkflowRunTaskLogs = ( + params: IGetWorkflowRunTaskLogsParams, +) => { + const { workspace } = useWorkspaces(); + if (!workspace) + throw new Error( + "Impossible to fetch workflows without specifying a workspace", + ); + + return useSWR( + getWorkflowRunTaskLogsUrl( + workspace.id, + params.workflowId, + params.runId, + params.taskId, + params.taskTryNumber, + ) ?? null, + async () => + await getWorkflowRunTaskLogs( + workspace.id, + params.workflowId, + params.runId, + params.taskId, + params.taskTryNumber, + ).then((data) => data?.data), + ); +}; diff --git a/frontend/src/features/workflows/api/runs/getWorkflowRunTaskResult.ts b/frontend/src/features/workflows/api/runs/getWorkflowRunTaskResult.ts new file mode 100644 index 00000000..c6e83b8f --- /dev/null +++ b/frontend/src/features/workflows/api/runs/getWorkflowRunTaskResult.ts @@ -0,0 +1,79 @@ +import { type AxiosResponse } from "axios"; +import { useWorkspaces } from "context/workspaces"; +import { dominoApiClient } from "services/clients/domino.client"; +import useSWR from "swr"; + +export interface IGetWorkflowRunTaskResultParams { + workflowId: string; + runId: string; + taskId: string; + taskTryNumber: string; +} + +const getWorkflowRunTaskResultUrl = ( + workspace: string, + workflowId: string, + runId: string, + taskId: string, + taskTryNumber: string, +) => { + if (workspace && workflowId && runId && taskId && Number(taskTryNumber)) + return `/workspaces/${workspace}/workflows/${workflowId}/runs/${runId}/tasks/${taskId}/${taskTryNumber}/result`; +}; + +/** + * Get workflows using GET /workflows + * @returns workflow + */ +const getWorkflowRunTaskResult: ( + workspace: string, + workflowId: string, + runId: string, + taskId: string, + taskTryNumber: string, +) => Promise< + AxiosResponse<{ base64_content: string; file_type: string }> | undefined +> = async (workspace, workflowId, runId, taskId, taskTryNumber) => { + if (workspace && workflowId && runId && taskId && taskTryNumber) { + const url = getWorkflowRunTaskResultUrl( + workspace, + workflowId, + runId, + taskId, + taskTryNumber, + ); + if (url) return await dominoApiClient.get(url); + } +}; + +/** + * Get workflow runs + * @returns runs as swr response + */ +export const useAuthenticatedGetWorkflowRunTaskResult = ( + params: IGetWorkflowRunTaskResultParams, +) => { + const { workspace } = useWorkspaces(); + if (!workspace) + throw new Error( + "Impossible to fetch workflows without specifying a workspace", + ); + + return useSWR( + getWorkflowRunTaskResultUrl( + workspace.id, + params.workflowId, + params.runId, + params.taskId, + params.taskTryNumber, + ) ?? null, + async () => + await getWorkflowRunTaskResult( + workspace.id, + params.workflowId, + params.runId, + params.taskId, + params.taskTryNumber, + ).then((data) => data?.data), + ); +}; diff --git a/frontend/src/features/workflows/api/runs/getWorkflowRunTasks.ts b/frontend/src/features/workflows/api/runs/getWorkflowRunTasks.ts new file mode 100644 index 00000000..63d82f19 --- /dev/null +++ b/frontend/src/features/workflows/api/runs/getWorkflowRunTasks.ts @@ -0,0 +1,63 @@ +import { type AxiosResponse } from "axios"; +import { useWorkspaces } from "context/workspaces"; +import { type IGetWorkflowRunTasksResponseInterface } from "features/workflows/types/runs"; +import { dominoApiClient } from "services/clients/domino.client"; + +export interface IGetWorkflowRunTasksParams { + workflowId: string; + runId: string; + page: number; + pageSize: number; +} + +const getWorkflowRunTasksUrl = ( + workspace: string, + workflowId: string, + runId: string, + page: number, + pageSize: number, +) => + `/workspaces/${workspace}/workflows/${workflowId}/runs/${runId}/tasks?page=${page}&page_size=${pageSize}`; + +/** + * Get workflows using GET /workflows + * @returns workflow + */ +const getWorkflowRunTasks: ( + workspace: string, + workflowId: string, + runId: string, + page: number, + pageSize: number, +) => Promise> = async ( + workspace, + workflowId, + runId, + page, + pageSize, +) => { + return await dominoApiClient.get( + getWorkflowRunTasksUrl(workspace, workflowId, runId, page, pageSize), + ); +}; + +/** + * Get workflow runs + * @returns runs as swr response + */ +export const useAuthenticatedGetWorkflowRunTasks = () => { + const { workspace } = useWorkspaces(); + if (!workspace) + throw new Error( + "Impossible to fetch workflows without specifying a workspace", + ); + + return async (params: IGetWorkflowRunTasksParams) => + await getWorkflowRunTasks( + workspace.id, + params.workflowId, + params.runId, + params.page, + params.pageSize, + ).then((data) => data.data); +}; diff --git a/frontend/src/features/workflows/api/runs/getWorkflowRuns.ts b/frontend/src/features/workflows/api/runs/getWorkflowRuns.ts new file mode 100644 index 00000000..c20e0c8a --- /dev/null +++ b/frontend/src/features/workflows/api/runs/getWorkflowRuns.ts @@ -0,0 +1,87 @@ +import { type AxiosResponse } from "axios"; +import { useWorkspaces } from "context/workspaces"; +import { type IGetWorkflowRunsResponseInterface } from "features/workflows/types/runs"; +import { dominoApiClient } from "services/clients/domino.client"; +import useSWR from "swr"; + +interface IGetWorkflowRunParams { + workflowId: string; + page: number; + pageSize: number; +} + +const getWorkflowRunsUrl = ( + workspace: string, + workflowId: string, + page: number, + pageSize: number, +) => + `/workspaces/${workspace}/workflows/${workflowId}/runs?page=${page}&page_size=${pageSize}`; + +/** + * Get workflows using GET /workflows + * @returns workflow + */ +const getWorkflowRuns: ( + workspace: string, + workflowId: string, + page: number, + pageSize: number, +) => Promise> = async ( + workspace, + workflowId, + page, + pageSize, +) => { + return await dominoApiClient.get( + getWorkflowRunsUrl(workspace, workflowId, page, pageSize), + ); +}; + +export const useAuthenticatedGetWorkflowRunFetcher = () => { + const { workspace } = useWorkspaces(); + if (!workspace) + throw new Error( + "Impossible to fetch workflows without specifying a workspace", + ); + + return async (params: IGetWorkflowRunParams) => + await getWorkflowRuns( + workspace.id, + params.workflowId, + params.page, + params.pageSize, + ).then((data) => data.data); +}; + +/** + * Get workflow runs + * @returns runs as swr response + */ +export const useAuthenticatedGetWorkflowRuns = ( + params: IGetWorkflowRunParams, +) => { + const { workspace } = useWorkspaces(); + if (!workspace) + throw new Error( + "Impossible to fetch workflows without specifying a workspace", + ); + + const fetcher = useAuthenticatedGetWorkflowRunFetcher(); + + return useSWR( + params.workflowId + ? getWorkflowRunsUrl( + workspace.id, + params.workflowId, + params.page, + params.pageSize, + ) + : null, + async () => await fetcher(params), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, + ); +}; diff --git a/frontend/src/features/workflows/api/runs/index.ts b/frontend/src/features/workflows/api/runs/index.ts new file mode 100644 index 00000000..bc215ffa --- /dev/null +++ b/frontend/src/features/workflows/api/runs/index.ts @@ -0,0 +1,4 @@ +export * from "./getWorkflowRuns"; +export * from "./getWorkflowRunTasks"; +export * from "./getWorkflowRunTaskLogs"; +export * from "./getWorkflowRunTaskResult"; diff --git a/frontend/src/features/workflows/api/workflow/deleteWorkflowId.ts b/frontend/src/features/workflows/api/workflow/deleteWorkflowId.ts new file mode 100644 index 00000000..0b8ebf20 --- /dev/null +++ b/frontend/src/features/workflows/api/workflow/deleteWorkflowId.ts @@ -0,0 +1,62 @@ +import { AxiosError, type AxiosResponse } from "axios"; +import { useWorkspaces } from "context/workspaces"; +import { type IDeleteWorkflowIdResponseInterface } from "features/workflows/types/workflow"; +import { toast } from "react-toastify"; +import { dominoApiClient } from "services/clients/domino.client"; + +interface IDeleteWorkflowIdParams { + id: string; +} + +/** + * Get workflow by id using GET /workflow + * @returns workflow + */ +const deleteWorkflowId: ( + workspaceId: string, + params: IDeleteWorkflowIdParams, +) => Promise> = async ( + workspaceId, + params, +) => { + return await dominoApiClient.delete( + `/workspaces/${workspaceId}/workflows/${params.id}`, + ); +}; + +/** + * Delete workflow by id + * @returns authenticated delete function + */ +export const useAuthenticatedDeleteWorkflowId = () => { + const { workspace } = useWorkspaces(); + + if (!workspace) + throw new Error( + "Impossible to fetch delete without specifying a workspace", + ); + + const fetcher = async (params: IDeleteWorkflowIdParams) => + await deleteWorkflowId(workspace.id, params) + .then((data) => { + toast.success("Workflow deleted."); + return data; + }) + .catch((e) => { + if (e instanceof AxiosError) { + if (e?.response?.status === 403) { + toast.error("You are not allowed to delete this workflow."); + } else if (e?.response?.status === 404) { + toast.error("Workflow not found."); + } else if (e?.response?.status === 409) { + toast.error("Workflow is not in a valid state. "); + } else { + console.error(e); + toast.error("Something went wrong. "); + } + } else { + throw e; + } + }); + return fetcher; +}; diff --git a/frontend/src/features/workflows/api/workflow/getWorkflow.ts b/frontend/src/features/workflows/api/workflow/getWorkflow.ts new file mode 100644 index 00000000..15af1751 --- /dev/null +++ b/frontend/src/features/workflows/api/workflow/getWorkflow.ts @@ -0,0 +1,53 @@ +import { type AxiosResponse } from "axios"; +import { useWorkspaces } from "context/workspaces"; +import { type IGetWorkflowResponseInterface } from "features/workflows/types/workflow"; +import { dominoApiClient } from "services/clients/domino.client"; +import useSWR from "swr"; + +const getWorkflowsUrl = (workspace: string, page: number, pageSize: number) => + `/workspaces/${workspace}/workflows?page=${page}&page_size=${pageSize}`; + +/** + * Get workflows using GET /workflows + * @param token auth token (string) + * @returns workflow + */ +const getWorkflows: ( + workspace: string, + page: number, + pageSize: number, +) => Promise> = async ( + workspace, + page, + pageSize, +) => { + return await dominoApiClient.get(getWorkflowsUrl(workspace, page, pageSize)); +}; + +/** + * Get workflow + * @returns workflow as swr response + */ +export const useAuthenticatedGetWorkflows = ( + page: number = 0, + pageSize: number = 5, +) => { + const { workspace } = useWorkspaces(); + + if (!workspace) + throw new Error( + "Impossible to fetch workflows without specifying a workspace", + ); + + const fetcher = async () => + await getWorkflows(workspace.id, page, pageSize).then((data) => data.data); + + return useSWR( + getWorkflowsUrl(workspace.id, page, pageSize), + async () => await fetcher(), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, + ); +}; diff --git a/frontend/src/features/workflows/api/workflow/getWorkflowId.ts b/frontend/src/features/workflows/api/workflow/getWorkflowId.ts new file mode 100644 index 00000000..728e21ce --- /dev/null +++ b/frontend/src/features/workflows/api/workflow/getWorkflowId.ts @@ -0,0 +1,54 @@ +import { type AxiosResponse } from "axios"; +import { useWorkspaces } from "context/workspaces"; +import { type IGetWorkflowIdResponseInterface } from "features/workflows/types/workflow"; +import { dominoApiClient } from "services/clients/domino.client"; +import useSWR from "swr"; + +interface IGetWorkflowIdParams { + id: string; +} + +const getWorkflowUrl = (workspaceId: string, id: string) => + `/workspaces/${workspaceId}/workflows/${id}`; + +/** + * Get workflow by id using GET /workflow + * @returns workflow + */ +const getWorkflowId: ( + workspaceId: string, + params: IGetWorkflowIdParams, +) => Promise> = async ( + workspaceId, + params, +) => { + return await dominoApiClient.get(getWorkflowUrl(workspaceId, params.id)); +}; + +/** + * Get workflow by id + * @param params `{ workspaceId: number, id: string }` + * @returns workflow fetcher fn + */ +export const useAuthenticatedGetWorkflowId = ({ id }: { id: string }) => { + const { workspace } = useWorkspaces(); + + if (!workspace) + throw new Error( + "Impossible to fetch workflows without specifying a workspace", + ); + + // todo add swr ? + const fetcher = async (params: IGetWorkflowIdParams) => { + return await getWorkflowId(workspace.id, params).then((data) => data.data); + }; + + return useSWR( + getWorkflowUrl(workspace.id, id), + async () => await fetcher({ id }), + { + revalidateOnFocus: false, + revalidateOnReconnect: false, + }, + ); +}; diff --git a/frontend/src/features/workflows/api/workflow/index.ts b/frontend/src/features/workflows/api/workflow/index.ts new file mode 100644 index 00000000..7164b493 --- /dev/null +++ b/frontend/src/features/workflows/api/workflow/index.ts @@ -0,0 +1,5 @@ +export * from "./deleteWorkflowId"; +export * from "./getWorkflowId"; +export * from "./getWorkflow"; +export * from "./postWorkflowRunId"; +export * from "./postWorkflow"; diff --git a/frontend/src/features/workflows/api/workflow/postWorkflow.ts b/frontend/src/features/workflows/api/workflow/postWorkflow.ts new file mode 100644 index 00000000..8be5ac62 --- /dev/null +++ b/frontend/src/features/workflows/api/workflow/postWorkflow.ts @@ -0,0 +1,35 @@ +import { type AxiosResponse } from "axios"; +import { type CreateWorkflowRequest } from "features/workflowEditor/context/types"; +import { type IPostWorkflowResponseInterface } from "features/workflows/types"; +import { dominoApiClient } from "services/clients/domino.client"; + +export interface IPostWorkflowParams extends CreateWorkflowRequest { + workspace_id: string; +} + +/** + * Create workflow using POST /workflow + * @returns ? + */ +const postWorkflow: ( + payload: IPostWorkflowParams, +) => Promise> = async ( + payload, +) => { + return await dominoApiClient.post( + `/workspaces/${payload.workspace_id}/workflows`, + payload, + ); +}; + +/** + * Create authenticated workflow + * @param params `{ id: string, data: Record }`` + * @returns crate workflow function + */ +export const useAuthenticatedPostWorkflow = () => { + const fetcher = async (params: IPostWorkflowParams) => + await postWorkflow(params).then((data) => data.data); + + return fetcher; +}; diff --git a/frontend/src/features/workflows/api/workflow/postWorkflowRunId.ts b/frontend/src/features/workflows/api/workflow/postWorkflowRunId.ts new file mode 100644 index 00000000..a8a5be4a --- /dev/null +++ b/frontend/src/features/workflows/api/workflow/postWorkflowRunId.ts @@ -0,0 +1,68 @@ +// TODO move to /runs +import { AxiosError, type AxiosResponse } from "axios"; +import { useWorkspaces } from "context/workspaces"; +import { type IPostWorkflowRunIdResponseInterface } from "features/workflows/types/workflow"; +import { toast } from "react-toastify"; +import { dominoApiClient } from "services/clients/domino.client"; + +interface IPostWorkflowRunIdParams { + id: string; +} + +const postWorkflowRunUrl = (workspaceId: string, id: string) => + `/workspaces/${workspaceId}/workflows/${id}/runs`; + +/** + * Run workflow by id using /workflow/run/:id + * @returns workflow run result + */ +const postWorkflowRunId: ( + workspaceId: string, + params: IPostWorkflowRunIdParams, +) => Promise> = async ( + workspaceId, + params, +) => { + return await dominoApiClient.post( + postWorkflowRunUrl(workspaceId, params.id), + null, + ); +}; + +/** + * Run workflow by id fetcher fn + * @param params `{ id: string }` + */ +export const useAuthenticatedPostWorkflowRunId = () => { + const { workspace } = useWorkspaces(); + + if (!workspace) + throw new Error( + "Impossible to run workflows without specifying a workspace", + ); + + const fetcher = async (params: IPostWorkflowRunIdParams) => + await postWorkflowRunId(workspace.id, params) + .then((data) => { + toast.success("Workflow started"); + return data; + }) + .catch((e) => { + if (e instanceof AxiosError) { + if (e?.response?.status === 403) { + toast.error("You are not allowed to run this workflow."); + } else if (e?.response?.status === 404) { + toast.error("Workflow not found."); + } else if (e?.response?.status === 409) { + toast.error("Workflow is not in a valid state. "); + } else { + console.error(e); + toast.error("Something went wrong. "); + } + } else { + throw e; + } + }); + + return fetcher; +}; diff --git a/frontend/src/features/workflows/components/WorkflowDetail/CustomTabMenu/CustomTabMenu.tsx b/frontend/src/features/workflows/components/WorkflowDetail/CustomTabMenu/CustomTabMenu.tsx new file mode 100644 index 00000000..78b3f1e5 --- /dev/null +++ b/frontend/src/features/workflows/components/WorkflowDetail/CustomTabMenu/CustomTabMenu.tsx @@ -0,0 +1,37 @@ +import { Box, Tab, Tabs } from "@mui/material"; +import React, { type ReactNode } from "react"; + +interface Props { + tabTitles: string[]; + children?: ReactNode; + value: number; + handleChange: (_event: React.SyntheticEvent, newValue: number) => void; +} + +export const CustomTabMenu: React.FC = ({ + value, + handleChange, + children, + tabTitles, +}) => ( + <> + + + {tabTitles.map((title, idx) => ( + + ))} + + + {children} + +); diff --git a/frontend/src/features/workflows/components/WorkflowDetail/CustomTabMenu/CustomTabPanel.tsx b/frontend/src/features/workflows/components/WorkflowDetail/CustomTabMenu/CustomTabPanel.tsx new file mode 100644 index 00000000..c04484b9 --- /dev/null +++ b/frontend/src/features/workflows/components/WorkflowDetail/CustomTabMenu/CustomTabPanel.tsx @@ -0,0 +1,24 @@ +interface TabPanelProps { + children?: React.ReactNode; + index: number; + value: number; +} + +export function CustomTabPanel(props: TabPanelProps) { + const { children, value, index, ...other } = props; + + return ( + + ); +} diff --git a/frontend/src/features/workflows/components/WorkflowDetail/CustomTabMenu/TaskDetail.tsx b/frontend/src/features/workflows/components/WorkflowDetail/CustomTabMenu/TaskDetail.tsx new file mode 100644 index 00000000..fe3f4a54 --- /dev/null +++ b/frontend/src/features/workflows/components/WorkflowDetail/CustomTabMenu/TaskDetail.tsx @@ -0,0 +1,169 @@ +import CalendarMonthIcon from "@mui/icons-material/CalendarMonth"; +import TimelapseIcon from "@mui/icons-material/Timelapse"; +import { + Grid, + List, + ListItem, + Chip, + Typography, + Container, +} from "@mui/material"; +import { intervalToDuration } from "date-fns"; +import { taskStatesColorMap } from "features/workflows/constants"; +import { type IWorkflowRunTasks } from "features/workflows/types/runs"; +import { useMemo } from "react"; + +interface IWorkflowRunTasksExtended extends IWorkflowRunTasks { + pieceName: string; +} + +interface ITaskDetailsProps { + taskData: IWorkflowRunTasksExtended; +} + +export const TaskDetails = (props: ITaskDetailsProps) => { + const duration = useMemo(() => { + if (props.taskData.duration) { + const duration = intervalToDuration({ + start: 0, + end: props.taskData.duration * 1000, + }); + + return `${duration.hours} ${ + (duration?.hours ?? 0) > 1 ? "hours" : "hour" + } : ${duration.minutes} ${ + (duration?.minutes ?? 0) > 1 ? "minutes" : "minute" + } : ${duration.seconds} ${ + (duration?.seconds ?? 0) > 1 ? "seconds" : "second" + }`; + } else { + return "Not done yet"; + } + }, [props.taskData.duration]); + + return ( + + + + + + + State: + + { + + } + + + + Piece: + + + {props.taskData.pieceName} + + + + + Start Date: + + + + {props.taskData.start_date + ? new Date(props.taskData.start_date).toLocaleString() + : "Not executed yet"} + + + + + End Date: + + + + {props.taskData.end_date + ? new Date(props.taskData.end_date).toLocaleString() + : "Not ended yet"} + + + + + Duration: + + + + {duration} + + + + + + + ); +}; diff --git a/frontend/src/features/workflows/components/WorkflowDetail/CustomTabMenu/TaskLogs.tsx b/frontend/src/features/workflows/components/WorkflowDetail/CustomTabMenu/TaskLogs.tsx new file mode 100644 index 00000000..d8baaedf --- /dev/null +++ b/frontend/src/features/workflows/components/WorkflowDetail/CustomTabMenu/TaskLogs.tsx @@ -0,0 +1,63 @@ +import { + Switch, + FormControlLabel, + FormGroup, + Typography, + Box, +} from "@mui/material"; +import { useState, useMemo, type CSSProperties } from "react"; + +interface ITaskLogsProps { + logs: string[]; +} + +export const TaskLogs = ({ logs }: ITaskLogsProps) => { + const [renderOverflowX, setRenderOverflowX] = useState(true); + + /* @todo + * const logsTypeColorMap = { + * 'INFO': '#64df46', + * 'ERROR': '#f00', + * 'WARNING': '#f90', + * 'DEBUG': '#00f', + * } + */ + + const logContent = useMemo(() => { + return logs.length ? logs.join("\n") : "No logs available"; + }, [logs]); + + const textareaStyle: CSSProperties = useMemo(() => { + return { + width: "100%", + height: "100%", + border: "none", + overflowX: renderOverflowX ? "hidden" : "scroll", + overflowY: "scroll", + whiteSpace: renderOverflowX ? "pre-wrap" : "pre", + wordWrap: renderOverflowX ? "break-word" : "normal", + outline: "none", + }; + }, [renderOverflowX]); + + return ( + + + { + setRenderOverflowX(!renderOverflowX); + }} + /> + } + label="Wrap text horizontally." + /> + + + {logContent} + + + ); +}; diff --git a/frontend/src/features/workflows/components/WorkflowDetail/CustomTabMenu/TaskResult.tsx b/frontend/src/features/workflows/components/WorkflowDetail/CustomTabMenu/TaskResult.tsx new file mode 100644 index 00000000..4096a22f --- /dev/null +++ b/frontend/src/features/workflows/components/WorkflowDetail/CustomTabMenu/TaskResult.tsx @@ -0,0 +1,116 @@ +import { CircularProgress, Container, Typography } from "@mui/material"; +import { type CSSProperties } from "react"; +import ReactMarkdown from "react-markdown"; +// import { PDFViewer, Page, Text, View, Document, StyleSheet } from '@react-pdf/renderer'; + +interface ITaskResultProps { + isLoading: boolean; + base64_content?: string; + file_type?: string; +} + +export const TaskResult = (props: ITaskResultProps) => { + const { base64_content, file_type } = props; + + const style: CSSProperties = { + height: "100%", + overflowY: "scroll", + overflowX: "hidden", + wordWrap: "break-word", + whiteSpace: "pre-wrap", + }; + + const renderContent = () => { + if (props.isLoading) { + return ; + } + + if (!base64_content || !file_type) { + return ( + + No content + + ); + } + + switch (file_type) { + case "txt": + return
{window.atob(base64_content)}
; + case "json": + return ( +
+            {JSON.stringify(JSON.parse(window.atob(base64_content)), null, 2)}
+          
+ ); + case "jpeg": + case "png": + case "bmp": + case "gif": + case "tiff": + return ( + Content + ); + case "svg": + return ( + + Your browser does not support SVG + + ); + case "md": + return {window.atob(base64_content)}; + case "pdf": + return ( +
+ PDF result display not yet implemented + {/* + + + + */} +
+ ); + case "html": + return ( +
+ HTML result display not yet implemented +
+ //