diff --git a/.github/workflows/cd-deploy-main.yaml b/.github/workflows/cd-deploy-main.yaml index 9b6217c5ab30..5959d0abd9c6 100644 --- a/.github/workflows/cd-deploy-main.yaml +++ b/.github/workflows/cd-deploy-main.yaml @@ -5,6 +5,7 @@ on: - main jobs: deploy-main: + timeout-minutes: 3 runs-on: ubuntu-latest steps: - name: Repository Dispatch diff --git a/.github/workflows/cd-deploy-tag.yaml b/.github/workflows/cd-deploy-tag.yaml index 17bee3d1a34f..cb07fe44687e 100644 --- a/.github/workflows/cd-deploy-tag.yaml +++ b/.github/workflows/cd-deploy-tag.yaml @@ -5,6 +5,7 @@ on: - 'v*' jobs: deploy-tag: + timeout-minutes: 3 runs-on: ubuntu-latest steps: - name: Repository Dispatch diff --git a/.github/workflows/ci-chrome-extension.yaml b/.github/workflows/ci-chrome-extension.yaml index a78be9415597..63c7094f7044 100644 --- a/.github/workflows/ci-chrome-extension.yaml +++ b/.github/workflows/ci-chrome-extension.yaml @@ -3,19 +3,16 @@ on: push: branches: - main - paths: - - 'package.json' - - 'packages/twenty-chrome-extension/**' + pull_request: - paths: - - 'package.json' - - 'packages/twenty-chrome-extension/**' + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: chrome-extension-build: + timeout-minutes: 15 runs-on: ubuntu-latest env: VITE_SERVER_BASE_URL: http://localhost:3000 @@ -26,7 +23,25 @@ jobs: with: access_token: ${{ github.token }} - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for changed files + id: changed-files + uses: tj-actions/changed-files@v11 + with: + files: | + package.json + packages/twenty-chrome-extension/** + - name: Install dependencies + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - name: Chrome Extension / Run build + if: steps.changed-files.outputs.any_changed == 'true' run: npx nx build twenty-chrome-extension + + - name: Mark as Valid if No Changes + if: steps.changed-files.outputs.changed != 'true' + run: | + echo "No relevant changes detected. Marking as valid." diff --git a/.github/workflows/ci-e2e.yml.bak b/.github/workflows/ci-e2e.yml.bak new file mode 100644 index 000000000000..7bba72f1e77b --- /dev/null +++ b/.github/workflows/ci-e2e.yml.bak @@ -0,0 +1,52 @@ +name: CI E2E Tests +on: + push: + branches: + - main + pull_request: + branches: + - '**' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + test: + timeout-minutes: 30 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - uses: actions/setup-node@v4 + with: + node-version: lts/* + + - name: Check for changed files + id: changed-files + uses: tj-actions/changed-files@v11 + with: + files: | + packages/** + playwright.config.ts + + - name: Skip if no relevant changes + if: steps.changed-files.outputs.any_changed == 'false' + run: echo "No relevant changes detected. Marking as valid." + + - name: Install dependencies + if: steps.changed-files.outputs.any_changed == 'true' + uses: ./.github/workflows/actions/yarn-install + - name: Install Playwright Browsers + if: steps.changed-files.outputs.any_changed == 'true' + run: yarn playwright install --with-deps + - name: Run Playwright tests + if: steps.changed-files.outputs.any_changed == 'true' + run: yarn test:e2e companies + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: playwright-report/ + retention-days: 30 diff --git a/.github/workflows/ci-front.yaml b/.github/workflows/ci-front.yaml index b61506709f93..910286ff1beb 100644 --- a/.github/workflows/ci-front.yaml +++ b/.github/workflows/ci-front.yaml @@ -3,21 +3,16 @@ on: push: branches: - main - paths: - - 'package.json' - - 'packages/twenty-front/**' - - 'packages/twenty-ui/**' + pull_request: - paths: - - 'package.json' - - 'packages/twenty-front/**' - - 'packages/twenty-ui/**' + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: front-sb-build: + timeout-minutes: 30 runs-on: ubuntu-latest env: REACT_APP_SERVER_BASE_URL: http://localhost:3000 @@ -29,21 +24,43 @@ jobs: access_token: ${{ github.token }} - name: Fetch local actions uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Check for changed files + id: changed-files + uses: tj-actions/changed-files@v11 + with: + files: | + package.json + packages/twenty-front/** + packages/twenty-ui/** + + - name: Skip if no relevant changes + if: steps.changed-files.outputs.any_changed == 'false' + run: echo "No relevant changes. Skipping CI." + - name: Install dependencies + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - name: Diagnostic disk space issue + if: steps.changed-files.outputs.any_changed == 'true' run: df -h - name: Front / Restore Storybook Task Cache + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/task-cache with: tag: scope:frontend tasks: storybook:build - name: Front / Write .env + if: steps.changed-files.outputs.any_changed == 'true' run: npx nx reset:env twenty-front - name: Front / Build storybook + if: steps.changed-files.outputs.any_changed == 'true' run: npx nx storybook:build twenty-front front-sb-test: - runs-on: ci-8-cores + timeout-minutes: 30 + runs-on: shipfox-8vcpu-ubuntu-2204 needs: front-sb-build strategy: matrix: @@ -54,36 +71,72 @@ jobs: steps: - name: Fetch local actions uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check for changed files + id: changed-files + uses: tj-actions/changed-files@v11 + with: + files: | + packages/twenty-front/** + - name: Skip if no relevant changes + if: steps.changed-files.outputs.any_changed == 'false' + run: echo "No relevant changes. Skipping CI." + - name: Install dependencies + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - name: Install Playwright + if: steps.changed-files.outputs.any_changed == 'true' run: cd packages/twenty-front && npx playwright install - name: Front / Restore Storybook Task Cache + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/task-cache with: tag: scope:frontend tasks: storybook:build - name: Front / Write .env + if: steps.changed-files.outputs.any_changed == 'true' run: npx nx reset:env twenty-front - name: Run storybook tests + if: steps.changed-files.outputs.any_changed == 'true' run: npx nx storybook:serve-and-test:static twenty-front --configuration=${{ matrix.storybook_scope }} front-sb-test-performance: - runs-on: ci-8-cores + timeout-minutes: 30 + runs-on: shipfox-8vcpu-ubuntu-2204 env: REACT_APP_SERVER_BASE_URL: http://localhost:3000 NX_REJECT_UNKNOWN_LOCAL_CACHE: 0 steps: - name: Fetch local actions uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check for changed files + id: changed-files + uses: tj-actions/changed-files@v11 + with: + files: | + packages/twenty-front/** + + - name: Skip if no relevant changes + if: steps.changed-files.outputs.any_changed == 'false' + run: echo "No relevant changes. Skipping CI." + - name: Install dependencies + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - name: Install Playwright + if: steps.changed-files.outputs.any_changed == 'true' run: cd packages/twenty-front && npx playwright install - name: Front / Write .env + if: steps.changed-files.outputs.any_changed == 'true' run: npx nx reset:env twenty-front - name: Run storybook tests - run: npx nx storybook:serve-and-test:static:performance twenty-front + if: steps.changed-files.outputs.any_changed == 'true' + run: npx nx run twenty-front:storybook:serve-and-test:static:performance front-chromatic-deployment: + timeout-minutes: 30 if: contains(github.event.pull_request.labels.*.name, 'run-chromatic') || github.event_name == 'push' needs: front-sb-build runs-on: ubuntu-latest @@ -95,21 +148,38 @@ jobs: - uses: actions/checkout@v4 with: fetch-depth: 0 + + - name: Check for changed files + id: changed-files + uses: tj-actions/changed-files@v11 + with: + files: | + packages/twenty-front/** + + - name: Skip if no relevant changes + if: steps.changed-files.outputs.any_changed == 'false' + run: echo "No relevant changes. Skipping CI." + - name: Install dependencies + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - name: Front / Restore Storybook Task Cache + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/task-cache with: tag: scope:frontend tasks: storybook:build - name: Front / Write .env + if: steps.changed-files.outputs.any_changed == 'true' run: | cd packages/twenty-front touch .env echo "REACT_APP_SERVER_BASE_URL: $REACT_APP_SERVER_BASE_URL" >> .env - name: Publish to Chromatic + if: steps.changed-files.outputs.any_changed == 'true' run: npx nx run twenty-front:chromatic:ci front-task: + timeout-minutes: 30 runs-on: ubuntu-latest env: NX_REJECT_UNKNOWN_LOCAL_CACHE: 0 @@ -125,20 +195,35 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + - name: Check for changed files + id: changed-files + uses: tj-actions/changed-files@v11 + with: + files: | + packages/twenty-front/** + + - name: Skip if no relevant changes + if: steps.changed-files.outputs.any_changed == 'false' + run: echo "No relevant changes. Skipping CI." + - name: Install dependencies + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - name: Front / Restore ${{ matrix.task }} task cache + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/task-cache with: tag: scope:frontend tasks: ${{ matrix.task }} - name: Reset .env + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/nx-affected with: tag: scope:frontend tasks: reset:env - name: Run ${{ matrix.task }} task + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/nx-affected with: tag: scope:frontend - tasks: ${{ matrix.task }} \ No newline at end of file + tasks: ${{ matrix.task }} diff --git a/.github/workflows/ci-release-create.yaml b/.github/workflows/ci-release-create.yaml index 068d054cb30b..736c57352597 100644 --- a/.github/workflows/ci-release-create.yaml +++ b/.github/workflows/ci-release-create.yaml @@ -15,6 +15,7 @@ on: jobs: create_pr: + timeout-minutes: 10 runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.github/workflows/ci-release-merge.yaml b/.github/workflows/ci-release-merge.yaml index 96b719318672..5b0bce577501 100644 --- a/.github/workflows/ci-release-merge.yaml +++ b/.github/workflows/ci-release-merge.yaml @@ -6,6 +6,7 @@ on: jobs: tag_and_release: + timeout-minutes: 10 runs-on: ubuntu-latest if: github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'release') steps: diff --git a/.github/workflows/ci-server.yaml b/.github/workflows/ci-server.yaml index 074d63fdda40..5d7a35244f01 100644 --- a/.github/workflows/ci-server.yaml +++ b/.github/workflows/ci-server.yaml @@ -3,21 +3,16 @@ on: push: branches: - main - paths: - - 'package.json' - - 'packages/twenty-server/**' - - 'packages/twenty-emails/**' + pull_request: - paths: - - 'package.json' - - 'packages/twenty-server/**' - - 'packages/twenty-emails/**' + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: server-setup: + timeout-minutes: 30 runs-on: ubuntu-latest env: NX_REJECT_UNKNOWN_LOCAL_CACHE: 0 @@ -38,25 +33,42 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + + - name: Check for changed files + id: changed-files + uses: tj-actions/changed-files@v11 + with: + files: | + package.json + packages/twenty-server/** + packages/twenty-emails/** + - name: Install dependencies + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - name: Server / Restore Task Cache + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/task-cache with: tag: scope:backend - name: Server / Run lint & typecheck + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/nx-affected with: tag: scope:backend tasks: lint,typecheck - name: Server / Build + if: steps.changed-files.outputs.any_changed == 'true' run: npx nx build twenty-server - name: Server / Write .env + if: steps.changed-files.outputs.any_changed == 'true' run: npx nx reset:env twenty-server - name: Worker / Run + if: steps.changed-files.outputs.any_changed == 'true' run: npx nx run twenty-server:worker:ci server-test: + timeout-minutes: 30 runs-on: ubuntu-latest needs: server-setup env: @@ -66,19 +78,33 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + + - name: Check for changed files + id: changed-files + uses: tj-actions/changed-files@v11 + with: + files: | + package.json + packages/twenty-server/** + packages/twenty-emails/** + - name: Install dependencies + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - name: Server / Restore Task Cache + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/task-cache with: tag: scope:backend - name: Server / Run Tests + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/nx-affected with: tag: scope:backend tasks: test server-integration-test: + timeout-minutes: 30 runs-on: ubuntu-latest needs: server-setup services: @@ -100,17 +126,30 @@ jobs: uses: actions/checkout@v4 with: fetch-depth: 0 + + - name: Check for changed files + id: changed-files + uses: tj-actions/changed-files@v11 + with: + files: | + package.json + packages/twenty-server/** + packages/twenty-emails/** + - name: Install dependencies + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/yarn-install - name: Server / Restore Task Cache + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/task-cache with: tag: scope:backend - name: Server / Run Integration Tests + if: steps.changed-files.outputs.any_changed == 'true' uses: ./.github/workflows/actions/nx-affected with: tag: scope:backend - tasks: "test:integration" + tasks: "test:integration:with-db-reset" - name: Server / Upload reset-logs file if: always() uses: actions/upload-artifact@v4 diff --git a/.github/workflows/ci-test-docker-compose.yaml b/.github/workflows/ci-test-docker-compose.yaml index 1496425c8511..d7202857a7b4 100644 --- a/.github/workflows/ci-test-docker-compose.yaml +++ b/.github/workflows/ci-test-docker-compose.yaml @@ -1,20 +1,31 @@ name: 'Test Docker Compose' on: pull_request: - paths: - - 'packages/twenty-docker/**' + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: test: + timeout-minutes: 10 runs-on: ubuntu-latest steps: - name: Checkout uses: actions/checkout@v2 + - name: Check for changed files + id: changed-files + uses: tj-actions/changed-files@v11 + with: + files: | + packages/twenty-docker/** + docker-compose.yml + - name: Skip if no relevant changes + if: steps.changed-files.outputs.any_changed != 'true' + run: echo "No relevant changes detected. Marking as valid." - name: Run compose + if: steps.changed-files.outputs.any_changed == 'true' run: | echo "Patching docker-compose.yml..." # change image to localbuild using yq @@ -31,10 +42,7 @@ jobs: cp .env.example .env echo "Generating secrets..." echo "# === Randomly generated secrets ===" >>.env - echo "ACCESS_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env - echo "LOGIN_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env - echo "REFRESH_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env - echo "FILE_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env + echo "APP_SECRET=$(openssl rand -base64 32)" >>.env echo "POSTGRES_ADMIN_PASSWORD=$(openssl rand -base64 32)" >>.env echo "Starting server..." diff --git a/.github/workflows/ci-tinybird.yaml b/.github/workflows/ci-tinybird.yaml new file mode 100644 index 000000000000..44328556408c --- /dev/null +++ b/.github/workflows/ci-tinybird.yaml @@ -0,0 +1,35 @@ +name: CI Tinybird +on: + push: + branches: + - main + + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + ci: + timeout-minutes: 10 + runs-on: ubuntu-latest + uses: tinybirdco/ci/.github/workflows/ci.yml@main + steps: + - name: Check for changed files + id: changed-files + uses: tj-actions/changed-files@v11 + with: + files: | + package.json + packages/twenty-tinybird/** + + - name: Skip if no relevant changes + if: steps.changed-files.outputs.any_changed == 'false' + run: echo "No relevant changes. Skipping CI." + + - name: Check twenty-tinybird package + with: + data_project_dir: packages/twenty-tinybird + tb_admin_token: ${{ secrets.TB_ADMIN_TOKEN }} + tb_host: https://api.eu-central-1.aws.tinybird.co diff --git a/.github/workflows/ci-utils.yaml b/.github/workflows/ci-utils.yaml index fccfca98d8ab..7a7ef38042dc 100644 --- a/.github/workflows/ci-utils.yaml +++ b/.github/workflows/ci-utils.yaml @@ -19,6 +19,7 @@ concurrency: jobs: danger-js: + timeout-minutes: 3 runs-on: ubuntu-latest if: github.event.action != 'closed' steps: @@ -31,6 +32,7 @@ jobs: DANGER_GITHUB_API_TOKEN: ${{ github.token }} congratulate: + timeout-minutes: 3 runs-on: ubuntu-latest if: github.event.action == 'closed' && github.event.pull_request.merged == true steps: diff --git a/.github/workflows/ci-website.yaml b/.github/workflows/ci-website.yaml index d79345f3bf53..770381855bd9 100644 --- a/.github/workflows/ci-website.yaml +++ b/.github/workflows/ci-website.yaml @@ -3,19 +3,17 @@ on: push: branches: - main - paths: - - 'package.json' - - 'packages/twenty-website/**' + pull_request: - paths: - - 'package.json' - - 'packages/twenty-website/**' + + concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: website-build: + timeout-minutes: 3 runs-on: ubuntu-latest services: postgres: @@ -27,13 +25,29 @@ jobs: - 5432:5432 steps: - uses: actions/checkout@v4 + with: + fetch-depth: 0 + - name: Check for changed files + id: changed-files + uses: tj-actions/changed-files@v11 + with: + files: 'package.json, packages/twenty-website/**' + - name: Install dependencies + if: steps.changed-files.outputs.changed == 'true' uses: ./.github/workflows/actions/yarn-install + - name: Website / Run migrations + if: steps.changed-files.outputs.changed == 'true' run: npx nx database:migrate twenty-website env: DATABASE_PG_URL: postgres://twenty:twenty@localhost:5432/default - name: Website / Build Website + if: steps.changed-files.outputs.changed == 'true' run: npx nx build twenty-website env: - DATABASE_PG_URL: postgres://twenty:twenty@localhost:5432/default \ No newline at end of file + DATABASE_PG_URL: postgres://twenty:twenty@localhost:5432/default + + - name: Mark as VALID + if: steps.changed-files.outputs.changed != 'true' # If no changes, mark as valid + run: echo "No relevant changes detected. CI is valid." \ No newline at end of file diff --git a/.github/workflows/playwright.yml.bak b/.github/workflows/playwright.yml.bak deleted file mode 100644 index cffb50287629..000000000000 --- a/.github/workflows/playwright.yml.bak +++ /dev/null @@ -1,27 +0,0 @@ -name: Playwright Tests -on: - push: - branches: [ main, master ] - pull_request: - branches: [ main, master ] -jobs: - test: - timeout-minutes: 60 - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 - with: - node-version: lts/* - - name: Install dependencies - run: npm install -g yarn && yarn - - name: Install Playwright Browsers - run: yarn playwright install --with-deps - - name: Run Playwright tests - run: yarn test:e2e companies - - uses: actions/upload-artifact@v4 - if: always() - with: - name: playwright-report - path: playwright-report/ - retention-days: 30 diff --git a/.gitignore b/.gitignore index c5bb33e003d1..20f974922280 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,4 @@ storybook-static .nyc_output test-results/ dump.rdb +.tinyb diff --git a/.vscode/settings.json b/.vscode/settings.json index d63c92973cfc..fde6cdfb1ffb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -45,5 +45,5 @@ "search.exclude": { "**/.yarn": true, }, - "eslint.debug": true + "eslint.debug": true, } diff --git a/LICENSE b/LICENSE index 0ad25db4bd1d..50a6a10a2861 100644 --- a/LICENSE +++ b/LICENSE @@ -1,3 +1,8 @@ + +This project is mostly licensed under the GNU General Public License (GPL) as described below. However, certain files within this project are licensed under a different commercial license. These files are clearly marked with the following comment at the top of the file: /* @license Enterprise */ +Files with this comment are not licensed under the aGPL v3, but instead are subject to the commercial license terms defined later in this file. + + GNU AFFERO GENERAL PUBLIC LICENSE Version 3, 19 November 2007 @@ -659,3 +664,47 @@ specific requirements. if any, to sign a "copyright disclaimer" for the program, if necessary. For more information on this, and how to apply and follow the GNU AGPL, see . + + + +------------------------------------ + + + + The Twenty.com Commercial License (the “Commercial License”) + Copyright (c) 2023-present Twenty.com, PBC + +With regard to Twenty's Software: + +This part of the software and associated documentation files (the "Software") may only be +used in production, if you (and any entity that you represent) have agreed to, +and are in compliance with, the Terms available +at https://twenty.com/legal/terms, or other agreements governing +the use of the Software, as mutually agreed by you and Twenty.com, PBC ("Twenty"), +and otherwise have a valid Twenty Enterprise Edition subscription +for the correct number of hosts and seats as defined in the Commercial Terms. +Subject to the foregoing sentence, +you are free to modify this Software and publish patches to the Software. You agree +that Twenty and/or its licensors (as applicable) retain all right, title and interest in +and to all such modifications and/or patches, and all such modifications and/or +patches may only be used, copied, modified, displayed, distributed, or otherwise +exploited with a valid Commercial Subscription for the correct number of hosts and seats. +Notwithstanding the foregoing, you may copy and modify the Software for development +and testing purposes, without requiring a subscription. You agree that Twenty.Com and/or +its licensors (as applicable) retain all right, title and interest in and to all such +modifications. You are not granted any other rights beyond what is expressly stated herein. +Subject to the foregoing, it is forbidden to copy, merge, publish, distribute, sublicense, +and/or sell the Software. + + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + +For all third party components incorporated into the Twenty Software, those +components are licensed under the original license provided by the owner of the +applicable component. \ No newline at end of file diff --git a/Makefile b/Makefile index 764dbd1531d4..af42f19d9fb0 100644 --- a/Makefile +++ b/Makefile @@ -1,26 +1,12 @@ -docker-dev-build: - make -C packages/twenty-docker dev-build - -docker-dev-up: - make -C packages/twenty-docker dev-up - -docker-dev-start: - make -C packages/twenty-docker dev-start - -docker-dev-stop: - make -C packages/twenty-docker dev-stop - -docker-dev-sh: - make -C packages/twenty-docker dev-sh - postgres-on-docker: - make -C packages/twenty-postgres provision-on-docker - -postgres-on-macos-arm: - make -C packages/twenty-postgres provision-on-macos-arm - -postgres-on-macos-intel: - make -C packages/twenty-postgres provision-on-macos-intel - -postgres-on-linux: - make -C packages/twenty-postgres provision-on-linux + docker run \ + --name twenty_postgres \ + -e POSTGRES_USER=postgres \ + -e POSTGRES_PASSWORD=postgres \ + -e POSTGRES_DB=default \ + -v twenty_db_data:/var/lib/postgresql/data \ + -p 5432:5432 \ + twentycrm/twenty-postgres:latest + +redis-on-docker: + docker run -d --name twenty_redis -p 6379:6379 redis/redis-stack-server:latest \ No newline at end of file diff --git a/README.md b/README.md index 86ed0fe51a6f..d75db65e1504 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,3 @@ -

- - - - - Hacktoberfest - - -


diff --git a/install.sh b/install.sh index 39eb096b8e25..34741b89b908 100755 --- a/install.sh +++ b/install.sh @@ -45,7 +45,7 @@ trap on_exit EXIT # Use environment variables VERSION and BRANCH, with defaults if not set version=${VERSION:-$(curl -s https://api.github.com/repos/twentyhq/twenty/releases/latest | grep '"tag_name":' | cut -d '"' -f 4)} -branch=${BRANCH:-main} +branch=${BRANCH:-$version} echo "🚀 Using version $version and branch $branch" @@ -72,7 +72,7 @@ done echo "📁 Creating directory '$dir_name'" mkdir -p "$dir_name" && cd "$dir_name" || { echo "❌ Failed to create/access directory '$dir_name'"; exit 1; } -# Copy the twenty/packages/twenty-docker/docker-compose.yml file in it +# Copy twenty/packages/twenty-docker/docker-compose.yml in it echo -e "\t• Copying docker-compose.yml" curl -sLo docker-compose.yml https://raw.githubusercontent.com/twentyhq/twenty/$branch/packages/twenty-docker/docker-compose.yml @@ -91,10 +91,7 @@ fi # Generate random strings for secrets echo "# === Randomly generated secrets ===" >>.env -echo "ACCESS_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env -echo "LOGIN_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env -echo "REFRESH_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env -echo "FILE_TOKEN_SECRET=$(openssl rand -base64 32)" >>.env +echo "APP_SECRET=$(openssl rand -base64 32)" >>.env echo "" >>.env echo "POSTGRES_ADMIN_PASSWORD=$(openssl rand -base64 32)" >>.env diff --git a/nx.json b/nx.json index 35b6e7501700..19c6f5462cce 100644 --- a/nx.json +++ b/nx.json @@ -108,12 +108,11 @@ "storybook:build": { "executor": "nx:run-commands", "cache": true, - "dependsOn": ["^build"], "inputs": ["^default", "excludeTests"], "outputs": ["{projectRoot}/{options.output-dir}"], "options": { "cwd": "{projectRoot}", - "command": "storybook build", + "command": "VITE_DISABLE_ESLINT_CHECKER=true storybook build", "output-dir": "storybook-static", "config-dir": ".storybook" } @@ -192,16 +191,7 @@ "executor": "nx:run-commands", "options": { "commands": [ - "npx concurrently --kill-others --success=first -n SB,TEST 'nx storybook:serve:static {projectName} --port={args.port}' 'npx wait-on tcp:{args.port} && nx storybook:test {projectName} --port={args.port}'" - ], - "port": 6006 - } - }, - "storybook:serve-and-test:static:performance": { - "executor": "nx:run-commands", - "options": { - "commands": [ - "npx concurrently --kill-others --success=first -n SB,TEST 'nx storybook:serve:dev {projectName} --configuration=performance --port={args.port}' 'npx wait-on tcp:{args.port} && nx storybook:test:no-coverage {projectName} --port={args.port} --configuration=performance'" + "npx concurrently --kill-others --success=first -n SB,TEST 'nx storybook:serve:static {projectName} --port={args.port} --configuration={args.performance}' 'npx wait-on tcp:{args.port} && nx storybook:test {projectName} --port={args.port} --configuration={args.scope}'" ], "port": 6006 } diff --git a/oss-gg/twenty-content-challenges/1-create-youtube-video-about-20.md b/oss-gg/twenty-content-challenges/1-create-youtube-video-about-20.md deleted file mode 100644 index 455b5e35bae3..000000000000 --- a/oss-gg/twenty-content-challenges/1-create-youtube-video-about-20.md +++ /dev/null @@ -1,21 +0,0 @@ -**Side Quest**: Create a YouTube Video about Twenty showcasing a specific way to use Twenty effectively. -**Points**: 750 Points -**Proof**: Add your oss handle and YouTube video link to the list below. - -Please follow the following schema: - ---- - -» 05-April-2024 by YOUR oss.gg HANDLE » YouTube Link: https://link.to/content - ---- - -//////////////////////////// - -Your turn 👇 - -//////////////////////////// - -» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) YouTube Link: [YouTube](https://twenty.com/) - ---- \ No newline at end of file diff --git a/oss-gg/twenty-content-challenges/2-write-blog-post-about-20.md b/oss-gg/twenty-content-challenges/2-write-blog-post-about-20.md deleted file mode 100644 index a4c4e6bee944..000000000000 --- a/oss-gg/twenty-content-challenges/2-write-blog-post-about-20.md +++ /dev/null @@ -1,21 +0,0 @@ -**Side Quest**: Write a blog post about sharing your experience using Twenty in a detailed format on any platform. -**Points**: 750 Points -**Proof**: Add your oss handle and blog link to the list below. - -Please follow the following schema: - ---- - -» 05-April-2024 by YOUR oss.gg HANDLE » blog Link: https://link.to/content - ---- - -//////////////////////////// - -Your turn 👇 - -//////////////////////////// - -» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) blog Link: [blog](https://twenty.com/) - ---- \ No newline at end of file diff --git a/oss-gg/twenty-content-challenges/3-write-selfthost-guide-blog-post-20.md b/oss-gg/twenty-content-challenges/3-write-selfthost-guide-blog-post-20.md deleted file mode 100644 index c7352ec430fc..000000000000 --- a/oss-gg/twenty-content-challenges/3-write-selfthost-guide-blog-post-20.md +++ /dev/null @@ -1,21 +0,0 @@ -**Side Quest**: Write a blog post about self-hosting Twenty in a detailed format on any platform. -**Points**: 750 Points -**Proof**: Add your oss handle and blog link to the list below. - -Please follow the following schema: - ---- - -» 05-April-2024 by YOUR oss.gg HANDLE » blog Link: https://link.to/content - ---- - -//////////////////////////// - -Your turn 👇 - -//////////////////////////// - -» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) blog Link: [blog](https://twenty.com/) - ---- \ No newline at end of file diff --git a/oss-gg/twenty-content-challenges/4-create-promotional-video-20-share.md b/oss-gg/twenty-content-challenges/4-create-promotional-video-20-share.md deleted file mode 100644 index e52cb43a4247..000000000000 --- a/oss-gg/twenty-content-challenges/4-create-promotional-video-20-share.md +++ /dev/null @@ -1,21 +0,0 @@ -**Side Quest**: Create a promotional video for Twenty and share it on social media. -**Points**: 750 Points -**Proof**: Add your oss handle and video link to the list below. - -Please follow the following schema: - ---- - -» 05-April-2024 by YOUR oss.gg HANDLE » video Link: https://link.to/content - ---- - -//////////////////////////// - -Your turn 👇 - -//////////////////////////// - -» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) video Link: [video](https://twenty.com/) - ---- \ No newline at end of file diff --git a/oss-gg/twenty-design-challenges/1-design-promotional-poster-20-share.md b/oss-gg/twenty-design-challenges/1-design-promotional-poster-20-share.md deleted file mode 100644 index b61f187117b5..000000000000 --- a/oss-gg/twenty-design-challenges/1-design-promotional-poster-20-share.md +++ /dev/null @@ -1,25 +0,0 @@ -**Side Quest**: Design a promotional poster of Twenty and share it on social media. -**Points**: 300 Points -**Proof**: Add your oss handle and poster link to the list below. - -Please follow the following schema: - ---- - -» 05-April-2024 by YOUR oss.gg HANDLE » poster Link: https://link.to/content - ---- - -//////////////////////////// - -Your turn 👇 - -//////////////////////////// - -» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) poster Link: [poster](https://twenty.com/) - -» 11-October-2024 by [thefool76](https://oss.gg/thefool76) poster Link: [poster](https://drive.google.com/file/d/1cIC1eitvY6zKVTXKq2LnVrS_2Ho9H8-P/view?usp=sharing) - -» 12-October-2024 by [Ionfinisher](https://oss.gg/Ionfinisher) poster Link: [poster](https://x.com/ion_finisher/status/1845168965963628802) - ---- diff --git a/oss-gg/twenty-design-challenges/2-design-new-logo-twenty.md b/oss-gg/twenty-design-challenges/2-design-new-logo-twenty.md deleted file mode 100644 index ceee0fa8e4da..000000000000 --- a/oss-gg/twenty-design-challenges/2-design-new-logo-twenty.md +++ /dev/null @@ -1,28 +0,0 @@ -**Side Quest**: Design/Create new Twenty logo, tweet your design, and mention @twentycrm. -**Points**: 300 Points -**Proof**: Create a logo upload it on any of the platform and add your oss handle and logo link to the list below. - -Please follow the following schema: - ---- - -» 05-April-2024 by YOUR oss.gg HANDLE » Logo Link: https://link.to/content » tweet Link: https://link.to/content - ---- - -//////////////////////////// - -Your turn 👇 - -//////////////////////////// - -» 08-October-2024 by [adityadeshlahre](https://oss.gg/adityadeshlahre) Logo Link: [logo](https://drive.google.com/drive/folders/13k22xMnX2fhnWK94vas_hO1t-ImqXcHZ?usp=drive_link) » tweet Link: [tweet](https://x.com/adityadeshlahre/status/1843354963176718374) - -» 11-October-2024 by [thefool76](https://oss.gg/thefool76) Logo Link: [logo](https://drive.google.com/file/d/1DxSwNY_i90kGgWzPQj5SxScBz_6r02l4/view?usp=sharing) » tweet Link: [tweet](https://x.com/thefool1135/status/1844693487067034008) - -» 13-October-2024 by [Atharva_404](https://oss.gg/Atharva-3000) Logo Link: [logo](https://drive.google.com/drive/folders/1XB7ELR7kPA4x7Fx5RQr8wo5etdZAZgcs?usp=drive_link) » tweet Link: [tweet](https://x.com/0x_atharva/status/1845421218914095453) - -» 13-October-2024 by [Ionfinisher](https://oss.gg/Ionfinisher) Logo Link: [logo](https://drive.google.com/file/d/1l9vE8CIjW9KfdioI5WKzxrdmvO8LR4j7/view?usp=drive_link) » tweet Link: [tweet](https://x.com/ion_finisher/status/1845466470429442163) - - ---- diff --git a/oss-gg/twenty-design-challenges/3-create-custom-interfact-theme-20.md b/oss-gg/twenty-design-challenges/3-create-custom-interfact-theme-20.md deleted file mode 100644 index e51945ea9988..000000000000 --- a/oss-gg/twenty-design-challenges/3-create-custom-interfact-theme-20.md +++ /dev/null @@ -1,21 +0,0 @@ -**Side Quest**: Duplicate the Figma file from the main repo and customize the variables to create a unique interface theme for Twenty. -**Points**: 750 Points -**Proof**: Add your oss handle and Figma link to the list below. - -Please follow the following schema: - ---- - -» 05-April-2024 by YOUR oss.gg HANDLE » Figma Link: https://link.to/content - ---- - -//////////////////////////// - -Your turn 👇 - -//////////////////////////// - -» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) Figma Link: [Figma](https://twenty.com/) - ---- \ No newline at end of file diff --git a/oss-gg/twenty-dev-challenges/1-write-migration-script-other-crm-to-20.md b/oss-gg/twenty-dev-challenges/1-write-migration-script-other-crm-to-20.md deleted file mode 100644 index 249d8e158cfa..000000000000 --- a/oss-gg/twenty-dev-challenges/1-write-migration-script-other-crm-to-20.md +++ /dev/null @@ -1,21 +0,0 @@ -**Side Quest**: Develop a script to facilitate the migration of data from another CRM to Twenty. -**Points**: 750 Points -**Proof**: Add your oss handle and record video and share link to the list below. In video show the working proof of your created script. - -Please follow the following schema: - ---- - -» 05-April-2024 by YOUR oss.gg HANDLE » video Link: https://link.to/content - ---- - -//////////////////////////// - -Your turn 👇 - -//////////////////////////// - -» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) video Link: [video](https://twenty.com/) - ---- \ No newline at end of file diff --git a/oss-gg/twenty-dev-challenges/2-create-raycast-integration-for-20.md b/oss-gg/twenty-dev-challenges/2-create-raycast-integration-for-20.md deleted file mode 100644 index e4793c40d66f..000000000000 --- a/oss-gg/twenty-dev-challenges/2-create-raycast-integration-for-20.md +++ /dev/null @@ -1,21 +0,0 @@ -**Side Quest**: Develop an integration for Raycast that enables users to create records on any object within Twenty directly from Raycast. -**Points**: 1500 Points -**Proof**: Add your oss handle and record video and share link to the list below. In video show the workflow of the your integration created and perform some task. - -Please follow the following schema: - ---- - -» 05-April-2024 by YOUR oss.gg HANDLE » video Link: https://link.to/content - ---- - -//////////////////////////// - -Your turn 👇 - -//////////////////////////// - -» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) video Link: [video](https://twenty.com/) - ---- \ No newline at end of file diff --git a/oss-gg/twenty-no-code-challenges/1-create-n8n-template-integrate-20-API.md b/oss-gg/twenty-no-code-challenges/1-create-n8n-template-integrate-20-API.md deleted file mode 100644 index 6786e5a94553..000000000000 --- a/oss-gg/twenty-no-code-challenges/1-create-n8n-template-integrate-20-API.md +++ /dev/null @@ -1,21 +0,0 @@ -**Side Quest**: Create an n8n workflow that empowers Twenty by connecting it to another tool. -**Points**: 750 Points -**Proof**: Add your oss handle and template link to the list below. - -Please follow the following schema: - ---- - -» 05-April-2024 by YOUR oss.gg HANDLE » template Link: https://link.to/content - ---- - -//////////////////////////// - -Your turn 👇 - -//////////////////////////// - -» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) template Link: [template](https://twenty.com/) - ---- \ No newline at end of file diff --git a/oss-gg/twenty-no-code-challenges/2-write-selfthost-guide-blog-post-20.md b/oss-gg/twenty-no-code-challenges/2-write-selfthost-guide-blog-post-20.md deleted file mode 100644 index 58fa6de4d8d6..000000000000 --- a/oss-gg/twenty-no-code-challenges/2-write-selfthost-guide-blog-post-20.md +++ /dev/null @@ -1,21 +0,0 @@ -**Side Quest**: Write a comprehensive guide on how to integrate Twenty with marketing automation tool (n8n, Zapier). Include a concrete use case and explain how to leverage AI to write API requests for non-developers and share it. -**Points**: 1500 Points -**Proof**: Add your oss handle and guide link to the list below. - -Please follow the following schema: - ---- - -» 05-April-2024 by YOUR oss.gg HANDLE » guide Link: https://link.to/content - ---- - -//////////////////////////// - -Your turn 👇 - -//////////////////////////// - -» 02-October-2024 by [yourhandle](https://oss.gg/yourhandle) guide Link: [guide](https://twenty.com/) - ---- \ No newline at end of file diff --git a/oss-gg/twenty-side-quest/1-quote-tweet-20-oss-gg-launch.md b/oss-gg/twenty-side-quest/1-quote-tweet-20-oss-gg-launch.md deleted file mode 100644 index 168e311b9a32..000000000000 --- a/oss-gg/twenty-side-quest/1-quote-tweet-20-oss-gg-launch.md +++ /dev/null @@ -1,45 +0,0 @@ -**Side Quest**: Like & Re-Tweet oss.gg Launch Tweet. Quote-tweet it tagging @twentycrm to say you’ll be contributing. -**Points**: 50 Points -**Proof**: Add a screenshot of the retweet to the PR description. Add a link to your retweet in the list below. - -Please follow the following schema: - ---- - -» 05-April-2024 by YOUR NAME -» Link to Tweet: https://x.com/... - ---- - -//////////////////////////// - -Your turn 👇 - -//////////////////////////// - -» 13-October-2024 by Vanshika Dargan -» Link to Tweet: https://x.com/VanshikaDargan/status/1845467453108949123 - -» 13-October-2024 by Utsav Bhattarai -» Link to Tweet: https://x.com/utsavdotdev/status/1845417863462649900 - -» 10-October-2024 by Devansh Baghel -» Link to Tweet: https://x.com/DevanshBaghel5/status/1844359648037748954 - -» 11-October-2024 by Bhavesh Mishra -» Link to Tweet: https://x.com/thefool1135/status/1844453425188405326 - -» 11-October-2024 by Chirag Arora -» Link to Tweet: https://x.com/Chirag8023/status/1844689900668682699 - -» 11-October-2024 by Aritra Sadhukhan -» Link to Tweet: https://x.com/AritraDevelops/status/1844670236512878646 - -» 13-October-2024 by Nabhag Motivaras -» Link to Tweet: https://x.com/NabhagMotivaras/status/1845449144695218357 - -» 13-October-2024 by Ali Yar Khan -» Link to Tweet: https://x.com/Mr_Programmer14/status/1845527862549577860 - -» 13-October-2024 by Yash Parmar -» Link to Tweet: https://x.com/yashp3020/status/1845720834716959009 diff --git a/oss-gg/twenty-side-quest/2-tweet-about-fav-twenty-feature.md b/oss-gg/twenty-side-quest/2-tweet-about-fav-twenty-feature.md deleted file mode 100644 index 508210ae5a6e..000000000000 --- a/oss-gg/twenty-side-quest/2-tweet-about-fav-twenty-feature.md +++ /dev/null @@ -1,28 +0,0 @@ -**Side Quest**: Share a tweet about your favorite feature in Twenty. Tweet about your favorite feature in Twenty and mention @twentycrm. -**Points**: 50 Points -**Proof**: Add a screenshot of the tweet to the PR description. Add a link to your tweet in the list below. - -Please follow the following schema: - ---- - -» 05-April-2024 by YOUR NAME -» Link to Tweet: https://x.com/... - ---- - -//////////////////////////// - -Your turn 👇 - -//////////////////////////// - -» 10-October-2024 by Devansh Baghel -» Link to Tweet: https://x.com/DevanshBaghel5/status/1844384722119704972 - -» 11-October-2024 by Bhavesh Mishra -» Link to Tweet: https://x.com/thefool1135/status/1844456500380696969 - -» 13-October-2024 by Ali Yar Khan -» Link to Tweet: https://x.com/Mr_Programmer14/status/1845530448245711197 ---- diff --git a/oss-gg/twenty-side-quest/3-bug-report.md b/oss-gg/twenty-side-quest/3-bug-report.md deleted file mode 100644 index d393a2cbeac8..000000000000 --- a/oss-gg/twenty-side-quest/3-bug-report.md +++ /dev/null @@ -1,23 +0,0 @@ -**Side Quest**: Create a bug report. Use the Twenty bug issue template to report a bug in detail, including steps to reproduce it. -**Points**: 50-150 Points -**Proof**: Add a link to your bug report in the list below. - -Please follow the following schema: - ---- - -» 05-April-2024 by YOUR NAME -» Link to bug report: https://github.com/twentyhq/twenty/issues/... - ---- - -//////////////////////////// - -Your turn 👇 - -//////////////////////////// - -» 10-October-2024 by Devansh Baghel -» Link to bug report: https://github.com/twentyhq/twenty/issues/7560 - ---- diff --git a/oss-gg/twenty-side-quest/4-meme-magic.md b/oss-gg/twenty-side-quest/4-meme-magic.md deleted file mode 100644 index b5cb3263d218..000000000000 --- a/oss-gg/twenty-side-quest/4-meme-magic.md +++ /dev/null @@ -1,34 +0,0 @@ -**Side Quest**: Meme Magic: Craft a meme where the number twenty plays a role. Tweet it, and tag @twentycrm. -**Points**: 150 Points -**Proof**: Add a screenshot of meme to the PR description. Add a link to your tweet in the list below. - -Please follow the following schema: - ---- - -» 05-April-2024 by YOUR NAME -» Link to Tweet: https://x.com/... - ---- - -//////////////////////////// - -Your turn 👇 - -//////////////////////////// - -» 10-October-2024 by Teddy ASSIH -» Link to Tweet: https://x.com/ion_finisher/status/1844389252253299173 - -» 11-October-2024 by Bhavesh Mishra -» Link to Tweet: https://x.com/thefool1135/status/1844458836402503931 - -» 12-October-2024 by Chirag Arora -» Link to Tweet: https://x.com/Chirag8023/status/1845108226527994222 - -» 13-October-2024 by Ali Yar Khan -» Link to Tweet: https://x.com/Mr_Programmer14/status/1845537662587072697 - -» 14-October-2024 by Yash Parmar -» Link to Tweet: [https://x.com/yashp3020/status/1845108226527994222](https://x.com/yashp3020/status/1845720142702842093) ---- diff --git a/oss-gg/twenty-side-quest/5-gif-magic.md b/oss-gg/twenty-side-quest/5-gif-magic.md deleted file mode 100644 index 20467fef4784..000000000000 --- a/oss-gg/twenty-side-quest/5-gif-magic.md +++ /dev/null @@ -1,33 +0,0 @@ -**Side Quest**: Gif Magic: Create a gif related to Twenty. Tweet it, and tag @twentycrm. -**Points**: 150 Points -**Proof**: Add a screenshot of GIF on Giphy to the PR description. Add a link to your GIPHY in the list below. - -Please follow the following schema: - ---- - -» 05-April-2024 by YOUR NAME -» Link to gif: https://giphy.com/... - ---- - -//////////////////////////// - -Your turn 👇 - -//////////////////////////// - -» 10-October-2024 by Teddy ASSIH -» Link to gif: https://giphy.com/gifs/oss-crm-twenty-VWDHAIlGTbc6Nqdza9 - -» 11-October-2024 by Bhavesh Mishra -» Link to gif: https://shorturl.at/yln9H - -» 12-October-2024 by Chirag Arora -» Link to gif: https://giphy.com/gifs/yCJIS2MGbBdifbnuj0 - -» 13-October-2024 by Nabhag Motivaras -» Link to gif: https://giphy.com/gifs/twenty-twentycrm-opensourcecrm-wCcsmnJuzzzGrfuf9B - - ---- diff --git a/oss-gg/twenty-side-quest/6-quest-wizard.md b/oss-gg/twenty-side-quest/6-quest-wizard.md deleted file mode 100644 index 9543e3767d6f..000000000000 --- a/oss-gg/twenty-side-quest/6-quest-wizard.md +++ /dev/null @@ -1,19 +0,0 @@ -**Side Quest**: Complete all Twenty side quests -**Points**: 300 Points -**Proof**: Add screenshots for each side quest to the PR description. Add your name to the list below. - -Please follow the following schema: - ---- - - » 05-April-2024 by YOUR NAME - -//////////////////////////// - -Your turn 👇 - -//////////////////////////// - -» 01-October-2024 by X - ---- diff --git a/package.json b/package.json index a4dc90df92ac..c9c7f6f1ef34 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@linaria/core": "^6.2.0", "@linaria/react": "^6.2.1", "@mdx-js/react": "^3.0.0", + "@microsoft/microsoft-graph-client": "^3.0.7", "@nestjs/apollo": "^11.0.5", "@nestjs/axios": "^3.0.1", "@nestjs/cli": "^9.0.0", @@ -49,6 +50,7 @@ "@stoplight/elements": "^8.0.5", "@swc/jest": "^0.2.29", "@tabler/icons-react": "^2.44.0", + "@tiptap/extension-hard-break": "^2.9.1", "@types/dompurify": "^3.0.5", "@types/facepaint": "^1.2.5", "@types/lodash.camelcase": "^4.3.7", @@ -176,6 +178,7 @@ "scroll-into-view": "^1.16.2", "semver": "^7.5.4", "sharp": "^0.32.1", + "slash": "^5.1.0", "stripe": "^14.17.0", "ts-key-enum": "^2.0.12", "tslib": "^2.3.0", @@ -199,6 +202,7 @@ "@graphql-codegen/typescript": "^3.0.4", "@graphql-codegen/typescript-operations": "^3.0.4", "@graphql-codegen/typescript-react-apollo": "^3.3.7", + "@microsoft/microsoft-graph-types": "^2.40.0", "@nestjs/cli": "^9.0.0", "@nestjs/schematics": "^9.0.0", "@nestjs/testing": "^9.0.0", @@ -294,6 +298,7 @@ "eslint-plugin-jsx-a11y": "^6.8.0", "eslint-plugin-prefer-arrow": "^1.2.3", "eslint-plugin-prettier": "^5.1.2", + "eslint-plugin-project-structure": "^3.9.1", "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.4", @@ -347,7 +352,7 @@ "version": "0.2.1", "nx": {}, "scripts": { - "start": "npx nx run-many -t start -p twenty-server twenty-front" + "start": "npx concurrently --kill-others 'npx nx run-many -t start -p twenty-server twenty-front' 'npx wait-on tcp:3000 && npx nx run twenty-server:worker'" }, "workspaces": { "packages": [ diff --git a/packages/twenty-chrome-extension/src/options/Loading.tsx b/packages/twenty-chrome-extension/src/options/Loading.tsx index 1fde24f2b9e5..a0543c9c6dad 100644 --- a/packages/twenty-chrome-extension/src/options/Loading.tsx +++ b/packages/twenty-chrome-extension/src/options/Loading.tsx @@ -1,6 +1,5 @@ -import styled from '@emotion/styled'; - import { Loader } from '@/ui/display/loader/components/Loader'; +import styled from '@emotion/styled'; const StyledContainer = styled.div` align-items: center; diff --git a/packages/twenty-docker/.env.example b/packages/twenty-docker/.env.example index 59d8d03f93a7..b10e09876e52 100644 --- a/packages/twenty-docker/.env.example +++ b/packages/twenty-docker/.env.example @@ -3,16 +3,12 @@ TAG=latest # POSTGRES_ADMIN_PASSWORD=replace_me_with_a_strong_password PG_DATABASE_HOST=db:5432 +REDIS_URL=redis://redis:6379 SERVER_URL=http://localhost:3000 -# REDIS_HOST=redis -# REDIS_PORT=6379 # Use openssl rand -base64 32 for each secret -# ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access -# LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login -# REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh -# FILE_TOKEN_SECRET=replace_me_with_a_random_string_refresh +# APP_SECRET=replace_me_with_a_random_string SIGN_IN_PREFILLED=true diff --git a/packages/twenty-docker/docker-compose.yml b/packages/twenty-docker/docker-compose.yml index b2efc1a168e4..41d80dabc398 100644 --- a/packages/twenty-docker/docker-compose.yml +++ b/packages/twenty-docker/docker-compose.yml @@ -25,8 +25,7 @@ services: PG_DATABASE_URL: postgres://twenty:twenty@${PG_DATABASE_HOST}/default SERVER_URL: ${SERVER_URL} FRONT_BASE_URL: ${FRONT_BASE_URL:-$SERVER_URL} - REDIS_PORT: ${REDIS_PORT:-6379} - REDIS_HOST: ${REDIS_HOST:-redis} + REDIS_URL: ${REDIS_URL:-redis://localhost:6379} ENABLE_DB_MIGRATIONS: "true" @@ -36,10 +35,7 @@ services: STORAGE_S3_NAME: ${STORAGE_S3_NAME} STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT} - ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET} - LOGIN_TOKEN_SECRET: ${LOGIN_TOKEN_SECRET} - REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET} - FILE_TOKEN_SECRET: ${FILE_TOKEN_SECRET} + APP_SECRET: ${APP_SECRET} depends_on: change-vol-ownership: condition: service_completed_successfully @@ -59,8 +55,7 @@ services: PG_DATABASE_URL: postgres://twenty:twenty@${PG_DATABASE_HOST}/default SERVER_URL: ${SERVER_URL} FRONT_BASE_URL: ${FRONT_BASE_URL:-$SERVER_URL} - REDIS_PORT: ${REDIS_PORT:-6379} - REDIS_HOST: ${REDIS_HOST:-redis} + REDIS_URL: ${REDIS_URL:-redis://localhost:6379} ENABLE_DB_MIGRATIONS: "false" # it already runs on the server @@ -69,10 +64,7 @@ services: STORAGE_S3_NAME: ${STORAGE_S3_NAME} STORAGE_S3_ENDPOINT: ${STORAGE_S3_ENDPOINT} - ACCESS_TOKEN_SECRET: ${ACCESS_TOKEN_SECRET} - LOGIN_TOKEN_SECRET: ${LOGIN_TOKEN_SECRET} - REFRESH_TOKEN_SECRET: ${REFRESH_TOKEN_SECRET} - FILE_TOKEN_SECRET: ${FILE_TOKEN_SECRET} + APP_SECRET: ${APP_SECRET} depends_on: db: condition: service_healthy diff --git a/packages/twenty-docker/k8s/manifests/deployment-server.yaml b/packages/twenty-docker/k8s/manifests/deployment-server.yaml index b1229d649bbb..857729788675 100644 --- a/packages/twenty-docker/k8s/manifests/deployment-server.yaml +++ b/packages/twenty-docker/k8s/manifests/deployment-server.yaml @@ -41,10 +41,8 @@ spec: value: "https://crm.example.com:443" - name: "PG_DATABASE_URL" value: "postgres://twenty:twenty@twenty-db.twentycrm.svc.cluster.local/default" - - name: "REDIS_HOST" - value: "twentycrm-redis.twentycrm.svc.cluster.local" - - name: "REDIS_PORT" - value: 6379 + - name: "REDIS_URL" + value: "redis://twentycrm-redis.twentycrm.svc.cluster.local:6379" - name: ENABLE_DB_MIGRATIONS value: "true" - name: SIGN_IN_PREFILLED @@ -57,26 +55,11 @@ spec: value: "7d" - name: "LOGIN_TOKEN_EXPIRES_IN" value: "1h" - - name: ACCESS_TOKEN_SECRET + - name: APP_SECRET valueFrom: secretKeyRef: name: tokens key: accessToken - - name: LOGIN_TOKEN_SECRET - valueFrom: - secretKeyRef: - name: tokens - key: loginToken - - name: REFRESH_TOKEN_SECRET - valueFrom: - secretKeyRef: - name: tokens - key: refreshToken - - name: FILE_TOKEN_SECRET - valueFrom: - secretKeyRef: - name: tokens - key: fileToken ports: - containerPort: 3000 name: http-tcp diff --git a/packages/twenty-docker/k8s/manifests/deployment-worker.yaml b/packages/twenty-docker/k8s/manifests/deployment-worker.yaml index b3a7e07a19aa..eb1938ba6dda 100644 --- a/packages/twenty-docker/k8s/manifests/deployment-worker.yaml +++ b/packages/twenty-docker/k8s/manifests/deployment-worker.yaml @@ -40,30 +40,13 @@ spec: value: "bull-mq" - name: "CACHE_STORAGE_TYPE" value: "redis" - - name: "REDIS_HOST" - value: "twentycrm-redis.twentycrm.svc.cluster.local" - - name: "REDIS_PORT" - value: 6379 - - name: ACCESS_TOKEN_SECRET + - name: "REDIS_URL" + value: "redis://twentycrm-redis.twentycrm.svc.cluster.local:6379" + - name: APP_SECRET valueFrom: secretKeyRef: name: tokens key: accessToken - - name: LOGIN_TOKEN_SECRET - valueFrom: - secretKeyRef: - name: tokens - key: loginToken - - name: REFRESH_TOKEN_SECRET - valueFrom: - secretKeyRef: - name: tokens - key: refreshToken - - name: FILE_TOKEN_SECRET - valueFrom: - secretKeyRef: - name: tokens - key: fileToken command: - yarn - worker:prod diff --git a/packages/twenty-docker/k8s/terraform/deployment-server.tf b/packages/twenty-docker/k8s/terraform/deployment-server.tf index 1868b17624da..5276d574319e 100644 --- a/packages/twenty-docker/k8s/terraform/deployment-server.tf +++ b/packages/twenty-docker/k8s/terraform/deployment-server.tf @@ -61,12 +61,8 @@ resource "kubernetes_deployment" "twentycrm_server" { value = "postgres://twenty:${var.twentycrm_pgdb_admin_password}@${kubernetes_service.twentycrm_db.metadata.0.name}.${kubernetes_namespace.twentycrm.metadata.0.name}.svc.cluster.local/default" } env { - name = "REDIS_HOST" - value = "${kubernetes_service.twentycrm_redis.metadata.0.name}.${kubernetes_namespace.twentycrm.metadata.0.name}.svc.cluster.local" - } - env { - name = "REDIS_PORT" - value = 6379 + name = "REDIS_URL" + value = "redis://${kubernetes_service.twentycrm_redis.metadata.0.name}.${kubernetes_namespace.twentycrm.metadata.0.name}.svc.cluster.local:6379" } env { name = "ENABLE_DB_MIGRATIONS" @@ -95,7 +91,7 @@ resource "kubernetes_deployment" "twentycrm_server" { value = "1h" } env { - name = "ACCESS_TOKEN_SECRET" + name = "APP_SECRET" value_from { secret_key_ref { name = "tokens" @@ -104,36 +100,6 @@ resource "kubernetes_deployment" "twentycrm_server" { } } - env { - name = "LOGIN_TOKEN_SECRET" - value_from { - secret_key_ref { - name = "tokens" - key = "loginToken" - } - } - } - - env { - name = "REFRESH_TOKEN_SECRET" - value_from { - secret_key_ref { - name = "tokens" - key = "refreshToken" - } - } - } - - env { - name = "FILE_TOKEN_SECRET" - value_from { - secret_key_ref { - name = "tokens" - key = "fileToken" - } - } - } - port { container_port = 3000 protocol = "TCP" diff --git a/packages/twenty-docker/k8s/terraform/deployment-worker.tf b/packages/twenty-docker/k8s/terraform/deployment-worker.tf index 78e5ea6dcc1d..aa68fd3af2da 100644 --- a/packages/twenty-docker/k8s/terraform/deployment-worker.tf +++ b/packages/twenty-docker/k8s/terraform/deployment-worker.tf @@ -59,13 +59,8 @@ resource "kubernetes_deployment" "twentycrm_worker" { } env { - name = "REDIS_HOST" - value = "${kubernetes_service.twentycrm_redis.metadata.0.name}.${kubernetes_namespace.twentycrm.metadata.0.name}.svc.cluster.local" - } - - env { - name = "REDIS_PORT" - value = 6379 + name = "REDIS_URL" + value = "redis://${kubernetes_service.twentycrm_redis.metadata.0.name}.${kubernetes_namespace.twentycrm.metadata.0.name}.svc.cluster.local:6379" } env { @@ -83,7 +78,7 @@ resource "kubernetes_deployment" "twentycrm_worker" { } env { - name = "ACCESS_TOKEN_SECRET" + name = "APP_SECRET" value_from { secret_key_ref { name = "tokens" @@ -92,36 +87,6 @@ resource "kubernetes_deployment" "twentycrm_worker" { } } - env { - name = "LOGIN_TOKEN_SECRET" - value_from { - secret_key_ref { - name = "tokens" - key = "loginToken" - } - } - } - - env { - name = "REFRESH_TOKEN_SECRET" - value_from { - secret_key_ref { - name = "tokens" - key = "refreshToken" - } - } - } - - env { - name = "FILE_TOKEN_SECRET" - value_from { - secret_key_ref { - name = "tokens" - key = "fileToken" - } - } - } - resources { requests = { cpu = "250m" diff --git a/packages/twenty-docker/twenty-postgres-spilo/Dockerfile b/packages/twenty-docker/twenty-postgres-spilo/Dockerfile index 21e107c477bb..a87a8a97ec12 100644 --- a/packages/twenty-docker/twenty-postgres-spilo/Dockerfile +++ b/packages/twenty-docker/twenty-postgres-spilo/Dockerfile @@ -1,6 +1,5 @@ ARG POSTGRES_VERSION=15 ARG SPILO_VERSION=3.2-p1 -ARG PG_GRAPHQL_VERSION=1.5.6 ARG WRAPPERS_VERSION=0.2.0 # Build the mysql_fdw extension @@ -38,10 +37,9 @@ WORKDIR /build/openssl RUN ./config && make && make install -# Extend the Spilo image with the pg_graphql and mysql_fdw extensions +# Extend the Spilo image with the mysql_fdw extensions FROM ghcr.io/zalando/spilo-${POSTGRES_VERSION}:${SPILO_VERSION} ARG POSTGRES_VERSION -ARG PG_GRAPHQL_VERSION ARG WRAPPERS_VERSION ARG TARGETARCH @@ -63,14 +61,6 @@ RUN curl -L "https://github.com/supabase/wrappers/releases/download/v${WRAPPERS_ COPY --from=build-libssl /usr/local/lib/libssl* /usr/local/lib/libcrypto* /usr/lib/ COPY --from=build-libssl /usr/local/lib/engines-1.1 /usr/lib/engines-1.1 -# Copy pg_graphql -COPY ./packages/twenty-postgres/linux/${TARGETARCH}/${POSTGRES_VERSION}/pg_graphql/${PG_GRAPHQL_VERSION}/pg_graphql--${PG_GRAPHQL_VERSION}.sql \ - /usr/share/postgresql/${POSTGRES_VERSION}/extension -COPY ./packages/twenty-postgres/linux/${TARGETARCH}/${POSTGRES_VERSION}/pg_graphql/${PG_GRAPHQL_VERSION}/pg_graphql.control \ - /usr/share/postgresql/${POSTGRES_VERSION}/extension -COPY ./packages/twenty-postgres/linux/${TARGETARCH}/${POSTGRES_VERSION}/pg_graphql/${PG_GRAPHQL_VERSION}/pg_graphql.so \ - /usr/lib/postgresql/${POSTGRES_VERSION}/lib/pg_graphql.so - # Copy mysql_fdw COPY --from=build-mysql_fdw /mysql_fdw/mysql_fdw.so \ /usr/lib/postgresql/${POSTGRES_VERSION}/lib/mysql_fdw.so diff --git a/packages/twenty-docker/twenty-postgres/Dockerfile b/packages/twenty-docker/twenty-postgres/Dockerfile index 5647a6cd35ac..9c9b96398e66 100644 --- a/packages/twenty-docker/twenty-postgres/Dockerfile +++ b/packages/twenty-docker/twenty-postgres/Dockerfile @@ -3,7 +3,6 @@ ARG IMAGE_TAG='15.5.0-debian-11-r15' FROM bitnami/postgresql:${IMAGE_TAG} ARG PG_MAIN_VERSION=15 -ARG PG_GRAPHQL_VERSION=1.5.6 ARG WRAPPERS_VERSION=0.2.0 ARG TARGETARCH @@ -26,14 +25,6 @@ RUN set -eux; \ RUN apt update && apt install build-essential git curl default-libmysqlclient-dev -y -# Install precompiled pg_graphql extensions -COPY ./packages/twenty-postgres/linux/${TARGETARCH}/${PG_MAIN_VERSION}/pg_graphql/${PG_GRAPHQL_VERSION}/pg_graphql--${PG_GRAPHQL_VERSION}.sql \ - /opt/bitnami/postgresql/share/extension/ -COPY ./packages/twenty-postgres/linux/${TARGETARCH}/${PG_MAIN_VERSION}/pg_graphql/${PG_GRAPHQL_VERSION}/pg_graphql.control \ - /opt/bitnami/postgresql/share/extension/ -COPY ./packages/twenty-postgres/linux/${TARGETARCH}/${PG_MAIN_VERSION}/pg_graphql/${PG_GRAPHQL_VERSION}/pg_graphql.so \ - /opt/bitnami/postgresql/lib/ - # Install precompiled supabase wrappers extensions RUN curl -L "https://github.com/supabase/wrappers/releases/download/v${WRAPPERS_VERSION}/wrappers-v${WRAPPERS_VERSION}-pg${PG_MAIN_VERSION}-${TARGETARCH}-linux-gnu.deb" -o wrappers.deb RUN dpkg --install wrappers.deb diff --git a/packages/twenty-docker/twenty-website/Dockerfile b/packages/twenty-docker/twenty-website/Dockerfile new file mode 100644 index 000000000000..e3b7420ff76a --- /dev/null +++ b/packages/twenty-docker/twenty-website/Dockerfile @@ -0,0 +1,29 @@ +FROM node:18.17.1-alpine as twenty-website-build + + +WORKDIR /app + +COPY ./package.json . +COPY ./yarn.lock . +COPY ./.yarnrc.yml . +COPY ./.yarn/releases /app/.yarn/releases +COPY ./tools/eslint-rules /app/tools/eslint-rules +COPY ./packages/twenty-website/package.json /app/packages/twenty-website/package.json + +RUN yarn + +COPY ./packages/twenty-website /app/packages/twenty-website +RUN npx nx build twenty-website + +FROM node:18.17.1-alpine as twenty-website + +WORKDIR /app/packages/twenty-website + +COPY --from=twenty-website-build /app /app + +WORKDIR /app/packages/twenty-website + +LABEL org.opencontainers.image.source=https://github.com/twentyhq/twenty +LABEL org.opencontainers.image.description="This image provides a consistent and reproducible environment for the website." + +CMD ["/bin/sh", "-c", "npx nx start"] \ No newline at end of file diff --git a/packages/twenty-e2e-testing/drivers/env_variables.ts b/packages/twenty-e2e-testing/drivers/env_variables.ts new file mode 100644 index 000000000000..2bb7f57d88fb --- /dev/null +++ b/packages/twenty-e2e-testing/drivers/env_variables.ts @@ -0,0 +1,22 @@ +import * as fs from 'fs'; +import path from 'path'; + +export const envVariables = (variables: string) => { + let payload = ` + PG_DATABASE_URL=postgres://twenty:twenty@localhost:5432/default + FRONT_BASE_URL=http://localhost:3001 + ACCESS_TOKEN_SECRET=replace_me_with_a_random_string_access + LOGIN_TOKEN_SECRET=replace_me_with_a_random_string_login + REFRESH_TOKEN_SECRET=replace_me_with_a_random_string_refresh + FILE_TOKEN_SECRET=replace_me_with_a_random_string_refresh + REDIS_URL=redis://localhost:6379 + `; + payload = payload.concat(variables); + fs.writeFile( + path.join(__dirname, '..', '..', 'twenty-server', '.env'), + payload, + (err) => { + throw err; + }, + ); +}; diff --git a/packages/twenty-e2e-testing/lib/pom/helper/confirmationModal.ts b/packages/twenty-e2e-testing/lib/pom/helper/confirmationModal.ts new file mode 100644 index 000000000000..225c6733b49c --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/helper/confirmationModal.ts @@ -0,0 +1,36 @@ +import { Locator, Page } from '@playwright/test'; + +export class ConfirmationModal { + private readonly input: Locator; + private readonly cancelButton: Locator; + private readonly confirmButton: Locator; + + constructor(public readonly page: Page) { + this.page = page; + this.input = page.getByTestId('confirmation-modal-input'); + this.cancelButton = page.getByRole('button', { name: 'Cancel' }); + this.confirmButton = page.getByTestId('confirmation-modal-confirm-button'); + } + + async typePlaceholderToInput() { + await this.page + .getByTestId('confirmation-modal-input') + .fill( + await this.page + .getByTestId('confirmation-modal-input') + .getAttribute('placeholder'), + ); + } + + async typePhraseToInput(value: string) { + await this.page.getByTestId('confirmation-modal-input').fill(value); + } + + async clickCancelButton() { + await this.page.getByRole('button', { name: 'Cancel' }).click(); + } + + async clickConfirmButton() { + await this.page.getByTestId('confirmation-modal-confirm-button').click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/helper/formatDate.function.ts b/packages/twenty-e2e-testing/lib/pom/helper/formatDate.function.ts new file mode 100644 index 000000000000..bffa490e80f4 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/helper/formatDate.function.ts @@ -0,0 +1,28 @@ +const nth = (d: number) => { + if (d > 3 && d < 21) return 'th'; + switch (d % 10) { + case 1: + return 'st'; + case 2: + return 'nd'; + case 3: + return 'rd'; + default: + return 'th'; + } +}; + +// label looks like this: Choose Wednesday, October 30th, 2024 +// eslint-disable-next-line prefer-arrow/prefer-arrow-functions +export function formatDate(value: string): string { + const date = new Date(value); + return 'Choose '.concat( + date.toLocaleDateString('en-US', { weekday: 'long' }), + ', ', + date.toLocaleDateString('en-US', { month: 'long' }), + ' ', + nth(date.getDate()), + ', ', + date.toLocaleDateString('en-US', { year: 'numeric' }), + ); +} diff --git a/packages/twenty-e2e-testing/lib/pom/helper/googleLogin.ts b/packages/twenty-e2e-testing/lib/pom/helper/googleLogin.ts new file mode 100644 index 000000000000..ca57fd6361f5 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/helper/googleLogin.ts @@ -0,0 +1,6 @@ +import { Locator, Page } from '@playwright/test'; + +export class GoogleLogin { + // TODO: map all things like inputs and buttons + // (what's the correct way for proceeding with Google interaction? log in each time test is performed?) +} diff --git a/packages/twenty-e2e-testing/lib/pom/helper/iconSelect.ts b/packages/twenty-e2e-testing/lib/pom/helper/iconSelect.ts new file mode 100644 index 000000000000..82b30c53c7af --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/helper/iconSelect.ts @@ -0,0 +1,23 @@ +import { Locator, Page } from '@playwright/test'; + +export class IconSelect { + private readonly iconSelectButton: Locator; + private readonly iconSearchInput: Locator; + + constructor(public readonly page: Page) { + this.iconSelectButton = page.getByLabel('Click to select icon ('); + this.iconSearchInput = page.getByPlaceholder('Search icon'); + } + + async selectIcon(name: string) { + await this.iconSelectButton.click(); + await this.iconSearchInput.fill(name); + await this.page.getByTitle(name).click(); + } + + async selectRelationIcon(name: string) { + await this.iconSelectButton.nth(1).click(); + await this.iconSearchInput.fill(name); + await this.page.getByTitle(name).click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/helper/insertFieldData.ts b/packages/twenty-e2e-testing/lib/pom/helper/insertFieldData.ts new file mode 100644 index 000000000000..a052eff68c03 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/helper/insertFieldData.ts @@ -0,0 +1,267 @@ +import { Locator, Page } from '@playwright/test'; +import { formatDate } from './formatDate.function'; + +export class InsertFieldData { + private readonly address1Input: Locator; + private readonly address2Input: Locator; + private readonly cityInput: Locator; + private readonly stateInput: Locator; + private readonly postCodeInput: Locator; + private readonly countrySelect: Locator; + private readonly arrayValueInput: Locator; + private readonly arrayAddValueButton: Locator; + // boolean react after click so no need to write special locator + private readonly currencySelect: Locator; + private readonly currencyAmountInput: Locator; + private readonly monthSelect: Locator; + private readonly yearSelect: Locator; + private readonly previousMonthButton: Locator; + private readonly nextMonthButton: Locator; + private readonly clearDateButton: Locator; + private readonly dateInput: Locator; + private readonly firstNameInput: Locator; + private readonly lastNameInput: Locator; + private readonly addURLButton: Locator; + private readonly setAsPrimaryButton: Locator; + private readonly addPhoneButton: Locator; + private readonly addMailButton: Locator; + + constructor(public readonly page: Page) { + this.page = page; + this.address1Input = page.locator( + '//label[contains(., "ADDRESS 1")]/../div[last()]/input', + ); + this.address2Input = page.locator( + '//label[contains(., "ADDRESS 2")]/../div[last()]/input', + ); + this.cityInput = page.locator( + '//label[contains(., "CITY")]/../div[last()]/input', + ); + this.stateInput = page.locator( + '//label[contains(., "STATE")]/../div[last()]/input', + ); + this.postCodeInput = page.locator( + '//label[contains(., "POST CODE")]/../div[last()]/input', + ); + this.countrySelect = page.locator( + '//span[contains(., "COUNTRY")]/../div[last()]/input', + ); + this.arrayValueInput = page.locator("//input[@placeholder='Enter value']"); + this.arrayAddValueButton = page.locator( + "//div[@data-testid='tooltip' and contains(.,'Add item')]", + ); + this.currencySelect = page.locator( + '//body/div[last()]/div/div/div[first()]/div/div', + ); + this.currencyAmountInput = page.locator("//input[@placeholder='Currency']"); + this.monthSelect; // TODO: add once some other attributes are added + this.yearSelect; + this.previousMonthButton; + this.nextMonthButton; + this.clearDateButton = page.locator( + "//div[@data-testid='tooltip' and contains(., 'Clear')]", + ); + this.dateInput = page.locator("//input[@placeholder='Type date and time']"); + this.firstNameInput = page.locator("//input[@placeholder='First name']"); // may fail if placeholder is `F‌‌irst name` instead of `First name` + this.lastNameInput = page.locator("//input[@placeholder='Last name']"); // may fail if placeholder is `L‌‌ast name` instead of `Last name` + this.addURLButton = page.locator( + "//div[@data-testid='tooltip' and contains(., 'Add URL')]", + ); + this.setAsPrimaryButton = page.locator( + "//div[@data-testid='tooltip' and contains(., 'Set as primary')]", + ); + this.addPhoneButton = page.locator( + "//div[@data-testid='tooltip' and contains(., 'Add Phone')]", + ); + this.addMailButton = page.locator( + "//div[@data-testid='tooltip' and contains(., 'Add Email')]", + ); + } + + // address + async typeAddress1(value: string) { + await this.address1Input.fill(value); + } + + async typeAddress2(value: string) { + await this.address2Input.fill(value); + } + + async typeCity(value: string) { + await this.cityInput.fill(value); + } + + async typeState(value: string) { + await this.stateInput.fill(value); + } + + async typePostCode(value: string) { + await this.postCodeInput.fill(value); + } + + async selectCountry(value: string) { + await this.countrySelect.click(); + await this.page + .locator(`//div[@data-testid='tooltip' and contains(., '${value}')]`) + .click(); + } + + // array + async typeArrayValue(value: string) { + await this.arrayValueInput.fill(value); + } + + async clickAddItemButton() { + await this.arrayAddValueButton.click(); + } + + // currency + async selectCurrency(value: string) { + await this.currencySelect.click(); + await this.page + .locator(`//div[@data-testid='tooltip' and contains(., '${value}')]`) + .click(); + } + + async typeCurrencyAmount(value: string) { + await this.currencyAmountInput.fill(value); + } + + // date(-time) + async typeDate(value: string) { + await this.dateInput.fill(value); + } + + async selectMonth(value: string) { + await this.monthSelect.click(); + await this.page + .locator(`//div[@data-testid='tooltip' and contains(., '${value}')]`) + .click(); + } + + async selectYear(value: string) { + await this.yearSelect.click(); + await this.page + .locator(`//div[@data-testid='tooltip' and contains(., '${value}')]`) + .click(); + } + + async clickPreviousMonthButton() { + await this.previousMonthButton.click(); + } + + async clickNextMonthButton() { + await this.nextMonthButton.click(); + } + + async selectDay(value: string) { + await this.page + .locator(`//div[@aria-label='${formatDate(value)}']`) + .click(); + } + + async clearDate() { + await this.clearDateButton.click(); + } + + // email + async typeEmail(value: string) { + await this.page.locator(`//input[@placeholder='Email']`).fill(value); + } + + async clickAddMailButton() { + await this.addMailButton.click(); + } + + // full name + async typeFirstName(name: string) { + await this.firstNameInput.fill(name); + } + + async typeLastName(name: string) { + await this.lastNameInput.fill(name); + } + + // JSON + // placeholder is dependent on the name of field + async typeJSON(placeholder: string, value: string) { + await this.page + .locator(`//input[@placeholder='${placeholder}']`) + .fill(value); + } + + // link + async typeLink(value: string) { + await this.page.locator("//input[@placeholder='URL']").fill(value); + } + + async clickAddURL() { + await this.addURLButton.click(); + } + + // (multi-)select + async selectValue(value: string) { + await this.page + .locator(`//div[@data-testid='tooltip' and contains(., '${value}')]`) + .click(); + } + + // number + // placeholder is dependent on the name of field + async typeNumber(placeholder: string, value: string) { + await this.page + .locator(`//input[@placeholder='${placeholder}']`) + .fill(value); + } + + // phones + async selectCountryPhoneCode(countryCode: string) { + await this.page + .locator( + `//div[@data-testid='tooltip' and contains(., '${countryCode}')]`, + ) + .click(); + } + + async typePhoneNumber(value: string) { + await this.page.locator(`//input[@placeholder='Phone']`).fill(value); + } + + async clickAddPhoneButton() { + await this.addPhoneButton.click(); + } + + // rating + // if adding rating for the first time, hover must be used + async selectRating(rating: number) { + await this.page.locator(`//div[@role='slider']/div[${rating}]`).click(); + } + + // text + // placeholder is dependent on the name of field + async typeText(placeholder: string, value: string) { + await this.page + .locator(`//input[@placeholder='${placeholder}']`) + .fill(value); + } + + async clickSetAsPrimaryButton() { + await this.setAsPrimaryButton.click(); + } + + async searchValue(value: string) { + await this.page.locator(`//div[@placeholder='Search']`).fill(value); + } + + async clickEditButton() { + await this.page + .locator("//div[@data-testid='tooltip' and contains(., 'Edit')]") + .click(); + } + + async clickDeleteButton() { + await this.page + .locator("//div[@data-testid='tooltip' and contains(., 'Delete')]") + .click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/helper/stripePage.ts b/packages/twenty-e2e-testing/lib/pom/helper/stripePage.ts new file mode 100644 index 000000000000..ccef759f916d --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/helper/stripePage.ts @@ -0,0 +1,5 @@ +import { Locator, Page } from '@playwright/test'; + +export class StripePage { + // TODO: implement all necessary methods (staging/sandbox page - does it differ anyhow from normal page?) +} diff --git a/packages/twenty-e2e-testing/lib/pom/helper/uploadImage.ts b/packages/twenty-e2e-testing/lib/pom/helper/uploadImage.ts new file mode 100644 index 000000000000..41493fdc0e19 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/helper/uploadImage.ts @@ -0,0 +1,25 @@ +import { Locator, Page } from '@playwright/test'; + +export class UploadImage { + private readonly imagePreview: Locator; + private readonly uploadButton: Locator; + private readonly removeButton: Locator; + + constructor(public readonly page: Page) { + this.imagePreview = page.locator('.css-6eut39'); //TODO: add attribute to make it independent of theme + this.uploadButton = page.getByRole('button', { name: 'Upload' }); + this.removeButton = page.getByRole('button', { name: 'Remove' }); + } + + async clickImagePreview() { + await this.imagePreview.click(); + } + + async clickUploadButton() { + await this.uploadButton.click(); + } + + async clickRemoveButton() { + await this.removeButton.click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/leftMenu.ts b/packages/twenty-e2e-testing/lib/pom/leftMenu.ts new file mode 100644 index 000000000000..c58c3f7f1a02 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/leftMenu.ts @@ -0,0 +1,115 @@ +import { Locator, Page } from '@playwright/test'; + +export class LeftMenu { + private readonly workspaceDropdown: Locator; + private readonly leftMenu: Locator; + private readonly searchSubTab: Locator; + private readonly settingsTab: Locator; + private readonly peopleTab: Locator; + private readonly companiesTab: Locator; + private readonly opportunitiesTab: Locator; + private readonly opportunitiesTabAll: Locator; + private readonly opportunitiesTabByStage: Locator; + private readonly tasksTab: Locator; + private readonly tasksTabAll: Locator; + private readonly tasksTabByStatus: Locator; + private readonly notesTab: Locator; + private readonly rocketsTab: Locator; + private readonly workflowsTab: Locator; + + constructor(public readonly page: Page) { + this.page = page; + this.workspaceDropdown = page.getByTestId('workspace-dropdown'); + this.leftMenu = page.getByRole('button').first(); + this.searchSubTab = page.getByText('Search'); + this.settingsTab = page.getByRole('link', { name: 'Settings' }); + this.peopleTab = page.getByRole('link', { name: 'People' }); + this.companiesTab = page.getByRole('link', { name: 'Companies' }); + this.opportunitiesTab = page.getByRole('link', { name: 'Opportunities' }); + this.opportunitiesTabAll = page.getByRole('link', { + name: 'All', + exact: true, + }); + this.opportunitiesTabByStage = page.getByRole('link', { name: 'By Stage' }); + this.tasksTab = page.getByRole('link', { name: 'Tasks' }); + this.tasksTabAll = page.getByRole('link', { name: 'All tasks' }); + this.tasksTabByStatus = page.getByRole('link', { name: 'Notes' }); + this.notesTab = page.getByRole('link', { name: 'Notes' }); + this.rocketsTab = page.getByRole('link', { name: 'Rockets' }); + this.workflowsTab = page.getByRole('link', { name: 'Workflows' }); + } + + async selectWorkspace(workspaceName: string) { + await this.workspaceDropdown.click(); + await this.page + .getByTestId('tooltip') + .filter({ hasText: workspaceName }) + .click(); + } + + async changeLeftMenu() { + await this.leftMenu.click(); + } + + async openSearchTab() { + await this.searchSubTab.click(); + } + + async goToSettings() { + await this.settingsTab.click(); + } + + async goToPeopleTab() { + await this.peopleTab.click(); + } + + async goToCompaniesTab() { + await this.companiesTab.click(); + } + + async goToOpportunitiesTab() { + await this.opportunitiesTab.click(); + } + + async goToOpportunitiesTableView() { + await this.opportunitiesTabAll.click(); + } + + async goToOpportunitiesKanbanView() { + await this.opportunitiesTabByStage.click(); + } + + async goToTasksTab() { + await this.tasksTab.click(); + } + + async goToTasksTableView() { + await this.tasksTabAll.click(); + } + + async goToTasksKanbanView() { + await this.tasksTabByStatus.click(); + } + + async goToNotesTab() { + await this.notesTab.click(); + } + + async goToRocketsTab() { + await this.rocketsTab.click(); + } + + async goToWorkflowsTab() { + await this.workflowsTab.click(); + } + + async goToCustomObject(customObjectName: string) { + await this.page.getByRole('link', { name: customObjectName }).click(); + } + + async goToCustomObjectView(name: string) { + await this.page.getByRole('link', { name: name }).click(); + } +} + +export default LeftMenu; diff --git a/packages/twenty-e2e-testing/lib/pom/loginPage.ts b/packages/twenty-e2e-testing/lib/pom/loginPage.ts new file mode 100644 index 000000000000..dc60d3f7a799 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/loginPage.ts @@ -0,0 +1,187 @@ +import { Locator, Page } from '@playwright/test'; + +export class LoginPage { + private readonly loginWithGoogleButton: Locator; + private readonly loginWithEmailButton: Locator; + private readonly termsOfServiceLink: Locator; + private readonly privacyPolicyLink: Locator; + private readonly emailField: Locator; + private readonly continueButton: Locator; + private readonly forgotPasswordButton: Locator; + private readonly passwordField: Locator; + private readonly revealPasswordButton: Locator; + private readonly signInButton: Locator; + private readonly signUpButton: Locator; + private readonly previewImageButton: Locator; + private readonly uploadImageButton: Locator; + private readonly deleteImageButton: Locator; + private readonly workspaceNameField: Locator; + private readonly firstNameField: Locator; + private readonly lastNameField: Locator; + private readonly syncEverythingWithGoogleRadio: Locator; + private readonly syncSubjectAndMetadataWithGoogleRadio: Locator; + private readonly syncMetadataWithGoogleRadio: Locator; + private readonly syncWithGoogleButton: Locator; + private readonly noSyncButton: Locator; + private readonly inviteLinkField1: Locator; + private readonly inviteLinkField2: Locator; + private readonly inviteLinkField3: Locator; + private readonly copyInviteLink: Locator; + private readonly finishButton: Locator; + + constructor(public readonly page: Page) { + this.page = page; + this.loginWithGoogleButton = page.getByRole('button', { + name: 'Continue with Google', + }); + this.loginWithEmailButton = page.getByRole('button', { + name: 'Continue With Email', + }); + this.termsOfServiceLink = page.getByRole('link', { + name: 'Terms of Service', + }); + this.privacyPolicyLink = page.getByRole('link', { name: 'Privacy Policy' }); + this.emailField = page.getByPlaceholder('Email'); + this.continueButton = page.getByRole('button', { + name: 'Continue', + exact: true, + }); + this.forgotPasswordButton = page.getByText('Forgot your password?'); + this.passwordField = page.getByPlaceholder('Password'); + this.revealPasswordButton = page.getByTestId('reveal-password-button'); + this.signInButton = page.getByRole('button', { name: 'Sign in' }); + this.signUpButton = page.getByRole('button', { name: 'Sign up' }); + this.previewImageButton = page.locator('.css-1qzw107'); // TODO: fix + this.uploadImageButton = page.getByRole('button', { name: 'Upload' }); + this.deleteImageButton = page.getByRole('button', { name: 'Remove' }); + this.workspaceNameField = page.getByPlaceholder('Apple'); + this.firstNameField = page.getByPlaceholder('Tim'); + this.lastNameField = page.getByPlaceholder('Cook'); + this.syncEverythingWithGoogleRadio = page.locator( + 'input[value="SHARE_EVERYTHING"]', + ); + this.syncSubjectAndMetadataWithGoogleRadio = page.locator( + 'input[value="SUBJECT"]', + ); + this.syncMetadataWithGoogleRadio = page.locator('input[value="METADATA"]'); + this.syncWithGoogleButton = page.getByRole('button', { + name: 'Sync with Google', + }); + this.noSyncButton = page.getByText('Continue without sync'); + this.inviteLinkField1 = page.getByPlaceholder('tim@apple.dev'); + this.inviteLinkField2 = page.getByPlaceholder('craig@apple.dev'); + this.inviteLinkField3 = page.getByPlaceholder('mike@apple.dev'); + this.copyInviteLink = page.getByRole('button', { + name: 'Copy invitation link', + }); + this.finishButton = page.getByRole('button', { name: 'Finish' }); + } + + async loginWithGoogle() { + await this.loginWithGoogleButton.click(); + } + + async clickLoginWithEmail() { + await this.loginWithEmailButton.click(); + } + + async clickContinueButton() { + await this.continueButton.click(); + } + + async clickTermsLink() { + await this.termsOfServiceLink.click(); + } + + async clickPrivacyPolicyLink() { + await this.privacyPolicyLink.click(); + } + + async typeEmail(email: string) { + await this.emailField.fill(email); + } + + async typePassword(email: string) { + await this.passwordField.fill(email); + } + + async clickSignInButton() { + await this.signInButton.click(); + } + + async clickSignUpButton() { + await this.signUpButton.click(); + } + + async clickForgotPassword() { + await this.forgotPasswordButton.click(); + } + + async revealPassword() { + await this.revealPasswordButton.click(); + } + + async previewImage() { + await this.previewImageButton.click(); + } + + async clickUploadImage() { + await this.uploadImageButton.click(); + } + + async deleteImage() { + await this.deleteImageButton.click(); + } + + async typeWorkspaceName(workspaceName: string) { + await this.workspaceNameField.fill(workspaceName); + } + + async typeFirstName(firstName: string) { + await this.firstNameField.fill(firstName); + } + + async typeLastName(lastName: string) { + await this.lastNameField.fill(lastName); + } + + async clickSyncEverythingWithGoogleRadio() { + await this.syncEverythingWithGoogleRadio.click(); + } + + async clickSyncSubjectAndMetadataWithGoogleRadio() { + await this.syncSubjectAndMetadataWithGoogleRadio.click(); + } + + async clickSyncMetadataWithGoogleRadio() { + await this.syncMetadataWithGoogleRadio.click(); + } + + async clickSyncWithGoogleButton() { + await this.syncWithGoogleButton.click(); + } + + async noSyncWithGoogle() { + await this.noSyncButton.click(); + } + + async typeInviteLink1(email: string) { + await this.inviteLinkField1.fill(email); + } + + async typeInviteLink2(email: string) { + await this.inviteLinkField2.fill(email); + } + + async typeInviteLink3(email: string) { + await this.inviteLinkField3.fill(email); + } + + async clickCopyInviteLink() { + await this.copyInviteLink.click(); + } + + async clickFinishButton() { + await this.finishButton.click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/mainPage.ts b/packages/twenty-e2e-testing/lib/pom/mainPage.ts new file mode 100644 index 000000000000..a28e133b575c --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/mainPage.ts @@ -0,0 +1,196 @@ +import { Locator, Page } from '@playwright/test'; + +export class MainPage { + // TODO: add missing elements (advanced filters, import/export popups) + private readonly tableViews: Locator; + private readonly addViewButton: Locator; + private readonly viewIconSelect: Locator; + private readonly viewNameInput: Locator; + private readonly viewTypeSelect: Locator; + private readonly createViewButton: Locator; + private readonly deleteViewButton: Locator; + private readonly filterButton: Locator; + private readonly searchFieldInput: Locator; + private readonly advancedFilterButton: Locator; + private readonly addFilterButton: Locator; + private readonly resetFilterButton: Locator; + private readonly saveFilterAsViewButton: Locator; + private readonly sortButton: Locator; + private readonly sortOrderButton: Locator; + private readonly optionsButton: Locator; + private readonly fieldsButton: Locator; + private readonly goBackButton: Locator; + private readonly hiddenFieldsButton: Locator; + private readonly editFieldsButton: Locator; + private readonly importButton: Locator; + private readonly exportButton: Locator; + private readonly deletedRecordsButton: Locator; + private readonly createNewRecordButton: Locator; + private readonly addToFavoritesButton: Locator; + private readonly deleteFromFavoritesButton: Locator; + private readonly exportBottomBarButton: Locator; + private readonly deleteRecordsButton: Locator; + + constructor(public readonly page: Page) { + this.tableViews = page.getByText('·'); + this.addViewButton = page + .getByTestId('tooltip') + .filter({ hasText: /^Add view$/ }); + this.viewIconSelect = page.getByLabel('Click to select icon ('); + this.viewNameInput; // can be selected using only actual value + this.viewTypeSelect = page.locator( + "//span[contains(., 'View type')]/../div", + ); + this.createViewButton = page.getByRole('button', { name: 'Create' }); + this.deleteViewButton = page.getByRole('button', { name: 'Delete' }); + this.filterButton = page.getByText('Filter'); + this.searchFieldInput = page.getByPlaceholder('Search fields'); + this.advancedFilterButton = page + .getByTestId('tooltip') + .filter({ hasText: /^Advanced filter$/ }); + this.addFilterButton = page.getByRole('button', { name: 'Add Filter' }); + this.resetFilterButton = page.getByTestId('cancel-button'); + this.saveFilterAsViewButton = page.getByRole('button', { + name: 'Save as new view', + }); + this.sortButton = page.getByText('Sort'); + this.sortOrderButton = page.locator('//li'); + this.optionsButton = page.getByText('Options'); + this.fieldsButton = page.getByText('Fields'); + this.goBackButton = page.getByTestId('dropdown-menu-header-end-icon'); + this.hiddenFieldsButton = page + .getByTestId('tooltip') + .filter({ hasText: /^Hidden Fields$/ }); + this.editFieldsButton = page + .getByTestId('tooltip') + .filter({ hasText: /^Edit Fields$/ }); + this.importButton = page + .getByTestId('tooltip') + .filter({ hasText: /^Import$/ }); + this.exportButton = page + .getByTestId('tooltip') + .filter({ hasText: /^Export$/ }); + this.deletedRecordsButton = page + .getByTestId('tooltip') + .filter({ hasText: /^Deleted */ }); + this.createNewRecordButton = page.getByTestId('add-button'); + this.addToFavoritesButton = page.getByText('Add to favorites'); + this.deleteFromFavoritesButton = page.getByText('Delete from favorites'); + this.exportBottomBarButton = page.getByText('Export'); + this.deleteRecordsButton = page.getByText('Delete'); + } + + async clickTableViews() { + await this.tableViews.click(); + } + + async clickAddViewButton() { + await this.addViewButton.click(); + } + + async typeViewName(name: string) { + await this.viewNameInput.clear(); + await this.viewNameInput.fill(name); + } + + // name can be either be 'Table' or 'Kanban' + async selectViewType(name: string) { + await this.viewTypeSelect.click(); + await this.page.getByTestId('tooltip').filter({ hasText: name }).click(); + } + + async createView() { + await this.createViewButton.click(); + } + + async deleteView() { + await this.deleteViewButton.click(); + } + + async clickFilterButton() { + await this.filterButton.click(); + } + + async searchFields(name: string) { + await this.searchFieldInput.clear(); + await this.searchFieldInput.fill(name); + } + + async clickAdvancedFilterButton() { + await this.advancedFilterButton.click(); + } + + async addFilter() { + await this.addFilterButton.click(); + } + + async resetFilter() { + await this.resetFilterButton.click(); + } + + async saveFilterAsView() { + await this.saveFilterAsViewButton.click(); + } + + async clickSortButton() { + await this.sortButton.click(); + } + + //can be Ascending or Descending + async setSortOrder(name: string) { + await this.sortOrderButton.click(); + await this.page.getByTestId('tooltip').filter({ hasText: name }).click(); + } + + async clickOptionsButton() { + await this.optionsButton.click(); + } + + async clickFieldsButton() { + await this.fieldsButton.click(); + } + + async clickBackButton() { + await this.goBackButton.click(); + } + + async clickHiddenFieldsButton() { + await this.hiddenFieldsButton.click(); + } + + async clickEditFieldsButton() { + await this.editFieldsButton.click(); + } + + async clickImportButton() { + await this.importButton.click(); + } + + async clickExportButton() { + await this.exportButton.click(); + } + + async clickDeletedRecordsButton() { + await this.deletedRecordsButton.click(); + } + + async clickCreateNewRecordButton() { + await this.createNewRecordButton.click(); + } + + async clickAddToFavoritesButton() { + await this.addToFavoritesButton.click(); + } + + async clickDeleteFromFavoritesButton() { + await this.deleteFromFavoritesButton.click(); + } + + async clickExportBottomBarButton() { + await this.exportBottomBarButton.click(); + } + + async clickDeleteRecordsButton() { + await this.deleteRecordsButton.click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/recordDetails.ts b/packages/twenty-e2e-testing/lib/pom/recordDetails.ts new file mode 100644 index 000000000000..f22dd8a459b5 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/recordDetails.ts @@ -0,0 +1,150 @@ +import { Locator, Page } from '@playwright/test'; + +export class RecordDetails { + // TODO: add missing components in tasks, notes, files, emails, calendar tabs + private readonly closeRecordButton: Locator; + private readonly previousRecordButton: Locator; + private readonly nextRecordButton: Locator; + private readonly favoriteRecordButton: Locator; + private readonly addShowPageButton: Locator; + private readonly moreOptionsButton: Locator; + private readonly deleteButton: Locator; + private readonly uploadProfileImageButton: Locator; + private readonly timelineTab: Locator; + private readonly tasksTab: Locator; + private readonly notesTab: Locator; + private readonly filesTab: Locator; + private readonly emailsTab: Locator; + private readonly calendarTab: Locator; + private readonly detachRelationButton: Locator; + + constructor(public readonly page: Page) { + this.page = page; + } + + async clickCloseRecordButton() { + await this.closeRecordButton.click(); + } + + async clickPreviousRecordButton() { + await this.previousRecordButton.click(); + } + + async clickNextRecordButton() { + await this.nextRecordButton.click(); + } + + async clickFavoriteRecordButton() { + await this.favoriteRecordButton.click(); + } + + async createRelatedNote() { + await this.addShowPageButton.click(); + await this.page + .locator('//div[@data-testid="tooltip" and contains(., "Note")]') + .click(); + } + + async createRelatedTask() { + await this.addShowPageButton.click(); + await this.page + .locator('//div[@data-testid="tooltip" and contains(., "Task")]') + .click(); + } + + async clickMoreOptionsButton() { + await this.moreOptionsButton.click(); + } + + async clickDeleteRecordButton() { + await this.deleteButton.click(); + } + + async clickUploadProfileImageButton() { + await this.uploadProfileImageButton.click(); + } + + async goToTimelineTab() { + await this.timelineTab.click(); + } + + async goToTasksTab() { + await this.tasksTab.click(); + } + + async goToNotesTab() { + await this.notesTab.click(); + } + + async goToFilesTab() { + await this.filesTab.click(); + } + + async goToEmailsTab() { + await this.emailsTab.click(); + } + + async goToCalendarTab() { + await this.calendarTab.click(); + } + + async clickField(name: string) { + await this.page + .locator( + `//div[@data-testid='tooltip' and contains(., '${name}']/../../../div[last()]/div/div`, + ) + .click(); + } + + async clickFieldWithButton(name: string) { + await this.page + .locator( + `//div[@data-testid='tooltip' and contains(., '${name}']/../../../div[last()]/div/div`, + ) + .hover(); + await this.page + .locator( + `//div[@data-testid='tooltip' and contains(., '${name}']/../../../div[last()]/div/div[last()]/div/button`, + ) + .click(); + } + + async clickRelationEditButton(name: string) { + await this.page.getByRole('heading').filter({ hasText: name }).hover(); + await this.page + .locator(`//header[contains(., "${name}")]/div[last()]/div/button`) + .click(); + } + + async detachRelation(name: string) { + await this.page.locator(`//a[contains(., "${name}")]`).hover(); + await this.page + .locator(`, //a[contains(., "${name}")]/../div[last()]/div/div/button`) + .hover(); + await this.detachRelationButton.click(); + } + + async deleteRelationRecord(name: string) { + await this.page.locator(`//a[contains(., "${name}")]`).hover(); + await this.page + .locator(`, //a[contains(., "${name}")]/../div[last()]/div/div/button`) + .hover(); + await this.deleteButton.click(); + } + + async selectRelationRecord(name: string) { + await this.page + .locator(`//div[@data-testid="tooltip" and contains(., "${name}")]`) + .click(); + } + + async searchRelationRecord(name: string) { + await this.page.getByPlaceholder('Search').fill(name); + } + + async createNewRelationRecord() { + await this.page + .locator('//div[@data-testid="tooltip" and contains(., "Add New")]') + .click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/settings/accountsSection.ts b/packages/twenty-e2e-testing/lib/pom/settings/accountsSection.ts new file mode 100644 index 000000000000..703cdffa6ed6 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/settings/accountsSection.ts @@ -0,0 +1,54 @@ +import { Locator, Page } from '@playwright/test'; + +export class AccountsSection { + private readonly addAccountButton: Locator; + private readonly deleteAccountButton: Locator; + private readonly addBlocklistField: Locator; + private readonly addBlocklistButton: Locator; + private readonly connectWithGoogleButton: Locator; + + constructor(public readonly page: Page) { + this.page = page; + this.addAccountButton = page.getByRole('button', { name: 'Add account' }); + this.deleteAccountButton = page + .getByTestId('tooltip') + .getByText('Remove account'); + this.addBlocklistField = page.getByPlaceholder( + 'eddy@gmail.com, @apple.com', + ); + this.addBlocklistButton = page.getByRole('button', { + name: 'Add to blocklist', + }); + this.connectWithGoogleButton = page.getByRole('button', { + name: 'Connect with Google', + }); + } + + async clickAddAccount() { + await this.addAccountButton.click(); + } + + async deleteAccount(email: string) { + await this.page + .locator(`//span[contains(., "${email}")]/../div/div/div/button`) + .click(); + await this.deleteAccountButton.click(); + } + + async addToBlockList(domain: string) { + await this.addBlocklistField.fill(domain); + await this.addBlocklistButton.click(); + } + + async removeFromBlocklist(domain: string) { + await this.page + .locator( + `//div[@data-testid='tooltip' and contains(., '${domain}')]/../../div[last()]/button`, + ) + .click(); + } + + async linkGoogleAccount() { + await this.connectWithGoogleButton.click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/settings/calendarSection.ts b/packages/twenty-e2e-testing/lib/pom/settings/calendarSection.ts new file mode 100644 index 000000000000..98ccba0d06f8 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/settings/calendarSection.ts @@ -0,0 +1,30 @@ +import { Locator, Page } from '@playwright/test'; + +export class CalendarSection { + private readonly eventVisibilityEverythingOption: Locator; + private readonly eventVisibilityMetadataOption: Locator; + private readonly contactAutoCreation: Locator; + + constructor(public readonly page: Page) { + this.page = page; + this.eventVisibilityEverythingOption = page.locator( + 'input[value="SHARE_EVERYTHING"]', + ); + this.eventVisibilityMetadataOption = page.locator( + 'input[value="METADATA"]', + ); + this.contactAutoCreation = page.getByRole('checkbox').nth(1); + } + + async changeVisibilityToEverything() { + await this.eventVisibilityEverythingOption.click(); + } + + async changeVisibilityToMetadata() { + await this.eventVisibilityMetadataOption.click(); + } + + async toggleAutoCreation() { + await this.contactAutoCreation.click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/settings/dataModelSection.ts b/packages/twenty-e2e-testing/lib/pom/settings/dataModelSection.ts new file mode 100644 index 000000000000..0e36bd351d56 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/settings/dataModelSection.ts @@ -0,0 +1,189 @@ +import { Locator, Page } from '@playwright/test'; + +export class DataModelSection { + private readonly searchObjectInput: Locator; + private readonly addObjectButton: Locator; + private readonly objectSingularNameInput: Locator; + private readonly objectPluralNameInput: Locator; + private readonly objectDescription: Locator; + private readonly synchronizeLabelAPIToggle: Locator; + private readonly objectAPISingularNameInput: Locator; + private readonly objectAPIPluralNameInput: Locator; + private readonly objectMoreOptionsButton: Locator; + private readonly editObjectButton: Locator; + private readonly deleteObjectButton: Locator; + private readonly activeSection: Locator; + private readonly inactiveSection: Locator; + private readonly searchFieldInput: Locator; + private readonly addFieldButton: Locator; + private readonly viewFieldDetailsMoreOptionsButton: Locator; + private readonly nameFieldInput: Locator; + private readonly descriptionFieldInput: Locator; + private readonly deactivateMoreOptionsButton: Locator; + private readonly activateMoreOptionsButton: Locator; + private readonly deactivateButton: Locator; // TODO: add attribute to make it one button + private readonly activateButton: Locator; + private readonly cancelButton: Locator; + private readonly saveButton: Locator; + + constructor(public readonly page: Page) { + this.searchObjectInput = page.getByPlaceholder('Search an object...'); + this.addObjectButton = page.getByRole('button', { name: 'Add object' }); + this.objectSingularNameInput = page.getByPlaceholder('Listing', { + exact: true, + }); + this.objectPluralNameInput = page.getByPlaceholder('Listings', { + exact: true, + }); + this.objectDescription = page.getByPlaceholder('Write a description'); + this.synchronizeLabelAPIToggle = page.getByRole('checkbox').nth(1); + this.objectAPISingularNameInput = page.getByPlaceholder('listing', { + exact: true, + }); + this.objectAPIPluralNameInput = page.getByPlaceholder('listings', { + exact: true, + }); + this.objectMoreOptionsButton = page.getByLabel('Object Options'); + this.editObjectButton = page.getByTestId('tooltip').getByText('Edit'); + this.deactivateMoreOptionsButton = page + .getByTestId('tooltip') + .getByText('Deactivate'); + this.activateMoreOptionsButton = page + .getByTestId('tooltip') + .getByText('Activate'); + this.deleteObjectButton = page.getByTestId('tooltip').getByText('Delete'); + this.activeSection = page.getByText('Active', { exact: true }); + this.inactiveSection = page.getByText('Inactive'); + this.searchFieldInput = page.getByPlaceholder('Search a field...'); + this.addFieldButton = page.getByRole('button', { name: 'Add field' }); + this.viewFieldDetailsMoreOptionsButton = page + .getByTestId('tooltip') + .getByText('View'); + this.nameFieldInput = page.getByPlaceholder('Employees'); + this.descriptionFieldInput = page.getByPlaceholder('Write a description'); + this.deactivateButton = page.getByRole('button', { name: 'Deactivate' }); + this.activateButton = page.getByRole('button', { name: 'Activate' }); + this.cancelButton = page.getByRole('button', { name: 'Cancel' }); + this.saveButton = page.getByRole('button', { name: 'Save' }); + } + + async searchObject(name: string) { + await this.searchObjectInput.fill(name); + } + + async clickAddObjectButton() { + await this.addObjectButton.click(); + } + + async typeObjectSingularName(name: string) { + await this.objectSingularNameInput.fill(name); + } + + async typeObjectPluralName(name: string) { + await this.objectPluralNameInput.fill(name); + } + + async typeObjectDescription(name: string) { + await this.objectDescription.fill(name); + } + + async toggleSynchronizeLabelAPI() { + await this.synchronizeLabelAPIToggle.click(); + } + + async typeObjectSingularAPIName(name: string) { + await this.objectAPISingularNameInput.fill(name); + } + + async typeObjectPluralAPIName(name: string) { + await this.objectAPIPluralNameInput.fill(name); + } + + async checkObjectDetails(name: string) { + await this.page.getByRole('link').filter({ hasText: name }).click(); + } + + async activateInactiveObject(name: string) { + await this.page + .locator(`//div[@title="${name}"]/../../div[last()]`) + .click(); + await this.activateButton.click(); + } + + // object can be deleted only if is custom and inactive + async deleteInactiveObject(name: string) { + await this.page + .locator(`//div[@title="${name}"]/../../div[last()]`) + .click(); + await this.deleteObjectButton.click(); + } + + async editObjectDetails() { + await this.objectMoreOptionsButton.click(); + await this.editObjectButton.click(); + } + + async deactivateObjectWithMoreOptions() { + await this.objectMoreOptionsButton.click(); + await this.deactivateButton.click(); + } + + async searchField(name: string) { + await this.searchFieldInput.fill(name); + } + + async checkFieldDetails(name: string) { + await this.page.locator(`//div[@title="${name}"]`).click(); + } + + async checkFieldDetailsWithButton(name: string) { + await this.page + .locator(`//div[@title="${name}"]/../../div[last()]`) + .click(); + await this.viewFieldDetailsMoreOptionsButton.click(); + } + + async deactivateFieldWithButton(name: string) { + await this.page + .locator(`//div[@title="${name}"]/../../div[last()]`) + .click(); + await this.deactivateMoreOptionsButton.click(); + } + + async activateFieldWithButton(name: string) { + await this.page + .locator(`//div[@title="${name}"]/../../div[last()]`) + .click(); + await this.activateMoreOptionsButton.click(); + } + + async clickAddFieldButton() { + await this.addFieldButton.click(); + } + + async typeFieldName(name: string) { + await this.nameFieldInput.clear(); + await this.nameFieldInput.fill(name); + } + + async typeFieldDescription(description: string) { + await this.descriptionFieldInput.clear(); + await this.descriptionFieldInput.fill(description); + } + + async clickInactiveSection() { + await this.inactiveSection.click(); + } + + async clickActiveSection() { + await this.activeSection.click(); + } + + async clickCancelButton() { + await this.cancelButton.click(); + } + + async clickSaveButton() { + await this.saveButton.click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/settings/developersSection.ts b/packages/twenty-e2e-testing/lib/pom/settings/developersSection.ts new file mode 100644 index 000000000000..0f7fec96f697 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/settings/developersSection.ts @@ -0,0 +1,123 @@ +import { Locator, Page } from '@playwright/test'; + +export class DevelopersSection { + private readonly readDocumentationButton: Locator; + private readonly createAPIKeyButton: Locator; + private readonly regenerateAPIKeyButton: Locator; + private readonly nameOfAPIKeyInput: Locator; + private readonly expirationDateAPIKeySelect: Locator; + private readonly createWebhookButton: Locator; + private readonly webhookURLInput: Locator; + private readonly webhookDescription: Locator; + private readonly webhookFilterObjectSelect: Locator; + private readonly webhookFilterActionSelect: Locator; + private readonly cancelButton: Locator; + private readonly saveButton: Locator; + private readonly deleteButton: Locator; + + constructor(public readonly page: Page) { + this.page = page; + this.readDocumentationButton = page.getByRole('link', { + name: 'Read documentation', + }); + this.createAPIKeyButton = page.getByRole('link', { + name: 'Create API Key', + }); + this.createWebhookButton = page.getByRole('link', { + name: 'Create Webhook', + }); + this.nameOfAPIKeyInput = page + .getByPlaceholder('E.g. backoffice integration') + .first(); + this.expirationDateAPIKeySelect = page + .locator('div') + .filter({ hasText: /^Never$/ }) + .nth(3); // good enough if expiration date will change only 1 time + this.regenerateAPIKeyButton = page.getByRole('button', { + name: 'Regenerate Key', + }); + this.webhookURLInput = page.getByPlaceholder('URL'); + this.webhookDescription = page.getByPlaceholder('Write a description'); + this.webhookFilterObjectSelect = page + .locator('div') + .filter({ hasText: /^All Objects$/ }) + .nth(3); // works only for first filter + this.webhookFilterActionSelect = page + .locator('div') + .filter({ hasText: /^All Actions$/ }) + .nth(3); // works only for first filter + this.cancelButton = page.getByRole('button', { name: 'Cancel' }); + this.saveButton = page.getByRole('button', { name: 'Save' }); + this.deleteButton = page.getByRole('button', { name: 'Delete' }); + } + + async openDocumentation() { + await this.readDocumentationButton.click(); + } + + async createAPIKey() { + await this.createAPIKeyButton.click(); + } + + async typeAPIKeyName(name: string) { + await this.nameOfAPIKeyInput.clear(); + await this.nameOfAPIKeyInput.fill(name); + } + + async selectAPIExpirationDate(date: string) { + await this.expirationDateAPIKeySelect.click(); + await this.page.getByText(date, { exact: true }).click(); + } + + async regenerateAPIKey() { + await this.regenerateAPIKeyButton.click(); + } + + async deleteAPIKey() { + await this.deleteButton.click(); + } + + async deleteWebhook() { + await this.deleteButton.click(); + } + + async createWebhook() { + await this.createWebhookButton.click(); + } + + async typeWebhookURL(url: string) { + await this.webhookURLInput.fill(url); + } + + async typeWebhookDescription(description: string) { + await this.webhookDescription.fill(description); + } + + async selectWebhookObject(object: string) { + // TODO: finish + } + + async selectWebhookAction(action: string) { + // TODO: finish + } + + async deleteWebhookFilter() { + // TODO: finish + } + + async clickCancelButton() { + await this.cancelButton.click(); + } + + async clickSaveButton() { + await this.saveButton.click(); + } + + async checkAPIKeyDetails(name: string) { + await this.page.locator(`//a/div[contains(.,'${name}')][first()]`).click(); + } + + async checkWebhookDetails(name: string) { + await this.page.locator(`//a/div[contains(.,'${name}')][first()]`).click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/settings/emailsSection.ts b/packages/twenty-e2e-testing/lib/pom/settings/emailsSection.ts new file mode 100644 index 000000000000..23a83f8db07e --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/settings/emailsSection.ts @@ -0,0 +1,61 @@ +import { Locator, Page } from '@playwright/test'; + +export class EmailsSection { + private readonly visibilityEverythingRadio: Locator; + private readonly visibilitySubjectRadio: Locator; + private readonly visibilityMetadataRadio: Locator; + private readonly autoCreationReceivedRadio: Locator; + private readonly autoCreationSentRadio: Locator; + private readonly autoCreationNoneRadio: Locator; + private readonly excludeNonProfessionalToggle: Locator; + private readonly excludeGroupToggle: Locator; + + constructor(public readonly page: Page) { + this.page = page; + this.visibilityEverythingRadio = page.locator( + 'input[value="SHARE_EVERYTHING"]', + ); + this.visibilitySubjectRadio = page.locator('input[value="SUBJECT"]'); + this.visibilityMetadataRadio = page.locator('input[value="METADATA"]'); + this.autoCreationReceivedRadio = page.locator( + 'input[value="SENT_AND_RECEIVED"]', + ); + this.autoCreationSentRadio = page.locator('input[value="SENT"]'); + this.autoCreationNoneRadio = page.locator('input[value="NONE"]'); + // first checkbox is advanced settings toggle + this.excludeNonProfessionalToggle = page.getByRole('checkbox').nth(1); + this.excludeGroupToggle = page.getByRole('checkbox').nth(2); + } + + async changeVisibilityToEverything() { + await this.visibilityEverythingRadio.click(); + } + + async changeVisibilityToSubject() { + await this.visibilitySubjectRadio.click(); + } + + async changeVisibilityToMetadata() { + await this.visibilityMetadataRadio.click(); + } + + async changeAutoCreationToAll() { + await this.autoCreationReceivedRadio.click(); + } + + async changeAutoCreationToSent() { + await this.autoCreationSentRadio.click(); + } + + async changeAutoCreationToNone() { + await this.autoCreationNoneRadio.click(); + } + + async toggleExcludeNonProfessional() { + await this.excludeNonProfessionalToggle.click(); + } + + async toggleExcludeGroup() { + await this.excludeGroupToggle.click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/settings/experienceSection.ts b/packages/twenty-e2e-testing/lib/pom/settings/experienceSection.ts new file mode 100644 index 000000000000..4ed6606c10c9 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/settings/experienceSection.ts @@ -0,0 +1,55 @@ +import { Locator, Page } from '@playwright/test'; + +export class ExperienceSection { + private readonly lightThemeButton: Locator; + private readonly darkThemeButton: Locator; + private readonly timezoneDropdown: Locator; + private readonly dateFormatDropdown: Locator; + private readonly timeFormatDropdown: Locator; + private readonly searchInput: Locator; + + constructor(public readonly page: Page) { + this.page = page; + this.lightThemeButton = page.getByText('AaLight'); // it works + this.darkThemeButton = page.getByText('AaDark'); + this.timezoneDropdown = page.locator( + '//span[contains(., "Time zone")]/../div/div/div', + ); + this.dateFormatDropdown = page.locator( + '//span[contains(., "Date format")]/../div/div/div', + ); + this.timeFormatDropdown = page.locator( + '//span[contains(., "Time format")]/../div/div/div', + ); + this.searchInput = page.getByPlaceholder('Search'); + } + + async changeThemeToLight() { + await this.lightThemeButton.click(); + } + + async changeThemeToDark() { + await this.darkThemeButton.click(); + } + + async selectTimeZone(timezone: string) { + await this.timezoneDropdown.click(); + await this.page.getByText(timezone, { exact: true }).click(); + } + + async selectTimeZoneWithSearch(timezone: string) { + await this.timezoneDropdown.click(); + await this.searchInput.fill(timezone); + await this.page.getByText(timezone, { exact: true }).click(); + } + + async selectDateFormat(dateFormat: string) { + await this.dateFormatDropdown.click(); + await this.page.getByText(dateFormat, { exact: true }).click(); + } + + async selectTimeFormat(timeFormat: string) { + await this.timeFormatDropdown.click(); + await this.page.getByText(timeFormat, { exact: true }).click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/settings/functionsSection.ts b/packages/twenty-e2e-testing/lib/pom/settings/functionsSection.ts new file mode 100644 index 000000000000..f8f42418a17e --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/settings/functionsSection.ts @@ -0,0 +1,159 @@ +import { Locator, Page } from '@playwright/test'; + +export class FunctionsSection { + private readonly newFunctionButton: Locator; + private readonly functionNameInput: Locator; + private readonly functionDescriptionInput: Locator; + private readonly editorTab: Locator; + private readonly codeEditorField: Locator; + private readonly resetButton: Locator; + private readonly publishButton: Locator; + private readonly testButton: Locator; + private readonly testTab: Locator; + private readonly runFunctionButton: Locator; + private readonly inputField: Locator; + private readonly settingsTab: Locator; + private readonly searchVariableInput: Locator; + private readonly addVariableButton: Locator; + private readonly nameVariableInput: Locator; + private readonly valueVariableInput: Locator; + private readonly cancelVariableButton: Locator; + private readonly saveVariableButton: Locator; + private readonly editVariableButton: Locator; + private readonly deleteVariableButton: Locator; + private readonly cancelButton: Locator; + private readonly saveButton: Locator; + private readonly deleteButton: Locator; + + constructor(public readonly page: Page) { + this.newFunctionButton = page.getByRole('button', { name: 'New Function' }); + this.functionNameInput = page.getByPlaceholder('Name'); + this.functionDescriptionInput = page.getByPlaceholder('Description'); + this.editorTab = page.getByTestId('tab-editor'); + this.codeEditorField = page.getByTestId('dummyInput'); // TODO: fix + this.resetButton = page.getByRole('button', { name: 'Reset' }); + this.publishButton = page.getByRole('button', { name: 'Publish' }); + this.testButton = page.getByRole('button', { name: 'Test' }); + this.testTab = page.getByTestId('tab-test'); + this.runFunctionButton = page.getByRole('button', { name: 'Run Function' }); + this.inputField = page.getByTestId('dummyInput'); // TODO: fix + this.settingsTab = page.getByTestId('tab-settings'); + this.searchVariableInput = page.getByPlaceholder('Search a variable'); + this.addVariableButton = page.getByRole('button', { name: 'Add Variable' }); + this.nameVariableInput = page.getByPlaceholder('Name').nth(1); + this.valueVariableInput = page.getByPlaceholder('Value'); + this.cancelVariableButton = page.locator('.css-uwqduk').first(); // TODO: fix + this.saveVariableButton = page.locator('.css-uwqduk').nth(1); // TODO: fix + this.editVariableButton = page.getByText('Edit', { exact: true }); + this.deleteVariableButton = page.getByText('Delete', { exact: true }); + this.cancelButton = page.getByRole('button', { name: 'Cancel' }); + this.saveButton = page.getByRole('button', { name: 'Save' }); + this.deleteButton = page.getByRole('button', { name: 'Delete function' }); + } + + async clickNewFunction() { + await this.newFunctionButton.click(); + } + + async typeFunctionName(name: string) { + await this.functionNameInput.fill(name); + } + + async typeFunctionDescription(description: string) { + await this.functionDescriptionInput.fill(description); + } + + async checkFunctionDetails(name: string) { + await this.page.getByRole('link', { name: `${name} nodejs18.x` }).click(); + } + + async clickEditorTab() { + await this.editorTab.click(); + } + + async clickResetButton() { + await this.resetButton.click(); + } + + async clickPublishButton() { + await this.publishButton.click(); + } + + async clickTestButton() { + await this.testButton.click(); + } + + async typeFunctionCode() { + // TODO: finish once utils are merged + } + + async clickTestTab() { + await this.testTab.click(); + } + + async runFunction() { + await this.runFunctionButton.click(); + } + + async typeFunctionInput() { + // TODO: finish once utils are merged + } + + async clickSettingsTab() { + await this.settingsTab.click(); + } + + async searchVariable(name: string) { + await this.searchVariableInput.fill(name); + } + + async addVariable() { + await this.addVariableButton.click(); + } + + async typeVariableName(name: string) { + await this.nameVariableInput.fill(name); + } + + async typeVariableValue(value: string) { + await this.valueVariableInput.fill(value); + } + + async editVariable(name: string) { + await this.page + .locator( + `//div[@data-testid='tooltip' and contains(., '${name}')]/../../div[last()]/div/div/button`, + ) + .click(); + await this.editVariableButton.click(); + } + + async deleteVariable(name: string) { + await this.page + .locator( + `//div[@data-testid='tooltip' and contains(., '${name}')]/../../div[last()]/div/div/button`, + ) + .click(); + await this.deleteVariableButton.click(); + } + + async cancelVariable() { + await this.cancelVariableButton.click(); + } + + async saveVariable() { + await this.saveVariableButton.click(); + } + + async clickCancelButton() { + await this.cancelButton.click(); + } + + async clickSaveButton() { + await this.saveButton.click(); + } + + async clickDeleteButton() { + await this.deleteButton.click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/settings/generalSection.ts b/packages/twenty-e2e-testing/lib/pom/settings/generalSection.ts new file mode 100644 index 000000000000..d936a2e9e196 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/settings/generalSection.ts @@ -0,0 +1,29 @@ +import { Locator, Page } from '@playwright/test'; + +export class GeneralSection { + private readonly workspaceNameField: Locator; + private readonly supportSwitch: Locator; + private readonly deleteWorkspaceButton: Locator; + + constructor(public readonly page: Page) { + this.page = page; + this.workspaceNameField = page.getByPlaceholder('Apple'); + this.supportSwitch = page.getByRole('checkbox').nth(1); + this.deleteWorkspaceButton = page.getByRole('button', { + name: 'Delete workspace', + }); + } + + async changeWorkspaceName(workspaceName: string) { + await this.workspaceNameField.clear(); + await this.workspaceNameField.fill(workspaceName); + } + + async changeSupportSwitchState() { + await this.supportSwitch.click(); + } + + async clickDeleteWorkSpaceButton() { + await this.deleteWorkspaceButton.click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/settings/membersSection.ts b/packages/twenty-e2e-testing/lib/pom/settings/membersSection.ts new file mode 100644 index 000000000000..4f1086657028 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/settings/membersSection.ts @@ -0,0 +1,48 @@ +import { Locator, Page } from '@playwright/test'; + +export class MembersSection { + private readonly inviteMembersField: Locator; + private readonly inviteMembersButton: Locator; + private readonly inviteLinkButton: Locator; + + constructor(public readonly page: Page) { + this.page = page; + this.inviteMembersField = page.getByPlaceholder( + 'tim@apple.com, jony.ive@apple', + ); + this.inviteMembersButton = page.getByRole('button', { name: 'Invite' }); + this.inviteLinkButton = page.getByRole('button', { name: 'Copy link' }); + } + + async copyInviteLink() { + await this.inviteLinkButton.click(); + } + + async sendInviteEmail(email: string) { + await this.inviteMembersField.click(); + await this.inviteMembersField.fill(email); + await this.inviteMembersButton.click(); + } + + async deleteMember(email: string) { + await this.page + .locator(`//div[contains(., '${email}')]/../../div[last()]/div/button`) + .click(); + } + + async deleteInviteEmail(email: string) { + await this.page + .locator( + `//div[contains(., '${email}')]/../../div[last()]/div/button[first()]`, + ) + .click(); + } + + async refreshInviteEmail(email: string) { + await this.page + .locator( + `//div[contains(., '${email}')]/../../div[last()]/div/button[last()]`, + ) + .click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/settings/newFieldSection.ts b/packages/twenty-e2e-testing/lib/pom/settings/newFieldSection.ts new file mode 100644 index 000000000000..50b37e03ac5b --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/settings/newFieldSection.ts @@ -0,0 +1,250 @@ +import { Locator, Page } from '@playwright/test'; + +export class NewFieldSection { + private readonly searchTypeFieldInput: Locator; + private readonly currencyFieldLink: Locator; + private readonly currencyDefaultUnitSelect: Locator; + private readonly emailsFieldLink: Locator; + private readonly linksFieldLink: Locator; + private readonly phonesFieldLink: Locator; + private readonly addressFieldLink: Locator; + private readonly textFieldLink: Locator; + private readonly numberFieldLink: Locator; + private readonly decreaseDecimalsButton: Locator; + private readonly decimalsNumberInput: Locator; + private readonly increaseDecimalsButton: Locator; + private readonly booleanFieldLink: Locator; + private readonly defaultBooleanSelect: Locator; + private readonly dateTimeFieldLink: Locator; + private readonly dateFieldLink: Locator; + private readonly relativeDateToggle: Locator; + private readonly selectFieldLink: Locator; + private readonly multiSelectFieldLink: Locator; + private readonly setAsDefaultOptionButton: Locator; + private readonly removeOptionButton: Locator; + private readonly addOptionButton: Locator; + private readonly ratingFieldLink: Locator; + private readonly JSONFieldLink: Locator; + private readonly arrayFieldLink: Locator; + private readonly relationFieldLink: Locator; + private readonly relationTypeSelect: Locator; + private readonly objectDestinationSelect: Locator; + private readonly relationFieldNameInput: Locator; + private readonly fullNameFieldLink: Locator; + private readonly UUIDFieldLink: Locator; + private readonly nameFieldInput: Locator; + private readonly descriptionFieldInput: Locator; + + constructor(public readonly page: Page) { + this.searchTypeFieldInput = page.getByPlaceholder('Search a type'); + this.currencyFieldLink = page.getByRole('link', { name: 'Currency' }); + this.currencyDefaultUnitSelect = page.locator( + "//span[contains(., 'Default Unit')]/../div", + ); + this.emailsFieldLink = page.getByRole('link', { name: 'Emails' }).nth(1); + this.linksFieldLink = page.getByRole('link', { name: 'Links' }); + this.phonesFieldLink = page.getByRole('link', { name: 'Phones' }); + this.addressFieldLink = page.getByRole('link', { name: 'Address' }); + this.textFieldLink = page.getByRole('link', { name: 'Text' }); + this.numberFieldLink = page.getByRole('link', { name: 'Number' }); + this.decreaseDecimalsButton = page.locator( + "//div[contains(., 'Number of decimals')]/../div[last()]/div/div/button[2]", + ); + this.decimalsNumberInput = page.locator( + // would be better if first div was span tag + "//div[contains(., 'Number of decimals')]/../div[last()]/div/div/div/div/input[2]", + ); + this.increaseDecimalsButton = page.locator( + "//div[contains(., 'Number of decimals')]/../div[last()]/div/div/button[3]", + ); + this.booleanFieldLink = page.getByRole('link', { name: 'True/False' }); + this.defaultBooleanSelect = page.locator( + "//span[contains(., 'Default Value')]/../div", + ); + this.dateTimeFieldLink = page.getByRole('link', { name: 'Date and Time' }); + this.dateFieldLink = page.getByRole('link', { name: 'Date' }); + this.relativeDateToggle = page.getByRole('checkbox').nth(1); + this.selectFieldLink = page.getByRole('link', { name: 'Select' }); + this.multiSelectFieldLink = page.getByRole('link', { + name: 'Multi-select', + }); + this.setAsDefaultOptionButton = page + .getByTestId('tooltip') + .getByText('Set as default'); + this.removeOptionButton = page + .getByTestId('tooltip') + .getByText('Remove option'); + this.addOptionButton = page.getByRole('button', { name: 'Add option' }); + this.ratingFieldLink = page.getByRole('link', { name: 'Rating' }); + this.JSONFieldLink = page.getByRole('link', { name: 'JSON' }); + this.arrayFieldLink = page.getByRole('link', { name: 'Array' }); + this.relationFieldLink = page.getByRole('link', { name: 'Relation' }); + this.relationTypeSelect = page.locator( + "//span[contains(., 'Relation type')]/../div", + ); + this.objectDestinationSelect = page.locator( + "//span[contains(., 'Object destination')]/../div", + ); + this.relationIconSelect = page.getByLabel('Click to select icon (').nth(1); + this.relationFieldNameInput = page.getByPlaceholder('Field name'); + this.fullNameFieldLink = page.getByRole('link', { name: 'Full Name' }); + this.UUIDFieldLink = page.getByRole('link', { name: 'Unique ID' }); + this.nameFieldInput = page.getByPlaceholder('Employees'); + this.descriptionFieldInput = page.getByPlaceholder('Write a description'); + } + + async searchTypeField(name: string) { + await this.searchTypeFieldInput.fill(name); + } + + async clickCurrencyType() { + await this.currencyFieldLink.click(); + } + + async selectDefaultUnit(name: string) { + await this.currencyDefaultUnitSelect.click(); + await this.page.getByTestId('tooltip').filter({ hasText: name }).click(); + } + + async clickEmailsType() { + await this.emailsFieldLink.click(); + } + + async clickLinksType() { + await this.linksFieldLink.click(); + } + + async clickPhonesType() { + await this.phonesFieldLink.click(); + } + + async clickAddressType() { + await this.addressFieldLink.click(); + } + + async clickTextType() { + await this.textFieldLink.click(); + } + + async clickNumberType() { + await this.numberFieldLink.click(); + } + + async decreaseDecimals() { + await this.decreaseDecimalsButton.click(); + } + + async typeNumberOfDecimals(amount: number) { + await this.decimalsNumberInput.fill(String(amount)); + } + + async increaseDecimals() { + await this.increaseDecimalsButton.click(); + } + + async clickBooleanType() { + await this.booleanFieldLink.click(); + } + + // either True of False + async selectDefaultBooleanValue(value: string) { + await this.defaultBooleanSelect.click(); + await this.page.getByTestId('tooltip').filter({ hasText: value }).click(); + } + + async clickDateTimeType() { + await this.dateTimeFieldLink.click(); + } + + async clickDateType() { + await this.dateFieldLink.click(); + } + + async toggleRelativeDate() { + await this.relativeDateToggle.click(); + } + + async clickSelectType() { + await this.selectFieldLink.click(); + } + + async clickMultiSelectType() { + await this.multiSelectFieldLink.click(); + } + + async addSelectOption() { + await this.addOptionButton.click(); + } + + async setOptionAsDefault() { + // TODO: finish + await this.setAsDefaultOptionButton.click(); + } + + async deleteSelectOption() { + // TODO: finish + await this.removeOptionButton.click(); + } + + async changeOptionAPIName() { + // TODO: finish + } + + async changeOptionColor() { + // TODO: finish + } + + async changeOptionName() { + // TODO: finish + } + + async clickRatingType() { + await this.ratingFieldLink.click(); + } + + async clickJSONType() { + await this.JSONFieldLink.click(); + } + + async clickArrayType() { + await this.arrayFieldLink.click(); + } + + async clickRelationType() { + await this.relationFieldLink.click(); + } + + // either 'Has many' or 'Belongs to one' + async selectRelationType(name: string) { + await this.relationTypeSelect.click(); + await this.page.getByTestId('tooltip').filter({ hasText: name }).click(); + } + + async selectObjectDestination(name: string) { + await this.objectDestinationSelect.click(); + await this.page.getByTestId('tooltip').filter({ hasText: name }).click(); + } + + async typeRelationName(name: string) { + await this.relationFieldNameInput.clear(); + await this.relationFieldNameInput.fill(name); + } + + async clickFullNameType() { + await this.fullNameFieldLink.click(); + } + + async clickUUIDType() { + await this.UUIDFieldLink.click(); + } + + async typeFieldName(name: string) { + await this.nameFieldInput.clear(); + await this.nameFieldInput.fill(name); + } + + async typeFieldDescription(description: string) { + await this.descriptionFieldInput.clear(); + await this.descriptionFieldInput.fill(description); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/settings/profileSection.ts b/packages/twenty-e2e-testing/lib/pom/settings/profileSection.ts new file mode 100644 index 000000000000..32feb9ac586c --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/settings/profileSection.ts @@ -0,0 +1,44 @@ +import { Locator, Page } from '@playwright/test'; + +export class ProfileSection { + private readonly firstNameField: Locator; + private readonly lastNameField: Locator; + private readonly emailField: Locator; + private readonly changePasswordButton: Locator; + private readonly deleteAccountButton: Locator; + + constructor(public readonly page: Page) { + this.page = page; + this.firstNameField = page.getByPlaceholder('Tim'); + this.lastNameField = page.getByPlaceholder('Cook'); + this.emailField = page.getByRole('textbox').nth(2); + this.changePasswordButton = page.getByRole('button', { + name: 'Change Password', + }); + this.deleteAccountButton = page.getByRole('button', { + name: 'Delete account', + }); + } + + async changeFirstName(firstName: string) { + await this.firstNameField.clear(); + await this.firstNameField.fill(firstName); + } + + async changeLastName(lastName: string) { + await this.lastNameField.clear(); + await this.lastNameField.fill(lastName); + } + + async getEmail() { + await this.emailField.textContent(); + } + + async sendChangePasswordEmail() { + await this.changePasswordButton.click(); + } + + async deleteAccount() { + await this.deleteAccountButton.click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/settings/securitySection.ts b/packages/twenty-e2e-testing/lib/pom/settings/securitySection.ts new file mode 100644 index 000000000000..01b83b515578 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/settings/securitySection.ts @@ -0,0 +1,13 @@ +import { Locator, Page } from '@playwright/test'; + +export class SecuritySection { + private readonly inviteByLinkToggle: Locator; + + constructor(public readonly page: Page) { + this.inviteByLinkToggle = page.locator('input[type="checkbox"]').nth(1); + } + + async toggleInviteByLink() { + await this.inviteByLinkToggle.click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/pom/settingsPage.ts b/packages/twenty-e2e-testing/lib/pom/settingsPage.ts new file mode 100644 index 000000000000..c753bb8d0d1e --- /dev/null +++ b/packages/twenty-e2e-testing/lib/pom/settingsPage.ts @@ -0,0 +1,104 @@ +import { Locator, Page } from '@playwright/test'; + +export class SettingsPage { + private readonly exitSettingsLink: Locator; + private readonly profileLink: Locator; + private readonly experienceLink: Locator; + private readonly accountsLink: Locator; + private readonly emailsLink: Locator; + private readonly calendarsLink: Locator; + private readonly generalLink: Locator; + private readonly membersLink: Locator; + private readonly dataModelLink: Locator; + private readonly developersLink: Locator; + private readonly functionsLink: Locator; + private readonly securityLink: Locator; + private readonly integrationsLink: Locator; + private readonly releasesLink: Locator; + private readonly logoutLink: Locator; + private readonly advancedToggle: Locator; + + constructor(public readonly page: Page) { + this.page = page; + this.exitSettingsLink = page.getByRole('button', { name: 'Exit Settings' }); + this.profileLink = page.getByRole('link', { name: 'Profile' }); + this.experienceLink = page.getByRole('link', { name: 'Experience' }); + this.accountsLink = page.getByRole('link', { name: 'Accounts' }); + this.emailsLink = page.getByRole('link', { name: 'Emails', exact: true }); + this.calendarsLink = page.getByRole('link', { name: 'Calendars' }); + this.generalLink = page.getByRole('link', { name: 'General' }); + this.membersLink = page.getByRole('link', { name: 'Members' }); + this.dataModelLink = page.getByRole('link', { name: 'Data model' }); + this.developersLink = page.getByRole('link', { name: 'Developers' }); + this.functionsLink = page.getByRole('link', { name: 'Functions' }); + this.integrationsLink = page.getByRole('link', { name: 'Integrations' }); + this.securityLink = page.getByRole('link', { name: 'Security' }); + this.releasesLink = page.getByRole('link', { name: 'Releases' }); + this.logoutLink = page.getByText('Logout'); + this.advancedToggle = page.locator('input[type="checkbox"]').first(); + } + + async leaveSettingsPage() { + await this.exitSettingsLink.click(); + } + + async goToProfileSection() { + await this.profileLink.click(); + } + + async goToExperienceSection() { + await this.experienceLink.click(); + } + + async goToAccountsSection() { + await this.accountsLink.click(); + } + + async goToEmailsSection() { + await this.emailsLink.click(); + } + + async goToCalendarsSection() { + await this.calendarsLink.click(); + } + + async goToGeneralSection() { + await this.generalLink.click(); + } + + async goToMembersSection() { + await this.membersLink.click(); + } + + async goToDataModelSection() { + await this.dataModelLink.click(); + } + + async goToDevelopersSection() { + await this.developersLink.click(); + } + + async goToFunctionsSection() { + await this.functionsLink.click(); + } + + async goToSecuritySection() { + await this.securityLink.click(); + } + + async goToIntegrationsSection() { + await this.integrationsLink.click(); + } + + async goToReleasesIntegration() { + await this.releasesLink.click(); + } + + async logout() { + await this.logoutLink.click(); + } + + async toggleAdvancedSettings() { + await this.advancedToggle.click(); + } +} diff --git a/packages/twenty-e2e-testing/lib/utils/keyboardShortcuts.ts b/packages/twenty-e2e-testing/lib/utils/keyboardShortcuts.ts new file mode 100644 index 000000000000..470e63c1a5d8 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/utils/keyboardShortcuts.ts @@ -0,0 +1,94 @@ +import { Page } from '@playwright/test'; + +const MAC = process.platform === 'darwin'; + +async function keyDownCtrlOrMeta(page: Page) { + if (MAC) { + await page.keyboard.down('Meta'); + } else { + await page.keyboard.down('Control'); + } +} + +async function keyUpCtrlOrMeta(page: Page) { + if (MAC) { + await page.keyboard.up('Meta'); + } else { + await page.keyboard.up('Control'); + } +} + +export async function withCtrlOrMeta(page: Page, key: () => Promise) { + await keyDownCtrlOrMeta(page); + await key(); + await keyUpCtrlOrMeta(page); +} + +export async function selectAllByKeyboard(page: Page) { + await keyDownCtrlOrMeta(page); + await page.keyboard.press('a', { delay: 50 }); + await keyUpCtrlOrMeta(page); +} + +export async function copyByKeyboard(page: Page) { + await keyDownCtrlOrMeta(page); + await page.keyboard.press('c', { delay: 50 }); + await keyUpCtrlOrMeta(page); +} + +export async function cutByKeyboard(page: Page) { + await keyDownCtrlOrMeta(page); + await page.keyboard.press('x', { delay: 50 }); + await keyUpCtrlOrMeta(page); +} + +export async function pasteByKeyboard(page: Page) { + await keyDownCtrlOrMeta(page); + await page.keyboard.press('v', { delay: 50 }); + await keyUpCtrlOrMeta(page); +} + +export async function companiesShortcut(page: Page) { + await page.keyboard.press('g', { delay: 50 }); + await page.keyboard.press('c'); +} + +export async function notesShortcut(page: Page) { + await page.keyboard.press('g', { delay: 50 }); + await page.keyboard.press('n'); +} + +export async function opportunitiesShortcut(page: Page) { + await page.keyboard.press('g', { delay: 50 }); + await page.keyboard.press('o'); +} + +export async function peopleShortcut(page: Page) { + await page.keyboard.press('g', { delay: 50 }); + await page.keyboard.press('p'); +} + +export async function rocketsShortcut(page: Page) { + await page.keyboard.press('g', { delay: 50 }); + await page.keyboard.press('r'); +} + +export async function tasksShortcut(page: Page) { + await page.keyboard.press('g', { delay: 50 }); + await page.keyboard.press('t'); +} + +export async function workflowsShortcut(page: Page) { + await page.keyboard.press('g', { delay: 50 }); + await page.keyboard.press('w'); +} + +export async function settingsShortcut(page: Page) { + await page.keyboard.press('g', { delay: 50 }); + await page.keyboard.press('s'); +} + +export async function customShortcut(page: Page, shortcut: string) { + await page.keyboard.press('g', { delay: 50 }); + await page.keyboard.press(shortcut); +} diff --git a/packages/twenty-e2e-testing/lib/utils/pasteCodeToCodeEditor.ts b/packages/twenty-e2e-testing/lib/utils/pasteCodeToCodeEditor.ts new file mode 100644 index 000000000000..f67defef9047 --- /dev/null +++ b/packages/twenty-e2e-testing/lib/utils/pasteCodeToCodeEditor.ts @@ -0,0 +1,14 @@ +import { Locator, Page } from '@playwright/test'; +import { selectAllByKeyboard } from './keyboardShortcuts'; + +// https://github.com/microsoft/playwright/issues/14126 +// code must have \n at the end of lines otherwise everything will be in one line +export const pasteCodeToCodeEditor = async ( + page: Page, + locator: Locator, + code: string, +) => { + await locator.click(); + await selectAllByKeyboard(page); + await page.keyboard.type(code); +}; diff --git a/packages/twenty-e2e-testing/lib/utils/uploadFile.ts b/packages/twenty-e2e-testing/lib/utils/uploadFile.ts new file mode 100644 index 000000000000..81898bc2ba7d --- /dev/null +++ b/packages/twenty-e2e-testing/lib/utils/uploadFile.ts @@ -0,0 +1,15 @@ +import { Page } from '@playwright/test'; +import path from 'path'; + +export const fileUploader = async ( + page: Page, + trigger: () => Promise, + filename: string, +) => { + const fileChooserPromise = page.waitForEvent('filechooser'); + await trigger(); + const fileChooser = await fileChooserPromise; + await fileChooser.setFiles( + path.join(__dirname, '..', 'test_files', filename), + ); +}; diff --git a/packages/twenty-e2e-testing/tests/companies.spec.ts b/packages/twenty-e2e-testing/tests/companies.spec.ts index b8f78c7ecad7..1aa53d61322e 100644 --- a/packages/twenty-e2e-testing/tests/companies.spec.ts +++ b/packages/twenty-e2e-testing/tests/companies.spec.ts @@ -1,7 +1,4 @@ import { test, expect } from '../lib/fixtures/screenshot'; -import { config } from 'dotenv'; -import path = require('path'); -config({ path: path.resolve(__dirname, '..', '.env') }); test.describe('Basic check', () => { test('Checking if table in Companies is visible', async ({ page }) => { diff --git a/packages/twenty-emails/package.json b/packages/twenty-emails/package.json index 408930ac02ec..2a8354371baa 100644 --- a/packages/twenty-emails/package.json +++ b/packages/twenty-emails/package.json @@ -1,6 +1,6 @@ { "name": "twenty-emails", - "version": "0.32.0-canary", + "version": "0.33.0-canary", "description": "", "author": "", "private": true, diff --git a/packages/twenty-front/.env.example b/packages/twenty-front/.env.example index 3fccb201c4b9..345d0fb92ad7 100644 --- a/packages/twenty-front/.env.example +++ b/packages/twenty-front/.env.example @@ -2,6 +2,7 @@ REACT_APP_SERVER_BASE_URL=http://localhost:3000 GENERATE_SOURCEMAP=false # ———————— Optional ———————— +# REACT_APP_PORT=3001 # CHROMATIC_PROJECT_TOKEN= # VITE_DISABLE_TYPESCRIPT_CHECKER=true # VITE_DISABLE_ESLINT_CHECKER=true \ No newline at end of file diff --git a/packages/twenty-front/.eslintrc.cjs b/packages/twenty-front/.eslintrc.cjs index df4daf7633a4..4d14adac114f 100644 --- a/packages/twenty-front/.eslintrc.cjs +++ b/packages/twenty-front/.eslintrc.cjs @@ -1,3 +1,5 @@ +const path = require('path'); + module.exports = { extends: ['../../.eslintrc.cjs', '../../.eslintrc.react.cjs'], ignorePatterns: [ @@ -21,7 +23,16 @@ module.exports = { parserOptions: { project: ['packages/twenty-front/tsconfig.{json,*.json}'], }, - rules: {}, + plugins: ['project-structure'], + settings: { + 'project-structure/folder-structure-config-path':path.resolve( + __dirname, + 'folderStructure.json' + ) + }, + rules: { + 'project-structure/folder-structure': 'error', + }, }, ], }; diff --git a/packages/twenty-front/.gitignore b/packages/twenty-front/.gitignore index a9e8e5b674c2..49985254bed4 100644 --- a/packages/twenty-front/.gitignore +++ b/packages/twenty-front/.gitignore @@ -41,4 +41,7 @@ dist-ssr *.sw? .vite/ -.nyc_output/ \ No newline at end of file +.nyc_output/ + +# eslint-plugin-project-structure +projectStructure.cache.json diff --git a/packages/twenty-front/.storybook/main.ts b/packages/twenty-front/.storybook/main.ts index 8b65348c4b69..6d34593abb2c 100644 --- a/packages/twenty-front/.storybook/main.ts +++ b/packages/twenty-front/.storybook/main.ts @@ -50,8 +50,13 @@ const config: StorybookConfig = { const { mergeConfig } = await import('vite'); return mergeConfig(config, { - // Add dependencies to pre-optimization + resolve: { + alias: { + 'react-dom/client': 'react-dom/profiling', + }, + }, }); }, + logLevel: 'error', }; export default config; diff --git a/packages/twenty-front/.storybook/preview.tsx b/packages/twenty-front/.storybook/preview.tsx index 1d67634e2a54..d35b87e856ca 100644 --- a/packages/twenty-front/.storybook/preview.tsx +++ b/packages/twenty-front/.storybook/preview.tsx @@ -29,6 +29,7 @@ initialize({ with payload ${JSON.stringify(requestBody)}\n This request should be mocked with MSW`); }, + quiet: true, }); const preview: Preview = { diff --git a/packages/twenty-front/folderStructure.json b/packages/twenty-front/folderStructure.json new file mode 100644 index 000000000000..2509807f518f --- /dev/null +++ b/packages/twenty-front/folderStructure.json @@ -0,0 +1,65 @@ +{ + "$schema": "../../node_modules/eslint-plugin-project-structure/folderStructure.schema.json", + "projectRoot": "packages/twenty-front", + "structureRoot": "src", + "regexParameters": { + "camelCase": "^[a-z]+([A-Za-z0-9]+)+", + "kebab-case": "[a-z][a-z0-9]*(?:-[a-z0-9]+)*" + }, + "structure": [ + { "name": "*" }, + { "name": "*", "children": [] }, + { "name": "modules", "ruleId": "modulesFolderRule" } + ], + "rules": { + "modulesFolderRule": { + "children": [ + { "ruleId": "moduleFolderRule" }, + { "name": "types", "children": [] } + ] + }, + + "moduleFolderRule": { + "name": "{kebab-case}", + "folderRecursionLimit": 6, + "children": [ + { "ruleId": "moduleFolderRule" }, + { "name": "hooks", "ruleId": "hooksLeafFolderRule" }, + { "name": "utils", "ruleId": "utilsLeafFolderRule" }, + { "name": "states", "children": [] }, + { "name": "types", "children": [] }, + { "name": "graphql", "children": [] }, + { "name": "components", "children": [] }, + { "name": "effect-components", "children": [] }, + { "name": "constants", "children": [] }, + { "name": "validation-schemas", "children": [] }, + { "name": "contexts", "children": [] }, + { "name": "scopes", "children": [] }, + { "name": "services", "children": [] }, + { "name": "errors", "children": [] } + ] + }, + + "hooksLeafFolderRule": { + "folderRecursionLimit": 2, + "children": [ + { "name": "use{PascalCase}.(ts|tsx)" }, + { + "name": "__tests__", + "children": [{ "name": "use{PascalCase}.test.(ts|tsx)" }] + }, + { "name": "internal", "ruleId": "hooksLeafFolderRule" } + ] + }, + + "utilsLeafFolderRule": { + "children": [ + { "name": "{camelCase}.ts" }, + { + "name": "__tests__", + "children": [{ "name": "{camelCase}.test.ts" }] + } + ] + } + } +} diff --git a/packages/twenty-front/index.html b/packages/twenty-front/index.html index 9be5c2b41f86..644e29d74fe2 100644 --- a/packages/twenty-front/index.html +++ b/packages/twenty-front/index.html @@ -6,7 +6,6 @@ - Twenty + + + diff --git a/packages/twenty-front/jest.config.ts b/packages/twenty-front/jest.config.ts index 8ed7f398db4e..ecf046e155e8 100644 --- a/packages/twenty-front/jest.config.ts +++ b/packages/twenty-front/jest.config.ts @@ -25,9 +25,9 @@ const jestConfig: JestConfigWithTsJest = { extensionsToTreatAsEsm: ['.ts', '.tsx'], coverageThreshold: { global: { - statements: 60, + statements: 58, lines: 55, - functions: 50, + functions: 47, }, }, collectCoverageFrom: ['/src/**/*.ts'], diff --git a/packages/twenty-front/nyc.config.cjs b/packages/twenty-front/nyc.config.cjs index 8ae501c6910f..b4c04651ecb6 100644 --- a/packages/twenty-front/nyc.config.cjs +++ b/packages/twenty-front/nyc.config.cjs @@ -10,7 +10,7 @@ const modulesCoverage = { branches: 25, statements: 49, lines: 50, - functions: 40, + functions: 38, include: ['src/modules/**/*'], exclude: ['src/**/*.ts'], }; diff --git a/packages/twenty-front/package.json b/packages/twenty-front/package.json index 80eb86a7cf2a..ff7a7bf7bf40 100644 --- a/packages/twenty-front/package.json +++ b/packages/twenty-front/package.json @@ -1,6 +1,6 @@ { "name": "twenty-front", - "version": "0.32.0-canary", + "version": "0.33.0-canary", "private": true, "type": "module", "scripts": { @@ -33,6 +33,12 @@ "@nivo/calendar": "^0.87.0", "@nivo/core": "^0.87.0", "@nivo/line": "^0.87.0", + "@tiptap/extension-document": "^2.9.0", + "@tiptap/extension-paragraph": "^2.9.0", + "@tiptap/extension-placeholder": "^2.9.0", + "@tiptap/extension-text": "^2.9.0", + "@tiptap/extension-text-style": "^2.8.0", + "@tiptap/react": "^2.8.0", "@xyflow/react": "^12.0.4", "transliteration": "^2.3.5" } diff --git a/packages/twenty-front/project.json b/packages/twenty-front/project.json index 3ed94b22f256..ad72457839c0 100644 --- a/packages/twenty-front/project.json +++ b/packages/twenty-front/project.json @@ -52,7 +52,9 @@ "reportUnusedDisableDirectives": "error" }, "configurations": { - "ci": { "eslintConfig": "{projectRoot}/.eslintrc-ci.cjs" }, + "ci": { + "eslintConfig": "{projectRoot}/.eslintrc-ci.cjs" + }, "fix": {} } }, @@ -68,6 +70,12 @@ "storybook:build": { "options": { "env": { "NODE_OPTIONS": "--max_old_space_size=6500" } + }, + "configurations": { + "docs": { "env": { "STORYBOOK_SCOPE": "ui-docs" } }, + "modules": { "env": { "STORYBOOK_SCOPE": "modules" } }, + "pages": { "env": { "STORYBOOK_SCOPE": "pages" } }, + "performance": { "env": { "STORYBOOK_SCOPE": "performance" } } } }, "storybook:serve:dev": { @@ -80,7 +88,13 @@ } }, "storybook:serve:static": { - "options": { "port": 6006 } + "options": { "port": 6006 }, + "configurations": { + "docs": { "env": { "STORYBOOK_SCOPE": "ui-docs" } }, + "modules": { "env": { "STORYBOOK_SCOPE": "modules" } }, + "pages": { "env": { "STORYBOOK_SCOPE": "pages" } }, + "performance": { "env": { "STORYBOOK_SCOPE": "performance" } } + } }, "storybook:coverage": { "configurations": { @@ -102,9 +116,6 @@ }, "storybook:serve-and-test:static": { "options": { - "commands": [ - "npx concurrently --kill-others --success=first -n SB,TEST 'nx storybook:serve:static {projectName} --port={args.port}' 'npx wait-on tcp:{args.port} && nx storybook:test {projectName} --port={args.port} --configuration={args.scope}'" - ], "port": 6006 }, "configurations": { @@ -114,15 +125,6 @@ "performance": { "scope": "performance" } } }, - "storybook:serve-and-test:static:performance": {}, - "storybook:test:no-coverage": { - "configurations": { - "docs": { "env": { "STORYBOOK_SCOPE": "ui-docs" } }, - "modules": { "env": { "STORYBOOK_SCOPE": "modules" } }, - "pages": { "env": { "STORYBOOK_SCOPE": "pages" } }, - "performance": { "env": { "STORYBOOK_SCOPE": "performance" } } - } - }, "graphql:generate": { "executor": "nx:run-commands", "defaultConfiguration": "data", diff --git a/packages/twenty-front/public/logos/20-high-resolution-logo-black-transparent.png b/packages/twenty-front/public/logos/20-high-resolution-logo-black-transparent.png new file mode 100644 index 000000000000..236da7815022 Binary files /dev/null and b/packages/twenty-front/public/logos/20-high-resolution-logo-black-transparent.png differ diff --git a/packages/twenty-front/public/logos/20-high-resolution-logo-black.png b/packages/twenty-front/public/logos/20-high-resolution-logo-black.png new file mode 100644 index 000000000000..db41f79831e5 Binary files /dev/null and b/packages/twenty-front/public/logos/20-high-resolution-logo-black.png differ diff --git a/packages/twenty-front/public/logos/20-high-resolution-logo-white-transparent.png b/packages/twenty-front/public/logos/20-high-resolution-logo-white-transparent.png new file mode 100644 index 000000000000..a7437a0f356f Binary files /dev/null and b/packages/twenty-front/public/logos/20-high-resolution-logo-white-transparent.png differ diff --git a/packages/twenty-front/public/logos/20-high-resolution-logo.png b/packages/twenty-front/public/logos/20-high-resolution-logo.png new file mode 100644 index 000000000000..0dbe8de6cba1 Binary files /dev/null and b/packages/twenty-front/public/logos/20-high-resolution-logo.png differ diff --git a/packages/twenty-front/src/__stories__/AppRouter.stories.tsx b/packages/twenty-front/src/__stories__/AppRouter.stories.tsx index 9d2fe91a6523..f322919baeea 100644 --- a/packages/twenty-front/src/__stories__/AppRouter.stories.tsx +++ b/packages/twenty-front/src/__stories__/AppRouter.stories.tsx @@ -1,18 +1,18 @@ import { getOperationName } from '@apollo/client/utilities'; import { jest } from '@storybook/jest'; import { Meta, StoryObj } from '@storybook/react'; -import { graphql, HttpResponse } from 'msw'; +import { HttpResponse, graphql } from 'msw'; import { HelmetProvider } from 'react-helmet-async'; import { RecoilRoot } from 'recoil'; -import { IconsProvider } from 'twenty-ui'; import { AppErrorBoundary } from '@/error-handler/components/AppErrorBoundary'; import indexAppPath from '@/navigation/utils/indexAppPath'; -import { AppPath } from '@/types/AppPath'; import { SnackBarProviderScope } from '@/ui/feedback/snack-bar-manager/scopes/SnackBarProviderScope'; import { GET_CURRENT_USER } from '@/users/graphql/queries/getCurrentUser'; import { AppRouter } from '@/app/components/AppRouter'; +import { AppPath } from '@/types/AppPath'; +import { IconsProvider } from 'twenty-ui'; import { graphqlMocks } from '~/testing/graphqlMocks'; import { mockedUserData } from '~/testing/mock-data/users'; diff --git a/packages/twenty-front/src/generated-metadata/gql.ts b/packages/twenty-front/src/generated-metadata/gql.ts index 415482505650..5d8e10a5ec05 100644 --- a/packages/twenty-front/src/generated-metadata/gql.ts +++ b/packages/twenty-front/src/generated-metadata/gql.ts @@ -32,16 +32,16 @@ const documents = { "\n mutation DeleteOneObjectMetadataItem($idToDelete: UUID!) {\n deleteOneObject(input: { id: $idToDelete }) {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isActive\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n }\n }\n": types.DeleteOneObjectMetadataItemDocument, "\n mutation DeleteOneFieldMetadataItem($idToDelete: UUID!) {\n deleteOneField(input: { id: $idToDelete }) {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isNullable\n createdAt\n updatedAt\n settings\n }\n }\n": types.DeleteOneFieldMetadataItemDocument, "\n mutation DeleteOneRelationMetadataItem($idToDelete: UUID!) {\n deleteOneRelation(input: { id: $idToDelete }) {\n id\n }\n }\n": types.DeleteOneRelationMetadataItemDocument, - "\n query ObjectMetadataItems(\n $objectFilter: objectFilter\n $fieldFilter: fieldFilter\n ) {\n objects(paging: { first: 1000 }, filter: $objectFilter) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\n }\n }\n fields(paging: { first: 1000 }, filter: $fieldFilter) {\n edges {\n node {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n": types.ObjectMetadataItemsDocument, - "\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n syncStatus\n latestVersion\n createdAt\n updatedAt\n }\n": types.ServerlessFunctionFieldsFragmentDoc, + "\n query ObjectMetadataItems(\n $objectFilter: objectFilter\n $fieldFilter: fieldFilter\n ) {\n objects(paging: { first: 1000 }, filter: $objectFilter) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\n }\n }\n fields(paging: { first: 1000 }, filter: $fieldFilter) {\n edges {\n node {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n": types.ObjectMetadataItemsDocument, + "\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n syncStatus\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n": types.ServerlessFunctionFieldsFragmentDoc, "\n \n mutation CreateOneServerlessFunctionItem(\n $input: CreateServerlessFunctionInput!\n ) {\n createOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.CreateOneServerlessFunctionItemDocument, - "\n \n mutation DeleteOneServerlessFunction($input: DeleteServerlessFunctionInput!) {\n deleteOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.DeleteOneServerlessFunctionDocument, + "\n \n mutation DeleteOneServerlessFunction($input: ServerlessFunctionIdInput!) {\n deleteOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.DeleteOneServerlessFunctionDocument, "\n mutation ExecuteOneServerlessFunction(\n $input: ExecuteServerlessFunctionInput!\n ) {\n executeOneServerlessFunction(input: $input) {\n data\n duration\n status\n error\n }\n }\n": types.ExecuteOneServerlessFunctionDocument, "\n \n mutation PublishOneServerlessFunction(\n $input: PublishServerlessFunctionInput!\n ) {\n publishServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.PublishOneServerlessFunctionDocument, "\n \n mutation UpdateOneServerlessFunction($input: UpdateServerlessFunctionInput!) {\n updateOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.UpdateOneServerlessFunctionDocument, "\n query FindManyAvailablePackages {\n getAvailablePackages\n }\n": types.FindManyAvailablePackagesDocument, - "\n \n query GetManyServerlessFunctions {\n serverlessFunctions(paging: { first: 100 }) {\n edges {\n node {\n ...ServerlessFunctionFields\n }\n }\n }\n }\n": types.GetManyServerlessFunctionsDocument, - "\n \n query GetOneServerlessFunction($id: UUID!) {\n serverlessFunction(id: $id) {\n ...ServerlessFunctionFields\n }\n }\n": types.GetOneServerlessFunctionDocument, + "\n \n query GetManyServerlessFunctions {\n findManyServerlessFunctions {\n ...ServerlessFunctionFields\n }\n }\n": types.GetManyServerlessFunctionsDocument, + "\n \n query GetOneServerlessFunction($input: ServerlessFunctionIdInput!) {\n findOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n": types.GetOneServerlessFunctionDocument, "\n query FindOneServerlessFunctionSourceCode(\n $input: GetServerlessFunctionSourceCodeInput!\n ) {\n getServerlessFunctionSourceCode(input: $input)\n }\n": types.FindOneServerlessFunctionSourceCodeDocument, }; @@ -138,11 +138,11 @@ export function graphql(source: "\n mutation DeleteOneRelationMetadataItem($idT /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n query ObjectMetadataItems(\n $objectFilter: objectFilter\n $fieldFilter: fieldFilter\n ) {\n objects(paging: { first: 1000 }, filter: $objectFilter) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\n }\n }\n fields(paging: { first: 1000 }, filter: $fieldFilter) {\n edges {\n node {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n"): (typeof documents)["\n query ObjectMetadataItems(\n $objectFilter: objectFilter\n $fieldFilter: fieldFilter\n ) {\n objects(paging: { first: 1000 }, filter: $objectFilter) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\n }\n }\n fields(paging: { first: 1000 }, filter: $fieldFilter) {\n edges {\n node {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n"]; +export function graphql(source: "\n query ObjectMetadataItems(\n $objectFilter: objectFilter\n $fieldFilter: fieldFilter\n ) {\n objects(paging: { first: 1000 }, filter: $objectFilter) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\n }\n }\n fields(paging: { first: 1000 }, filter: $fieldFilter) {\n edges {\n node {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n"): (typeof documents)["\n query ObjectMetadataItems(\n $objectFilter: objectFilter\n $fieldFilter: fieldFilter\n ) {\n objects(paging: { first: 1000 }, filter: $objectFilter) {\n edges {\n node {\n id\n dataSourceId\n nameSingular\n namePlural\n labelSingular\n labelPlural\n description\n icon\n isCustom\n isRemote\n isActive\n isSystem\n createdAt\n updatedAt\n labelIdentifierFieldMetadataId\n imageIdentifierFieldMetadataId\n shortcut\n isLabelSyncedWithName\n indexMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n name\n indexWhereClause\n indexType\n isUnique\n indexFieldMetadatas(paging: { first: 100 }) {\n edges {\n node {\n id\n createdAt\n updatedAt\n order\n fieldMetadataId\n }\n }\n }\n }\n }\n }\n fields(paging: { first: 1000 }, filter: $fieldFilter) {\n edges {\n node {\n id\n type\n name\n label\n description\n icon\n isCustom\n isActive\n isSystem\n isNullable\n isUnique\n createdAt\n updatedAt\n defaultValue\n options\n settings\n relationDefinition {\n relationId\n direction\n sourceObjectMetadata {\n id\n nameSingular\n namePlural\n }\n sourceFieldMetadata {\n id\n name\n }\n targetObjectMetadata {\n id\n nameSingular\n namePlural\n }\n targetFieldMetadata {\n id\n name\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n }\n pageInfo {\n hasNextPage\n hasPreviousPage\n startCursor\n endCursor\n }\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n syncStatus\n latestVersion\n createdAt\n updatedAt\n }\n"): (typeof documents)["\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n syncStatus\n latestVersion\n createdAt\n updatedAt\n }\n"]; +export function graphql(source: "\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n syncStatus\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n"): (typeof documents)["\n fragment ServerlessFunctionFields on ServerlessFunction {\n id\n name\n description\n runtime\n syncStatus\n latestVersion\n latestVersionInputSchema\n publishedVersions\n createdAt\n updatedAt\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -150,7 +150,7 @@ export function graphql(source: "\n \n mutation CreateOneServerlessFunctionIte /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n \n mutation DeleteOneServerlessFunction($input: DeleteServerlessFunctionInput!) {\n deleteOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n"): (typeof documents)["\n \n mutation DeleteOneServerlessFunction($input: DeleteServerlessFunctionInput!) {\n deleteOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n"]; +export function graphql(source: "\n \n mutation DeleteOneServerlessFunction($input: ServerlessFunctionIdInput!) {\n deleteOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n"): (typeof documents)["\n \n mutation DeleteOneServerlessFunction($input: ServerlessFunctionIdInput!) {\n deleteOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ @@ -170,11 +170,11 @@ export function graphql(source: "\n query FindManyAvailablePackages {\n getA /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n \n query GetManyServerlessFunctions {\n serverlessFunctions(paging: { first: 100 }) {\n edges {\n node {\n ...ServerlessFunctionFields\n }\n }\n }\n }\n"): (typeof documents)["\n \n query GetManyServerlessFunctions {\n serverlessFunctions(paging: { first: 100 }) {\n edges {\n node {\n ...ServerlessFunctionFields\n }\n }\n }\n }\n"]; +export function graphql(source: "\n \n query GetManyServerlessFunctions {\n findManyServerlessFunctions {\n ...ServerlessFunctionFields\n }\n }\n"): (typeof documents)["\n \n query GetManyServerlessFunctions {\n findManyServerlessFunctions {\n ...ServerlessFunctionFields\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ -export function graphql(source: "\n \n query GetOneServerlessFunction($id: UUID!) {\n serverlessFunction(id: $id) {\n ...ServerlessFunctionFields\n }\n }\n"): (typeof documents)["\n \n query GetOneServerlessFunction($id: UUID!) {\n serverlessFunction(id: $id) {\n ...ServerlessFunctionFields\n }\n }\n"]; +export function graphql(source: "\n \n query GetOneServerlessFunction($input: ServerlessFunctionIdInput!) {\n findOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n"): (typeof documents)["\n \n query GetOneServerlessFunction($input: ServerlessFunctionIdInput!) {\n findOneServerlessFunction(input: $input) {\n ...ServerlessFunctionFields\n }\n }\n"]; /** * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. */ diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 97705b937cba..b26b39ddc122 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -28,13 +28,6 @@ export type Scalars = { Upload: { input: any; output: any; } }; -export type AisqlQueryResult = { - __typename?: 'AISQLQueryResult'; - queryFailedErrorMessage?: Maybe; - sqlQuery: Scalars['String']['output']; - sqlQueryResult?: Maybe; -}; - export type ActivateWorkspaceInput = { displayName?: InputMaybe; }; @@ -45,6 +38,16 @@ export type Analytics = { success: Scalars['Boolean']['output']; }; +export type AnalyticsTinybirdJwtMap = { + __typename?: 'AnalyticsTinybirdJwtMap'; + getPageviewsAnalytics: Scalars['String']['output']; + getServerlessFunctionDuration: Scalars['String']['output']; + getServerlessFunctionErrorCount: Scalars['String']['output']; + getServerlessFunctionSuccessRate: Scalars['String']['output']; + getUsersAnalytics: Scalars['String']['output']; + getWebhookAnalytics: Scalars['String']['output']; +}; + export type ApiConfig = { __typename?: 'ApiConfig'; mutationMaximumAffectedRecords: Scalars['Float']['output']; @@ -78,6 +81,7 @@ export type AuthProviders = { magicLink: Scalars['Boolean']['output']; microsoft: Scalars['Boolean']['output']; password: Scalars['Boolean']['output']; + sso: Scalars['Boolean']['output']; }; export type AuthToken = { @@ -155,6 +159,7 @@ export enum CaptchaDriverType { export type ClientConfig = { __typename?: 'ClientConfig'; + analyticsEnabled: Scalars['Boolean']['output']; api: ApiConfig; authProviders: AuthProviders; billing: Billing; @@ -167,6 +172,11 @@ export type ClientConfig = { support: Support; }; +export type ComputeStepOutputSchemaInput = { + /** Step JSON format */ + step: Scalars['JSON']['input']; +}; + export type CreateAppTokenInput = { expiresAt: Scalars['DateTime']['input']; }; @@ -193,6 +203,7 @@ export type CreateObjectInput = { description?: InputMaybe; icon?: InputMaybe; imageIdentifierFieldMetadataId?: InputMaybe; + isLabelSyncedWithName?: InputMaybe; isRemote?: InputMaybe; labelIdentifierFieldMetadataId?: InputMaybe; labelPlural: Scalars['String']['input']; @@ -201,6 +212,7 @@ export type CreateObjectInput = { nameSingular: Scalars['String']['input']; primaryKeyColumnType?: InputMaybe; primaryKeyFieldMetadataSettings?: InputMaybe; + shortcut?: InputMaybe; }; export type CreateOneAppTokenInput = { @@ -277,9 +289,13 @@ export type DeleteOneRelationInput = { id: Scalars['UUID']['input']; }; -export type DeleteServerlessFunctionInput = { - /** The id of the function. */ - id: Scalars['ID']['input']; +export type DeleteSsoInput = { + identityProviderId: Scalars['String']['input']; +}; + +export type DeleteSsoOutput = { + __typename?: 'DeleteSsoOutput'; + identityProviderId: Scalars['String']['output']; }; /** Schema update on a table */ @@ -290,6 +306,20 @@ export enum DistantTableUpdate { TableDeleted = 'TABLE_DELETED' } +export type EditSsoInput = { + id: Scalars['String']['input']; + status: SsoIdentityProviderStatus; +}; + +export type EditSsoOutput = { + __typename?: 'EditSsoOutput'; + id: Scalars['String']['output']; + issuer: Scalars['String']['output']; + name: Scalars['String']['output']; + status: SsoIdentityProviderStatus; + type: IdpType; +}; + export type EmailPasswordResetLink = { __typename?: 'EmailPasswordResetLink'; /** Boolean that confirms query was dispatched */ @@ -379,6 +409,20 @@ export enum FileFolder { WorkspaceLogo = 'WorkspaceLogo' } +export type FindAvailableSsoidpInput = { + email: Scalars['String']['input']; +}; + +export type FindAvailableSsoidpOutput = { + __typename?: 'FindAvailableSSOIDPOutput'; + id: Scalars['String']['output']; + issuer: Scalars['String']['output']; + name: Scalars['String']['output']; + status: SsoIdentityProviderStatus; + type: IdpType; + workspace: WorkspaceNameAndId; +}; + export type FindManyRemoteTablesInput = { /** The id of the remote server. */ id: Scalars['ID']['input']; @@ -392,6 +436,33 @@ export type FullName = { lastName: Scalars['String']['output']; }; +export type GenerateJwt = GenerateJwtOutputWithAuthTokens | GenerateJwtOutputWithSsoauth; + +export type GenerateJwtOutputWithAuthTokens = { + __typename?: 'GenerateJWTOutputWithAuthTokens'; + authTokens: AuthTokens; + reason: Scalars['String']['output']; + success: Scalars['Boolean']['output']; +}; + +export type GenerateJwtOutputWithSsoauth = { + __typename?: 'GenerateJWTOutputWithSSOAUTH'; + availableSSOIDPs: Array; + reason: Scalars['String']['output']; + success: Scalars['Boolean']['output']; +}; + +export type GetAuthorizationUrlInput = { + identityProviderId: Scalars['String']['input']; +}; + +export type GetAuthorizationUrlOutput = { + __typename?: 'GetAuthorizationUrlOutput'; + authorizationURL: Scalars['String']['output']; + id: Scalars['String']['output']; + type: Scalars['String']['output']; +}; + export type GetServerlessFunctionSourceCodeInput = { /** The id of the function. */ id: Scalars['ID']['input']; @@ -399,6 +470,11 @@ export type GetServerlessFunctionSourceCodeInput = { version?: Scalars['String']['input']; }; +export enum IdpType { + Oidc = 'OIDC', + Saml = 'SAML' +} + export type IndexConnection = { __typename?: 'IndexConnection'; /** Array of edges. */ @@ -468,12 +544,15 @@ export type Mutation = { authorizeApp: AuthorizeApp; challenge: LoginToken; checkoutSession: SessionEntity; + computeStepOutputSchema: Scalars['JSON']['output']; + createOIDCIdentityProvider: SetupSsoOutput; createOneAppToken: AppToken; createOneField: Field; createOneObject: Object; createOneRelation: Relation; createOneRemoteServer: RemoteServer; createOneServerlessFunction: ServerlessFunction; + createSAMLIdentityProvider: SetupSsoOutput; deactivateWorkflowVersion: Scalars['Boolean']['output']; deleteCurrentWorkspace: Workspace; deleteOneField: Field; @@ -481,16 +560,20 @@ export type Mutation = { deleteOneRelation: Relation; deleteOneRemoteServer: RemoteServer; deleteOneServerlessFunction: ServerlessFunction; + deleteSSOIdentityProvider: DeleteSsoOutput; deleteUser: User; deleteWorkspaceInvitation: Scalars['String']['output']; disablePostgresProxy: PostgresCredentials; + editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; enablePostgresProxy: PostgresCredentials; exchangeAuthorizationCode: ExchangeAuthCode; executeOneServerlessFunction: ServerlessFunctionExecutionResult; + findAvailableSSOIdentityProviders: Array; generateApiKeyToken: ApiKeyToken; - generateJWT: AuthTokens; + generateJWT: GenerateJwt; generateTransientToken: TransientToken; + getAuthorizationUrl: GetAuthorizationUrlOutput; impersonate: Verify; publishServerlessFunction: ServerlessFunction; renewToken: AuthTokens; @@ -558,6 +641,16 @@ export type MutationCheckoutSessionArgs = { }; +export type MutationComputeStepOutputSchemaArgs = { + input: ComputeStepOutputSchemaInput; +}; + + +export type MutationCreateOidcIdentityProviderArgs = { + input: SetupOidcSsoInput; +}; + + export type MutationCreateOneAppTokenArgs = { input: CreateOneAppTokenInput; }; @@ -588,6 +681,11 @@ export type MutationCreateOneServerlessFunctionArgs = { }; +export type MutationCreateSamlIdentityProviderArgs = { + input: SetupSamlSsoInput; +}; + + export type MutationDeactivateWorkflowVersionArgs = { workflowVersionId: Scalars['String']['input']; }; @@ -614,7 +712,12 @@ export type MutationDeleteOneRemoteServerArgs = { export type MutationDeleteOneServerlessFunctionArgs = { - input: DeleteServerlessFunctionInput; + input: ServerlessFunctionIdInput; +}; + + +export type MutationDeleteSsoIdentityProviderArgs = { + input: DeleteSsoInput; }; @@ -623,6 +726,11 @@ export type MutationDeleteWorkspaceInvitationArgs = { }; +export type MutationEditSsoIdentityProviderArgs = { + input: EditSsoInput; +}; + + export type MutationEmailPasswordResetLinkArgs = { email: Scalars['String']['input']; }; @@ -640,6 +748,11 @@ export type MutationExecuteOneServerlessFunctionArgs = { }; +export type MutationFindAvailableSsoIdentityProvidersArgs = { + input: FindAvailableSsoidpInput; +}; + + export type MutationGenerateApiKeyTokenArgs = { apiKeyId: Scalars['String']['input']; expiresAt: Scalars['String']['input']; @@ -651,6 +764,11 @@ export type MutationGenerateJwtArgs = { }; +export type MutationGetAuthorizationUrlArgs = { + input: GetAuthorizationUrlInput; +}; + + export type MutationImpersonateArgs = { userId: Scalars['String']['input']; }; @@ -859,10 +977,11 @@ export type Query = { fields: FieldConnection; findDistantTablesWithStatus: Array; findManyRemoteServersByType: Array; + findManyServerlessFunctions: Array; findOneRemoteServerById: RemoteServer; + findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; - getAISQLQuery: AisqlQueryResult; getAvailablePackages: Scalars['JSON']['output']; getPostgresCredentials?: Maybe; getProductPrices: ProductPricesEntity; @@ -873,12 +992,11 @@ export type Query = { getTimelineThreadsFromPersonId: TimelineThreadsWithTotal; index: Index; indexMetadatas: IndexConnection; + listSSOIdentityProvidersByWorkspaceId: Array; object: Object; objects: ObjectConnection; relation: Relation; relations: RelationConnection; - serverlessFunction: ServerlessFunction; - serverlessFunctions: ServerlessFunctionConnection; validatePasswordResetToken: ValidatePasswordResetToken; }; @@ -925,13 +1043,13 @@ export type QueryFindOneRemoteServerByIdArgs = { }; -export type QueryFindWorkspaceFromInviteHashArgs = { - inviteHash: Scalars['String']['input']; +export type QueryFindOneServerlessFunctionArgs = { + input: ServerlessFunctionIdInput; }; -export type QueryGetAisqlQueryArgs = { - text: Scalars['String']['input']; +export type QueryFindWorkspaceFromInviteHashArgs = { + inviteHash: Scalars['String']['input']; }; @@ -1005,18 +1123,6 @@ export type QueryRelationsArgs = { }; -export type QueryServerlessFunctionArgs = { - id: Scalars['UUID']['input']; -}; - - -export type QueryServerlessFunctionsArgs = { - filter?: ServerlessFunctionFilter; - paging?: CursorPaging; - sorting?: Array; -}; - - export type QueryValidatePasswordResetTokenArgs = { passwordResetToken: Scalars['String']['input']; }; @@ -1104,6 +1210,12 @@ export type RunWorkflowVersionInput = { workflowVersionId: Scalars['String']['input']; }; +export enum SsoIdentityProviderStatus { + Active = 'Active', + Error = 'Error', + Inactive = 'Inactive' +} + export type SendInvitationsOutput = { __typename?: 'SendInvitationsOutput'; errors: Array; @@ -1125,28 +1237,14 @@ export type ServerlessFunction = { description?: Maybe; id: Scalars['UUID']['output']; latestVersion?: Maybe; + latestVersionInputSchema?: Maybe; name: Scalars['String']['output']; + publishedVersions: Array; runtime: Scalars['String']['output']; syncStatus: ServerlessFunctionSyncStatus; updatedAt: Scalars['DateTime']['output']; }; -export type ServerlessFunctionConnection = { - __typename?: 'ServerlessFunctionConnection'; - /** Array of edges. */ - edges: Array; - /** Paging information */ - pageInfo: PageInfo; -}; - -export type ServerlessFunctionEdge = { - __typename?: 'ServerlessFunctionEdge'; - /** Cursor for this node. */ - cursor: Scalars['ConnectionCursor']['output']; - /** The node containing the ServerlessFunction */ - node: ServerlessFunction; -}; - export type ServerlessFunctionExecutionResult = { __typename?: 'ServerlessFunctionExecutionResult'; /** Execution result in JSON format */ @@ -1165,22 +1263,11 @@ export enum ServerlessFunctionExecutionStatus { Success = 'SUCCESS' } -export type ServerlessFunctionFilter = { - and?: InputMaybe>; - id?: InputMaybe; - or?: InputMaybe>; -}; - -export type ServerlessFunctionSort = { - direction: SortDirection; - field: ServerlessFunctionSortFields; - nulls?: InputMaybe; +export type ServerlessFunctionIdInput = { + /** The id of the function. */ + id: Scalars['ID']['input']; }; -export enum ServerlessFunctionSortFields { - Id = 'id' -} - /** SyncStatus of the serverlessFunction */ export enum ServerlessFunctionSyncStatus { NotReady = 'NOT_READY', @@ -1192,6 +1279,31 @@ export type SessionEntity = { url?: Maybe; }; +export type SetupOidcSsoInput = { + clientID: Scalars['String']['input']; + clientSecret: Scalars['String']['input']; + issuer: Scalars['String']['input']; + name: Scalars['String']['input']; +}; + +export type SetupSamlSsoInput = { + certificate: Scalars['String']['input']; + fingerprint?: InputMaybe; + id: Scalars['String']['input']; + issuer: Scalars['String']['input']; + name: Scalars['String']['input']; + ssoURL: Scalars['String']['input']; +}; + +export type SetupSsoOutput = { + __typename?: 'SetupSsoOutput'; + id: Scalars['String']['output']; + issuer: Scalars['String']['output']; + name: Scalars['String']['output']; + status: SsoIdentityProviderStatus; + type: IdpType; +}; + /** Sort Directions */ export enum SortDirection { Asc = 'ASC', @@ -1340,11 +1452,13 @@ export type UpdateObjectPayload = { icon?: InputMaybe; imageIdentifierFieldMetadataId?: InputMaybe; isActive?: InputMaybe; + isLabelSyncedWithName?: InputMaybe; labelIdentifierFieldMetadataId?: InputMaybe; labelPlural?: InputMaybe; labelSingular?: InputMaybe; namePlural?: InputMaybe; nameSingular?: InputMaybe; + shortcut?: InputMaybe; }; export type UpdateOneFieldMetadataInput = { @@ -1381,11 +1495,13 @@ export type UpdateWorkspaceInput = { displayName?: InputMaybe; domainName?: InputMaybe; inviteHash?: InputMaybe; + isPublicInviteLinkEnabled?: InputMaybe; logo?: InputMaybe; }; export type User = { __typename?: 'User'; + analyticsTinybirdJwts?: Maybe; canImpersonate: Scalars['Boolean']['output']; createdAt: Scalars['DateTime']['output']; defaultAvatarUrl?: Maybe; @@ -1478,8 +1594,10 @@ export type Workspace = { displayName?: Maybe; domainName?: Maybe; featureFlags?: Maybe>; + hasValidEntrepriseKey: Scalars['Boolean']['output']; id: Scalars['UUID']['output']; inviteHash?: Maybe; + isPublicInviteLinkEnabled: Scalars['Boolean']['output']; logo?: Maybe; metadataVersion: Scalars['Float']['output']; updatedAt: Scalars['DateTime']['output']; @@ -1552,6 +1670,12 @@ export enum WorkspaceMemberTimeFormatEnum { System = 'SYSTEM' } +export type WorkspaceNameAndId = { + __typename?: 'WorkspaceNameAndId'; + displayName?: Maybe; + id: Scalars['String']['output']; +}; + export type Field = { __typename?: 'field'; createdAt: Scalars['DateTime']['output']; @@ -1670,6 +1794,7 @@ export type Object = { indexMetadatas: ObjectIndexMetadatasConnection; isActive: Scalars['Boolean']['output']; isCustom: Scalars['Boolean']['output']; + isLabelSyncedWithName: Scalars['Boolean']['output']; isRemote: Scalars['Boolean']['output']; isSystem: Scalars['Boolean']['output']; labelIdentifierFieldMetadataId?: Maybe; @@ -1677,6 +1802,7 @@ export type Object = { labelSingular: Scalars['String']['output']; namePlural: Scalars['String']['output']; nameSingular: Scalars['String']['output']; + shortcut?: Maybe; updatedAt: Scalars['DateTime']['output']; }; @@ -1863,23 +1989,23 @@ export type ObjectMetadataItemsQueryVariables = Exact<{ }>; -export type ObjectMetadataItemsQuery = { __typename?: 'Query', objects: { __typename?: 'ObjectConnection', edges: Array<{ __typename?: 'objectEdge', node: { __typename?: 'object', id: any, dataSourceId: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, createdAt: any, updatedAt: any, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, indexMetadatas: { __typename?: 'ObjectIndexMetadatasConnection', edges: Array<{ __typename?: 'indexEdge', node: { __typename?: 'index', id: any, createdAt: any, updatedAt: any, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, indexFieldMetadatas: { __typename?: 'IndexIndexFieldMetadatasConnection', edges: Array<{ __typename?: 'indexFieldEdge', node: { __typename?: 'indexField', id: any, createdAt: any, updatedAt: any, order: number, fieldMetadataId: any } }> } } }> }, fields: { __typename?: 'ObjectFieldsConnection', edges: Array<{ __typename?: 'fieldEdge', node: { __typename?: 'field', id: any, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: any, updatedAt: any, defaultValue?: any | null, options?: any | null, settings?: any | null, relationDefinition?: { __typename?: 'RelationDefinition', relationId: any, direction: RelationDefinitionType, sourceObjectMetadata: { __typename?: 'object', id: any, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'field', id: any, name: string }, targetObjectMetadata: { __typename?: 'object', id: any, nameSingular: string, namePlural: string }, targetFieldMetadata: { __typename?: 'field', id: any, name: string } } | null } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage?: boolean | null, hasPreviousPage?: boolean | null, startCursor?: any | null, endCursor?: any | null } } } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage?: boolean | null, hasPreviousPage?: boolean | null, startCursor?: any | null, endCursor?: any | null } } }; +export type ObjectMetadataItemsQuery = { __typename?: 'Query', objects: { __typename?: 'ObjectConnection', edges: Array<{ __typename?: 'objectEdge', node: { __typename?: 'object', id: any, dataSourceId: string, nameSingular: string, namePlural: string, labelSingular: string, labelPlural: string, description?: string | null, icon?: string | null, isCustom: boolean, isRemote: boolean, isActive: boolean, isSystem: boolean, createdAt: any, updatedAt: any, labelIdentifierFieldMetadataId?: string | null, imageIdentifierFieldMetadataId?: string | null, shortcut?: string | null, isLabelSyncedWithName: boolean, indexMetadatas: { __typename?: 'ObjectIndexMetadatasConnection', edges: Array<{ __typename?: 'indexEdge', node: { __typename?: 'index', id: any, createdAt: any, updatedAt: any, name: string, indexWhereClause?: string | null, indexType: IndexType, isUnique: boolean, indexFieldMetadatas: { __typename?: 'IndexIndexFieldMetadatasConnection', edges: Array<{ __typename?: 'indexFieldEdge', node: { __typename?: 'indexField', id: any, createdAt: any, updatedAt: any, order: number, fieldMetadataId: any } }> } } }> }, fields: { __typename?: 'ObjectFieldsConnection', edges: Array<{ __typename?: 'fieldEdge', node: { __typename?: 'field', id: any, type: FieldMetadataType, name: string, label: string, description?: string | null, icon?: string | null, isCustom?: boolean | null, isActive?: boolean | null, isSystem?: boolean | null, isNullable?: boolean | null, isUnique?: boolean | null, createdAt: any, updatedAt: any, defaultValue?: any | null, options?: any | null, settings?: any | null, relationDefinition?: { __typename?: 'RelationDefinition', relationId: any, direction: RelationDefinitionType, sourceObjectMetadata: { __typename?: 'object', id: any, nameSingular: string, namePlural: string }, sourceFieldMetadata: { __typename?: 'field', id: any, name: string }, targetObjectMetadata: { __typename?: 'object', id: any, nameSingular: string, namePlural: string }, targetFieldMetadata: { __typename?: 'field', id: any, name: string } } | null } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage?: boolean | null, hasPreviousPage?: boolean | null, startCursor?: any | null, endCursor?: any | null } } } }>, pageInfo: { __typename?: 'PageInfo', hasNextPage?: boolean | null, hasPreviousPage?: boolean | null, startCursor?: any | null, endCursor?: any | null } } }; -export type ServerlessFunctionFieldsFragment = { __typename?: 'ServerlessFunction', id: any, name: string, description?: string | null, runtime: string, syncStatus: ServerlessFunctionSyncStatus, latestVersion?: string | null, createdAt: any, updatedAt: any }; +export type ServerlessFunctionFieldsFragment = { __typename?: 'ServerlessFunction', id: any, name: string, description?: string | null, runtime: string, syncStatus: ServerlessFunctionSyncStatus, latestVersion?: string | null, latestVersionInputSchema?: any | null, publishedVersions: Array, createdAt: any, updatedAt: any }; export type CreateOneServerlessFunctionItemMutationVariables = Exact<{ input: CreateServerlessFunctionInput; }>; -export type CreateOneServerlessFunctionItemMutation = { __typename?: 'Mutation', createOneServerlessFunction: { __typename?: 'ServerlessFunction', id: any, name: string, description?: string | null, runtime: string, syncStatus: ServerlessFunctionSyncStatus, latestVersion?: string | null, createdAt: any, updatedAt: any } }; +export type CreateOneServerlessFunctionItemMutation = { __typename?: 'Mutation', createOneServerlessFunction: { __typename?: 'ServerlessFunction', id: any, name: string, description?: string | null, runtime: string, syncStatus: ServerlessFunctionSyncStatus, latestVersion?: string | null, latestVersionInputSchema?: any | null, publishedVersions: Array, createdAt: any, updatedAt: any } }; export type DeleteOneServerlessFunctionMutationVariables = Exact<{ - input: DeleteServerlessFunctionInput; + input: ServerlessFunctionIdInput; }>; -export type DeleteOneServerlessFunctionMutation = { __typename?: 'Mutation', deleteOneServerlessFunction: { __typename?: 'ServerlessFunction', id: any, name: string, description?: string | null, runtime: string, syncStatus: ServerlessFunctionSyncStatus, latestVersion?: string | null, createdAt: any, updatedAt: any } }; +export type DeleteOneServerlessFunctionMutation = { __typename?: 'Mutation', deleteOneServerlessFunction: { __typename?: 'ServerlessFunction', id: any, name: string, description?: string | null, runtime: string, syncStatus: ServerlessFunctionSyncStatus, latestVersion?: string | null, latestVersionInputSchema?: any | null, publishedVersions: Array, createdAt: any, updatedAt: any } }; export type ExecuteOneServerlessFunctionMutationVariables = Exact<{ input: ExecuteServerlessFunctionInput; @@ -1893,14 +2019,14 @@ export type PublishOneServerlessFunctionMutationVariables = Exact<{ }>; -export type PublishOneServerlessFunctionMutation = { __typename?: 'Mutation', publishServerlessFunction: { __typename?: 'ServerlessFunction', id: any, name: string, description?: string | null, runtime: string, syncStatus: ServerlessFunctionSyncStatus, latestVersion?: string | null, createdAt: any, updatedAt: any } }; +export type PublishOneServerlessFunctionMutation = { __typename?: 'Mutation', publishServerlessFunction: { __typename?: 'ServerlessFunction', id: any, name: string, description?: string | null, runtime: string, syncStatus: ServerlessFunctionSyncStatus, latestVersion?: string | null, latestVersionInputSchema?: any | null, publishedVersions: Array, createdAt: any, updatedAt: any } }; export type UpdateOneServerlessFunctionMutationVariables = Exact<{ input: UpdateServerlessFunctionInput; }>; -export type UpdateOneServerlessFunctionMutation = { __typename?: 'Mutation', updateOneServerlessFunction: { __typename?: 'ServerlessFunction', id: any, name: string, description?: string | null, runtime: string, syncStatus: ServerlessFunctionSyncStatus, latestVersion?: string | null, createdAt: any, updatedAt: any } }; +export type UpdateOneServerlessFunctionMutation = { __typename?: 'Mutation', updateOneServerlessFunction: { __typename?: 'ServerlessFunction', id: any, name: string, description?: string | null, runtime: string, syncStatus: ServerlessFunctionSyncStatus, latestVersion?: string | null, latestVersionInputSchema?: any | null, publishedVersions: Array, createdAt: any, updatedAt: any } }; export type FindManyAvailablePackagesQueryVariables = Exact<{ [key: string]: never; }>; @@ -1910,14 +2036,14 @@ export type FindManyAvailablePackagesQuery = { __typename?: 'Query', getAvailabl export type GetManyServerlessFunctionsQueryVariables = Exact<{ [key: string]: never; }>; -export type GetManyServerlessFunctionsQuery = { __typename?: 'Query', serverlessFunctions: { __typename?: 'ServerlessFunctionConnection', edges: Array<{ __typename?: 'ServerlessFunctionEdge', node: { __typename?: 'ServerlessFunction', id: any, name: string, description?: string | null, runtime: string, syncStatus: ServerlessFunctionSyncStatus, latestVersion?: string | null, createdAt: any, updatedAt: any } }> } }; +export type GetManyServerlessFunctionsQuery = { __typename?: 'Query', findManyServerlessFunctions: Array<{ __typename?: 'ServerlessFunction', id: any, name: string, description?: string | null, runtime: string, syncStatus: ServerlessFunctionSyncStatus, latestVersion?: string | null, latestVersionInputSchema?: any | null, publishedVersions: Array, createdAt: any, updatedAt: any }> }; export type GetOneServerlessFunctionQueryVariables = Exact<{ - id: Scalars['UUID']['input']; + input: ServerlessFunctionIdInput; }>; -export type GetOneServerlessFunctionQuery = { __typename?: 'Query', serverlessFunction: { __typename?: 'ServerlessFunction', id: any, name: string, description?: string | null, runtime: string, syncStatus: ServerlessFunctionSyncStatus, latestVersion?: string | null, createdAt: any, updatedAt: any } }; +export type GetOneServerlessFunctionQuery = { __typename?: 'Query', findOneServerlessFunction: { __typename?: 'ServerlessFunction', id: any, name: string, description?: string | null, runtime: string, syncStatus: ServerlessFunctionSyncStatus, latestVersion?: string | null, latestVersionInputSchema?: any | null, publishedVersions: Array, createdAt: any, updatedAt: any } }; export type FindOneServerlessFunctionSourceCodeQueryVariables = Exact<{ input: GetServerlessFunctionSourceCodeInput; @@ -1928,7 +2054,7 @@ export type FindOneServerlessFunctionSourceCodeQuery = { __typename?: 'Query', g export const RemoteServerFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RemoteServerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RemoteServer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"foreignDataWrapperId"}},{"kind":"Field","name":{"kind":"Name","value":"foreignDataWrapperOptions"}},{"kind":"Field","name":{"kind":"Name","value":"foreignDataWrapperType"}},{"kind":"Field","name":{"kind":"Name","value":"userMappingOptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"}}]}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"schema"}},{"kind":"Field","name":{"kind":"Name","value":"label"}}]}}]} as unknown as DocumentNode; export const RemoteTableFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RemoteTableFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RemoteTable"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"schema"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"schemaPendingUpdates"}}]}}]} as unknown as DocumentNode; -export const ServerlessFunctionFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; +export const ServerlessFunctionFieldsFragmentDoc = {"kind":"Document","definitions":[{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersionInputSchema"}},{"kind":"Field","name":{"kind":"Name","value":"publishedVersions"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const CreateServerDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"createServer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateRemoteServerInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOneRemoteServer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RemoteServerFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RemoteServerFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RemoteServer"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"foreignDataWrapperId"}},{"kind":"Field","name":{"kind":"Name","value":"foreignDataWrapperOptions"}},{"kind":"Field","name":{"kind":"Name","value":"foreignDataWrapperType"}},{"kind":"Field","name":{"kind":"Name","value":"userMappingOptions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"user"}}]}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"schema"}},{"kind":"Field","name":{"kind":"Name","value":"label"}}]}}]} as unknown as DocumentNode; export const DeleteServerDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"deleteServer"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoteServerIdInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOneRemoteServer"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; export const SyncRemoteTableDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"syncRemoteTable"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"RemoteTableInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"syncRemoteTable"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"RemoteTableFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"RemoteTableFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"RemoteTable"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"schema"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"schemaPendingUpdates"}}]}}]} as unknown as DocumentNode; @@ -1946,13 +2072,13 @@ export const UpdateOneObjectMetadataItemDocument = {"kind":"Document","definitio export const DeleteOneObjectMetadataItemDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteOneObjectMetadataItem"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"idToDelete"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOneObject"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"idToDelete"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}},{"kind":"Field","name":{"kind":"Name","value":"labelSingular"}},{"kind":"Field","name":{"kind":"Name","value":"labelPlural"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"labelIdentifierFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"imageIdentifierFieldMetadataId"}}]}}]}}]} as unknown as DocumentNode; export const DeleteOneFieldMetadataItemDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteOneFieldMetadataItem"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"idToDelete"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOneField"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"idToDelete"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"isNullable"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"settings"}}]}}]}}]} as unknown as DocumentNode; export const DeleteOneRelationMetadataItemDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteOneRelationMetadataItem"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"idToDelete"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOneRelation"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"idToDelete"}}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}}]}}]}}]} as unknown as DocumentNode; -export const ObjectMetadataItemsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ObjectMetadataItems"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectFilter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"objectFilter"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fieldFilter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"fieldFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"objects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1000"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}},{"kind":"Field","name":{"kind":"Name","value":"labelSingular"}},{"kind":"Field","name":{"kind":"Name","value":"labelPlural"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isRemote"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"isSystem"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"labelIdentifierFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"imageIdentifierFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"indexMetadatas"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"indexWhereClause"}},{"kind":"Field","name":{"kind":"Name","value":"indexType"}},{"kind":"Field","name":{"kind":"Name","value":"isUnique"}},{"kind":"Field","name":{"kind":"Name","value":"indexFieldMetadatas"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}}]}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"fields"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1000"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fieldFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"isSystem"}},{"kind":"Field","name":{"kind":"Name","value":"isNullable"}},{"kind":"Field","name":{"kind":"Name","value":"isUnique"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"defaultValue"}},{"kind":"Field","name":{"kind":"Name","value":"options"}},{"kind":"Field","name":{"kind":"Name","value":"settings"}},{"kind":"Field","name":{"kind":"Name","value":"relationDefinition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"relationId"}},{"kind":"Field","name":{"kind":"Name","value":"direction"}},{"kind":"Field","name":{"kind":"Name","value":"sourceObjectMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}}]}},{"kind":"Field","name":{"kind":"Name","value":"sourceFieldMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"targetObjectMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}}]}},{"kind":"Field","name":{"kind":"Name","value":"targetFieldMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; -export const CreateOneServerlessFunctionItemDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOneServerlessFunctionItem"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateServerlessFunctionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOneServerlessFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServerlessFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; -export const DeleteOneServerlessFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteOneServerlessFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"DeleteServerlessFunctionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOneServerlessFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServerlessFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; +export const ObjectMetadataItemsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"ObjectMetadataItems"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"objectFilter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"objectFilter"}}},{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"fieldFilter"}},"type":{"kind":"NamedType","name":{"kind":"Name","value":"fieldFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"objects"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1000"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"objectFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"dataSourceId"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}},{"kind":"Field","name":{"kind":"Name","value":"labelSingular"}},{"kind":"Field","name":{"kind":"Name","value":"labelPlural"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isRemote"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"isSystem"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"labelIdentifierFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"imageIdentifierFieldMetadataId"}},{"kind":"Field","name":{"kind":"Name","value":"shortcut"}},{"kind":"Field","name":{"kind":"Name","value":"isLabelSyncedWithName"}},{"kind":"Field","name":{"kind":"Name","value":"indexMetadatas"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"indexWhereClause"}},{"kind":"Field","name":{"kind":"Name","value":"indexType"}},{"kind":"Field","name":{"kind":"Name","value":"isUnique"}},{"kind":"Field","name":{"kind":"Name","value":"indexFieldMetadatas"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"order"}},{"kind":"Field","name":{"kind":"Name","value":"fieldMetadataId"}}]}}]}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"fields"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"1000"}}]}},{"kind":"Argument","name":{"kind":"Name","value":"filter"},"value":{"kind":"Variable","name":{"kind":"Name","value":"fieldFilter"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"type"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"label"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"icon"}},{"kind":"Field","name":{"kind":"Name","value":"isCustom"}},{"kind":"Field","name":{"kind":"Name","value":"isActive"}},{"kind":"Field","name":{"kind":"Name","value":"isSystem"}},{"kind":"Field","name":{"kind":"Name","value":"isNullable"}},{"kind":"Field","name":{"kind":"Name","value":"isUnique"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}},{"kind":"Field","name":{"kind":"Name","value":"defaultValue"}},{"kind":"Field","name":{"kind":"Name","value":"options"}},{"kind":"Field","name":{"kind":"Name","value":"settings"}},{"kind":"Field","name":{"kind":"Name","value":"relationDefinition"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"relationId"}},{"kind":"Field","name":{"kind":"Name","value":"direction"}},{"kind":"Field","name":{"kind":"Name","value":"sourceObjectMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}}]}},{"kind":"Field","name":{"kind":"Name","value":"sourceFieldMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"targetObjectMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"nameSingular"}},{"kind":"Field","name":{"kind":"Name","value":"namePlural"}}]}},{"kind":"Field","name":{"kind":"Name","value":"targetFieldMetadata"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]}},{"kind":"Field","name":{"kind":"Name","value":"pageInfo"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"hasNextPage"}},{"kind":"Field","name":{"kind":"Name","value":"hasPreviousPage"}},{"kind":"Field","name":{"kind":"Name","value":"startCursor"}},{"kind":"Field","name":{"kind":"Name","value":"endCursor"}}]}}]}}]}}]} as unknown as DocumentNode; +export const CreateOneServerlessFunctionItemDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"CreateOneServerlessFunctionItem"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"CreateServerlessFunctionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"createOneServerlessFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServerlessFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersionInputSchema"}},{"kind":"Field","name":{"kind":"Name","value":"publishedVersions"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; +export const DeleteOneServerlessFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"DeleteOneServerlessFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunctionIdInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"deleteOneServerlessFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServerlessFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersionInputSchema"}},{"kind":"Field","name":{"kind":"Name","value":"publishedVersions"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const ExecuteOneServerlessFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"ExecuteOneServerlessFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ExecuteServerlessFunctionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"executeOneServerlessFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"data"}},{"kind":"Field","name":{"kind":"Name","value":"duration"}},{"kind":"Field","name":{"kind":"Name","value":"status"}},{"kind":"Field","name":{"kind":"Name","value":"error"}}]}}]}}]} as unknown as DocumentNode; -export const PublishOneServerlessFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"PublishOneServerlessFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PublishServerlessFunctionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publishServerlessFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServerlessFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; -export const UpdateOneServerlessFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateOneServerlessFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateServerlessFunctionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateOneServerlessFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServerlessFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; +export const PublishOneServerlessFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"PublishOneServerlessFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"PublishServerlessFunctionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"publishServerlessFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServerlessFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersionInputSchema"}},{"kind":"Field","name":{"kind":"Name","value":"publishedVersions"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; +export const UpdateOneServerlessFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"mutation","name":{"kind":"Name","value":"UpdateOneServerlessFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UpdateServerlessFunctionInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"updateOneServerlessFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServerlessFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersionInputSchema"}},{"kind":"Field","name":{"kind":"Name","value":"publishedVersions"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const FindManyAvailablePackagesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindManyAvailablePackages"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getAvailablePackages"}}]}}]} as unknown as DocumentNode; -export const GetManyServerlessFunctionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetManyServerlessFunctions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverlessFunctions"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"paging"},"value":{"kind":"ObjectValue","fields":[{"kind":"ObjectField","name":{"kind":"Name","value":"first"},"value":{"kind":"IntValue","value":"100"}}]}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"edges"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"node"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServerlessFunctionFields"}}]}}]}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; -export const GetOneServerlessFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOneServerlessFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"id"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"UUID"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"serverlessFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"id"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServerlessFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; +export const GetManyServerlessFunctionsDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetManyServerlessFunctions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findManyServerlessFunctions"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServerlessFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersionInputSchema"}},{"kind":"Field","name":{"kind":"Name","value":"publishedVersions"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; +export const GetOneServerlessFunctionDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"GetOneServerlessFunction"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunctionIdInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"findOneServerlessFunction"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"FragmentSpread","name":{"kind":"Name","value":"ServerlessFunctionFields"}}]}}]}},{"kind":"FragmentDefinition","name":{"kind":"Name","value":"ServerlessFunctionFields"},"typeCondition":{"kind":"NamedType","name":{"kind":"Name","value":"ServerlessFunction"}},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"description"}},{"kind":"Field","name":{"kind":"Name","value":"runtime"}},{"kind":"Field","name":{"kind":"Name","value":"syncStatus"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersion"}},{"kind":"Field","name":{"kind":"Name","value":"latestVersionInputSchema"}},{"kind":"Field","name":{"kind":"Name","value":"publishedVersions"}},{"kind":"Field","name":{"kind":"Name","value":"createdAt"}},{"kind":"Field","name":{"kind":"Name","value":"updatedAt"}}]}}]} as unknown as DocumentNode; export const FindOneServerlessFunctionSourceCodeDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"FindOneServerlessFunctionSourceCode"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"input"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"GetServerlessFunctionSourceCodeInput"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"getServerlessFunctionSourceCode"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"input"},"value":{"kind":"Variable","name":{"kind":"Name","value":"input"}}}]}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/packages/twenty-front/src/generated/graphql.tsx b/packages/twenty-front/src/generated/graphql.tsx index 9e930133b3fb..de35163609bc 100644 --- a/packages/twenty-front/src/generated/graphql.tsx +++ b/packages/twenty-front/src/generated/graphql.tsx @@ -1,5 +1,5 @@ -import * as Apollo from '@apollo/client'; import { gql } from '@apollo/client'; +import * as Apollo from '@apollo/client'; export type Maybe = T | null; export type InputMaybe = Maybe; export type Exact = { [K in keyof T]: T[K] }; @@ -21,13 +21,6 @@ export type Scalars = { Upload: any; }; -export type AisqlQueryResult = { - __typename?: 'AISQLQueryResult'; - queryFailedErrorMessage?: Maybe; - sqlQuery: Scalars['String']; - sqlQueryResult?: Maybe; -}; - export type ActivateWorkspaceInput = { displayName?: InputMaybe; }; @@ -38,6 +31,16 @@ export type Analytics = { success: Scalars['Boolean']; }; +export type AnalyticsTinybirdJwtMap = { + __typename?: 'AnalyticsTinybirdJwtMap'; + getPageviewsAnalytics: Scalars['String']; + getServerlessFunctionDuration: Scalars['String']; + getServerlessFunctionErrorCount: Scalars['String']; + getServerlessFunctionSuccessRate: Scalars['String']; + getUsersAnalytics: Scalars['String']; + getWebhookAnalytics: Scalars['String']; +}; + export type ApiConfig = { __typename?: 'ApiConfig'; mutationMaximumAffectedRecords: Scalars['Float']; @@ -71,6 +74,7 @@ export type AuthProviders = { magicLink: Scalars['Boolean']; microsoft: Scalars['Boolean']; password: Scalars['Boolean']; + sso: Scalars['Boolean']; }; export type AuthToken = { @@ -148,6 +152,7 @@ export enum CaptchaDriverType { export type ClientConfig = { __typename?: 'ClientConfig'; + analyticsEnabled: Scalars['Boolean']; api: ApiConfig; authProviders: AuthProviders; billing: Billing; @@ -160,13 +165,12 @@ export type ClientConfig = { support: Support; }; -export type CreateServerlessFunctionFromFileInput = { - description?: InputMaybe; - name: Scalars['String']; +export type ComputeStepOutputSchemaInput = { + /** Step JSON format */ + step: Scalars['JSON']; }; export type CreateServerlessFunctionInput = { - code: Scalars['String']; description?: InputMaybe; name: Scalars['String']; }; @@ -187,9 +191,13 @@ export type DeleteOneObjectInput = { id: Scalars['UUID']; }; -export type DeleteServerlessFunctionInput = { - /** The id of the function. */ - id: Scalars['ID']; +export type DeleteSsoInput = { + identityProviderId: Scalars['String']; +}; + +export type DeleteSsoOutput = { + __typename?: 'DeleteSsoOutput'; + identityProviderId: Scalars['String']; }; /** Schema update on a table */ @@ -200,6 +208,20 @@ export enum DistantTableUpdate { TableDeleted = 'TABLE_DELETED' } +export type EditSsoInput = { + id: Scalars['String']; + status: SsoIdentityProviderStatus; +}; + +export type EditSsoOutput = { + __typename?: 'EditSsoOutput'; + id: Scalars['String']; + issuer: Scalars['String']; + name: Scalars['String']; + status: SsoIdentityProviderStatus; + type: IdpType; +}; + export type EmailPasswordResetLink = { __typename?: 'EmailPasswordResetLink'; /** Boolean that confirms query was dispatched */ @@ -289,12 +311,53 @@ export enum FileFolder { WorkspaceLogo = 'WorkspaceLogo' } +export type FindAvailableSsoidpInput = { + email: Scalars['String']; +}; + +export type FindAvailableSsoidpOutput = { + __typename?: 'FindAvailableSSOIDPOutput'; + id: Scalars['String']; + issuer: Scalars['String']; + name: Scalars['String']; + status: SsoIdentityProviderStatus; + type: IdpType; + workspace: WorkspaceNameAndId; +}; + export type FullName = { __typename?: 'FullName'; firstName: Scalars['String']; lastName: Scalars['String']; }; +export type GenerateJwt = GenerateJwtOutputWithAuthTokens | GenerateJwtOutputWithSsoauth; + +export type GenerateJwtOutputWithAuthTokens = { + __typename?: 'GenerateJWTOutputWithAuthTokens'; + authTokens: AuthTokens; + reason: Scalars['String']; + success: Scalars['Boolean']; +}; + +export type GenerateJwtOutputWithSsoauth = { + __typename?: 'GenerateJWTOutputWithSSOAUTH'; + availableSSOIDPs: Array; + reason: Scalars['String']; + success: Scalars['Boolean']; +}; + +export type GetAuthorizationUrlInput = { + identityProviderId: Scalars['String']; +}; + +export type GetAuthorizationUrlOutput = { + __typename?: 'GetAuthorizationUrlOutput'; + authorizationURL: Scalars['String']; + id: Scalars['String']; + type: Scalars['String']; +}; + export type GetServerlessFunctionSourceCodeInput = { /** The id of the function. */ id: Scalars['ID']; @@ -302,6 +365,41 @@ export type GetServerlessFunctionSourceCodeInput = { version?: Scalars['String']; }; +export enum IdpType { + Oidc = 'OIDC', + Saml = 'SAML' +} + +export type IndexConnection = { + __typename?: 'IndexConnection'; + /** Array of edges. */ + edges: Array; + /** Paging information */ + pageInfo: PageInfo; +}; + +export type IndexIndexFieldMetadatasConnection = { + __typename?: 'IndexIndexFieldMetadatasConnection'; + /** Array of edges. */ + edges: Array; + /** Paging information */ + pageInfo: PageInfo; +}; + +export type IndexObjectMetadataConnection = { + __typename?: 'IndexObjectMetadataConnection'; + /** Array of edges. */ + edges: Array; + /** Paging information */ + pageInfo: PageInfo; +}; + +/** Type of the index */ +export enum IndexType { + Btree = 'BTREE', + Gin = 'GIN' +} + export type InvalidatePassword = { __typename?: 'InvalidatePassword'; /** Boolean that confirms query was dispatched */ @@ -341,24 +439,30 @@ export type Mutation = { authorizeApp: AuthorizeApp; challenge: LoginToken; checkoutSession: SessionEntity; + computeStepOutputSchema: Scalars['JSON']; + createOIDCIdentityProvider: SetupSsoOutput; createOneAppToken: AppToken; createOneObject: Object; createOneServerlessFunction: ServerlessFunction; - createOneServerlessFunctionFromFile: ServerlessFunction; + createSAMLIdentityProvider: SetupSsoOutput; deactivateWorkflowVersion: Scalars['Boolean']; deleteCurrentWorkspace: Workspace; deleteOneObject: Object; deleteOneServerlessFunction: ServerlessFunction; + deleteSSOIdentityProvider: DeleteSsoOutput; deleteUser: User; deleteWorkspaceInvitation: Scalars['String']; disablePostgresProxy: PostgresCredentials; + editSSOIdentityProvider: EditSsoOutput; emailPasswordResetLink: EmailPasswordResetLink; enablePostgresProxy: PostgresCredentials; exchangeAuthorizationCode: ExchangeAuthCode; executeOneServerlessFunction: ServerlessFunctionExecutionResult; + findAvailableSSOIdentityProviders: Array; generateApiKeyToken: ApiKeyToken; - generateJWT: AuthTokens; + generateJWT: GenerateJwt; generateTransientToken: TransientToken; + getAuthorizationUrl: GetAuthorizationUrlOutput; impersonate: Verify; publishServerlessFunction: ServerlessFunction; renewToken: AuthTokens; @@ -421,14 +525,23 @@ export type MutationCheckoutSessionArgs = { }; +export type MutationComputeStepOutputSchemaArgs = { + input: ComputeStepOutputSchemaInput; +}; + + +export type MutationCreateOidcIdentityProviderArgs = { + input: SetupOidcSsoInput; +}; + + export type MutationCreateOneServerlessFunctionArgs = { input: CreateServerlessFunctionInput; }; -export type MutationCreateOneServerlessFunctionFromFileArgs = { - file: Scalars['Upload']; - input: CreateServerlessFunctionFromFileInput; +export type MutationCreateSamlIdentityProviderArgs = { + input: SetupSamlSsoInput; }; @@ -443,7 +556,12 @@ export type MutationDeleteOneObjectArgs = { export type MutationDeleteOneServerlessFunctionArgs = { - input: DeleteServerlessFunctionInput; + input: ServerlessFunctionIdInput; +}; + + +export type MutationDeleteSsoIdentityProviderArgs = { + input: DeleteSsoInput; }; @@ -452,6 +570,11 @@ export type MutationDeleteWorkspaceInvitationArgs = { }; +export type MutationEditSsoIdentityProviderArgs = { + input: EditSsoInput; +}; + + export type MutationEmailPasswordResetLinkArgs = { email: Scalars['String']; }; @@ -469,6 +592,11 @@ export type MutationExecuteOneServerlessFunctionArgs = { }; +export type MutationFindAvailableSsoIdentityProvidersArgs = { + input: FindAvailableSsoidpInput; +}; + + export type MutationGenerateApiKeyTokenArgs = { apiKeyId: Scalars['String']; expiresAt: Scalars['String']; @@ -480,6 +608,11 @@ export type MutationGenerateJwtArgs = { }; +export type MutationGetAuthorizationUrlArgs = { + input: GetAuthorizationUrlInput; +}; + + export type MutationImpersonateArgs = { userId: Scalars['String']; }; @@ -520,9 +653,8 @@ export type MutationSignUpArgs = { export type MutationTrackArgs = { - data: Scalars['JSON']; - sessionId: Scalars['String']; - type: Scalars['String']; + action: Scalars['String']; + payload: Scalars['JSON']; }; @@ -589,6 +721,14 @@ export type ObjectFieldsConnection = { pageInfo: PageInfo; }; +export type ObjectIndexMetadatasConnection = { + __typename?: 'ObjectIndexMetadatasConnection'; + /** Array of edges. */ + edges: Array; + /** Paging information */ + pageInfo: PageInfo; +}; + /** Onboarding status */ export enum OnboardingStatus { Completed = 'COMPLETED', @@ -652,21 +792,23 @@ export type Query = { clientConfig: ClientConfig; currentUser: User; currentWorkspace: Workspace; + findManyServerlessFunctions: Array; + findOneServerlessFunction: ServerlessFunction; findWorkspaceFromInviteHash: Workspace; findWorkspaceInvitations: Array; - getAISQLQuery: AisqlQueryResult; getAvailablePackages: Scalars['JSON']; getPostgresCredentials?: Maybe; getProductPrices: ProductPricesEntity; - getServerlessFunctionSourceCode?: Maybe; + getServerlessFunctionSourceCode?: Maybe; getTimelineCalendarEventsFromCompanyId: TimelineCalendarEventsWithTotal; getTimelineCalendarEventsFromPersonId: TimelineCalendarEventsWithTotal; getTimelineThreadsFromCompanyId: TimelineThreadsWithTotal; getTimelineThreadsFromPersonId: TimelineThreadsWithTotal; + index: Index; + indexMetadatas: IndexConnection; + listSSOIdentityProvidersByWorkspaceId: Array; object: Object; objects: ObjectConnection; - serverlessFunction: ServerlessFunction; - serverlessFunctions: ServerlessFunctionConnection; validatePasswordResetToken: ValidatePasswordResetToken; }; @@ -687,13 +829,13 @@ export type QueryCheckWorkspaceInviteHashIsValidArgs = { }; -export type QueryFindWorkspaceFromInviteHashArgs = { - inviteHash: Scalars['String']; +export type QueryFindOneServerlessFunctionArgs = { + input: ServerlessFunctionIdInput; }; -export type QueryGetAisqlQueryArgs = { - text: Scalars['String']; +export type QueryFindWorkspaceFromInviteHashArgs = { + inviteHash: Scalars['String']; }; @@ -808,6 +950,12 @@ export type RunWorkflowVersionInput = { workflowVersionId: Scalars['String']; }; +export enum SsoIdentityProviderStatus { + Active = 'Active', + Error = 'Error', + Inactive = 'Inactive' +} + export type SendInvitationsOutput = { __typename?: 'SendInvitationsOutput'; errors: Array; @@ -829,29 +977,14 @@ export type ServerlessFunction = { description?: Maybe; id: Scalars['UUID']; latestVersion?: Maybe; + latestVersionInputSchema?: Maybe; name: Scalars['String']; + publishedVersions: Array; runtime: Scalars['String']; - sourceCodeHash: Scalars['String']; syncStatus: ServerlessFunctionSyncStatus; updatedAt: Scalars['DateTime']; }; -export type ServerlessFunctionConnection = { - __typename?: 'ServerlessFunctionConnection'; - /** Array of edges. */ - edges: Array; - /** Paging information */ - pageInfo: PageInfo; -}; - -export type ServerlessFunctionEdge = { - __typename?: 'ServerlessFunctionEdge'; - /** Cursor for this node. */ - cursor: Scalars['ConnectionCursor']; - /** The node containing the ServerlessFunction */ - node: ServerlessFunction; -}; - export type ServerlessFunctionExecutionResult = { __typename?: 'ServerlessFunctionExecutionResult'; /** Execution result in JSON format */ @@ -870,6 +1003,11 @@ export enum ServerlessFunctionExecutionStatus { Success = 'SUCCESS' } +export type ServerlessFunctionIdInput = { + /** The id of the function. */ + id: Scalars['ID']; +}; + /** SyncStatus of the serverlessFunction */ export enum ServerlessFunctionSyncStatus { NotReady = 'NOT_READY', @@ -881,6 +1019,31 @@ export type SessionEntity = { url?: Maybe; }; +export type SetupOidcSsoInput = { + clientID: Scalars['String']; + clientSecret: Scalars['String']; + issuer: Scalars['String']; + name: Scalars['String']; +}; + +export type SetupSamlSsoInput = { + certificate: Scalars['String']; + fingerprint?: InputMaybe; + id: Scalars['String']; + issuer: Scalars['String']; + name: Scalars['String']; + ssoURL: Scalars['String']; +}; + +export type SetupSsoOutput = { + __typename?: 'SetupSsoOutput'; + id: Scalars['String']; + issuer: Scalars['String']; + name: Scalars['String']; + status: SsoIdentityProviderStatus; + type: IdpType; +}; + /** Sort Directions */ export enum SortDirection { Asc = 'ASC', @@ -1014,11 +1177,13 @@ export type UpdateObjectPayload = { icon?: InputMaybe; imageIdentifierFieldMetadataId?: InputMaybe; isActive?: InputMaybe; + isLabelSyncedWithName?: InputMaybe; labelIdentifierFieldMetadataId?: InputMaybe; labelPlural?: InputMaybe; labelSingular?: InputMaybe; namePlural?: InputMaybe; nameSingular?: InputMaybe; + shortcut?: InputMaybe; }; export type UpdateOneObjectInput = { @@ -1028,7 +1193,7 @@ export type UpdateOneObjectInput = { }; export type UpdateServerlessFunctionInput = { - code: Scalars['String']; + code: Scalars['JSON']; description?: InputMaybe; /** Id of the serverless function to execute */ id: Scalars['UUID']; @@ -1040,11 +1205,13 @@ export type UpdateWorkspaceInput = { displayName?: InputMaybe; domainName?: InputMaybe; inviteHash?: InputMaybe; + isPublicInviteLinkEnabled?: InputMaybe; logo?: InputMaybe; }; export type User = { __typename?: 'User'; + analyticsTinybirdJwts?: Maybe; canImpersonate: Scalars['Boolean']; createdAt: Scalars['DateTime']; defaultAvatarUrl?: Maybe; @@ -1127,8 +1294,10 @@ export type Workspace = { displayName?: Maybe; domainName?: Maybe; featureFlags?: Maybe>; + hasValidEntrepriseKey: Scalars['Boolean']; id: Scalars['UUID']; inviteHash?: Maybe; + isPublicInviteLinkEnabled: Scalars['Boolean']; logo?: Maybe; metadataVersion: Scalars['Float']; updatedAt: Scalars['DateTime']; @@ -1201,6 +1370,12 @@ export enum WorkspaceMemberTimeFormatEnum { System = 'SYSTEM' } +export type WorkspaceNameAndId = { + __typename?: 'WorkspaceNameAndId'; + displayName?: Maybe; + id: Scalars['String']; +}; + export type Field = { __typename?: 'field'; createdAt: Scalars['DateTime']; @@ -1213,6 +1388,7 @@ export type Field = { isCustom?: Maybe; isNullable?: Maybe; isSystem?: Maybe; + isUnique?: Maybe; label: Scalars['String']; name: Scalars['String']; object?: Maybe; @@ -1241,6 +1417,71 @@ export type FieldFilter = { or?: InputMaybe>; }; +export type Index = { + __typename?: 'index'; + createdAt: Scalars['DateTime']; + id: Scalars['UUID']; + indexFieldMetadatas: IndexIndexFieldMetadatasConnection; + indexType: IndexType; + indexWhereClause?: Maybe; + isCustom?: Maybe; + isUnique: Scalars['Boolean']; + name: Scalars['String']; + objectMetadata: IndexObjectMetadataConnection; + updatedAt: Scalars['DateTime']; +}; + + +export type IndexIndexFieldMetadatasArgs = { + filter?: IndexFieldFilter; + paging?: CursorPaging; +}; + + +export type IndexObjectMetadataArgs = { + filter?: ObjectFilter; + paging?: CursorPaging; +}; + +export type IndexEdge = { + __typename?: 'indexEdge'; + /** Cursor for this node. */ + cursor: Scalars['ConnectionCursor']; + /** The node containing the index */ + node: Index; +}; + +export type IndexField = { + __typename?: 'indexField'; + createdAt: Scalars['DateTime']; + fieldMetadataId: Scalars['UUID']; + id: Scalars['UUID']; + order: Scalars['Float']; + updatedAt: Scalars['DateTime']; +}; + +export type IndexFieldEdge = { + __typename?: 'indexFieldEdge'; + /** Cursor for this node. */ + cursor: Scalars['ConnectionCursor']; + /** The node containing the indexField */ + node: IndexField; +}; + +export type IndexFieldFilter = { + and?: InputMaybe>; + fieldMetadataId?: InputMaybe; + id?: InputMaybe; + or?: InputMaybe>; +}; + +export type IndexFilter = { + and?: InputMaybe>; + id?: InputMaybe; + isCustom?: InputMaybe; + or?: InputMaybe>; +}; + export type Object = { __typename?: 'object'; createdAt: Scalars['DateTime']; @@ -1250,8 +1491,10 @@ export type Object = { icon?: Maybe; id: Scalars['UUID']; imageIdentifierFieldMetadataId?: Maybe; + indexMetadatas: ObjectIndexMetadatasConnection; isActive: Scalars['Boolean']; isCustom: Scalars['Boolean']; + isLabelSyncedWithName: Scalars['Boolean']; isRemote: Scalars['Boolean']; isSystem: Scalars['Boolean']; labelIdentifierFieldMetadataId?: Maybe; @@ -1259,6 +1502,7 @@ export type Object = { labelSingular: Scalars['String']; namePlural: Scalars['String']; nameSingular: Scalars['String']; + shortcut?: Maybe; updatedAt: Scalars['DateTime']; }; @@ -1268,6 +1512,12 @@ export type ObjectFieldsArgs = { paging?: CursorPaging; }; + +export type ObjectIndexMetadatasArgs = { + filter?: IndexFilter; + paging?: CursorPaging; +}; + export type ObjectEdge = { __typename?: 'objectEdge'; /** Cursor for this node. */ @@ -1276,6 +1526,16 @@ export type ObjectEdge = { node: Object; }; +export type ObjectFilter = { + and?: InputMaybe>; + id?: InputMaybe; + isActive?: InputMaybe; + isCustom?: InputMaybe; + isRemote?: InputMaybe; + isSystem?: InputMaybe; + or?: InputMaybe>; +}; + export type Relation = { __typename?: 'relation'; createdAt: Scalars['DateTime']; @@ -1374,6 +1634,8 @@ export type AuthTokenFragmentFragment = { __typename?: 'AuthToken', token: strin export type AuthTokensFragmentFragment = { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } }; +export type AvailableSsoIdentityProvidersFragmentFragment = { __typename?: 'FindAvailableSSOIDPOutput', id: string, issuer: string, name: string, status: SsoIdentityProviderStatus, workspace: { __typename?: 'WorkspaceNameAndId', id: string, displayName?: string | null } }; + export type AuthorizeAppMutationVariables = Exact<{ clientId: Scalars['String']; codeChallenge: Scalars['String']; @@ -1399,6 +1661,13 @@ export type EmailPasswordResetLinkMutationVariables = Exact<{ export type EmailPasswordResetLinkMutation = { __typename?: 'Mutation', emailPasswordResetLink: { __typename?: 'EmailPasswordResetLink', success: boolean } }; +export type FindAvailableSsoIdentityProvidersMutationVariables = Exact<{ + input: FindAvailableSsoidpInput; +}>; + + +export type FindAvailableSsoIdentityProvidersMutation = { __typename?: 'Mutation', findAvailableSSOIdentityProviders: Array<{ __typename?: 'FindAvailableSSOIDPOutput', id: string, issuer: string, name: string, status: SsoIdentityProviderStatus, workspace: { __typename?: 'WorkspaceNameAndId', id: string, displayName?: string | null } }> }; + export type GenerateApiKeyTokenMutationVariables = Exact<{ apiKeyId: Scalars['String']; expiresAt: Scalars['String']; @@ -1412,19 +1681,26 @@ export type GenerateJwtMutationVariables = Exact<{ }>; -export type GenerateJwtMutation = { __typename?: 'Mutation', generateJWT: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type GenerateJwtMutation = { __typename?: 'Mutation', generateJWT: { __typename?: 'GenerateJWTOutputWithAuthTokens', success: boolean, reason: string, authTokens: { __typename?: 'AuthTokens', tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } } | { __typename?: 'GenerateJWTOutputWithSSOAUTH', success: boolean, reason: string, availableSSOIDPs: Array<{ __typename?: 'FindAvailableSSOIDPOutput', id: string, issuer: string, name: string, status: SsoIdentityProviderStatus, workspace: { __typename?: 'WorkspaceNameAndId', id: string, displayName?: string | null } }> } }; export type GenerateTransientTokenMutationVariables = Exact<{ [key: string]: never; }>; export type GenerateTransientTokenMutation = { __typename?: 'Mutation', generateTransientToken: { __typename?: 'TransientToken', transientToken: { __typename?: 'AuthToken', token: string } } }; +export type GetAuthorizationUrlMutationVariables = Exact<{ + input: GetAuthorizationUrlInput; +}>; + + +export type GetAuthorizationUrlMutation = { __typename?: 'Mutation', getAuthorizationUrl: { __typename?: 'GetAuthorizationUrlOutput', id: string, type: string, authorizationURL: string } }; + export type ImpersonateMutationVariables = Exact<{ userId: Scalars['String']; }>; -export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type ImpersonateMutation = { __typename?: 'Mutation', impersonate: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type RenewTokenMutationVariables = Exact<{ appToken: Scalars['String']; @@ -1457,7 +1733,7 @@ export type VerifyMutationVariables = Exact<{ }>; -export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; +export type VerifyMutation = { __typename?: 'Mutation', verify: { __typename?: 'Verify', user: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }, tokens: { __typename?: 'AuthTokenPair', accessToken: { __typename?: 'AuthToken', token: string, expiresAt: string }, refreshToken: { __typename?: 'AuthToken', token: string, expiresAt: string } } } }; export type CheckUserExistsQueryVariables = Exact<{ email: Scalars['String']; @@ -1504,21 +1780,47 @@ export type UpdateBillingSubscriptionMutation = { __typename?: 'Mutation', updat export type GetClientConfigQueryVariables = Exact<{ [key: string]: never; }>; -export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, chromeExtensionId?: string | null, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } }; +export type GetClientConfigQuery = { __typename?: 'Query', clientConfig: { __typename?: 'ClientConfig', signInPrefilled: boolean, signUpDisabled: boolean, debugMode: boolean, analyticsEnabled: boolean, chromeExtensionId?: string | null, authProviders: { __typename?: 'AuthProviders', google: boolean, password: boolean, microsoft: boolean, sso: boolean }, billing: { __typename?: 'Billing', isBillingEnabled: boolean, billingUrl?: string | null, billingFreeTrialDurationInDays?: number | null }, support: { __typename?: 'Support', supportDriver: string, supportFrontChatId?: string | null }, sentry: { __typename?: 'Sentry', dsn?: string | null, environment?: string | null, release?: string | null }, captcha: { __typename?: 'Captcha', provider?: CaptchaDriverType | null, siteKey?: string | null }, api: { __typename?: 'ApiConfig', mutationMaximumAffectedRecords: number } } }; export type SkipSyncEmailOnboardingStepMutationVariables = Exact<{ [key: string]: never; }>; export type SkipSyncEmailOnboardingStepMutation = { __typename?: 'Mutation', skipSyncEmailOnboardingStep: { __typename?: 'OnboardingStepSuccess', success: boolean } }; -export type GetAisqlQueryQueryVariables = Exact<{ - text: Scalars['String']; +export type CreateOidcIdentityProviderMutationVariables = Exact<{ + input: SetupOidcSsoInput; +}>; + + +export type CreateOidcIdentityProviderMutation = { __typename?: 'Mutation', createOIDCIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdpType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; + +export type CreateSamlIdentityProviderMutationVariables = Exact<{ + input: SetupSamlSsoInput; +}>; + + +export type CreateSamlIdentityProviderMutation = { __typename?: 'Mutation', createSAMLIdentityProvider: { __typename?: 'SetupSsoOutput', id: string, type: IdpType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; + +export type DeleteSsoIdentityProviderMutationVariables = Exact<{ + input: DeleteSsoInput; +}>; + + +export type DeleteSsoIdentityProviderMutation = { __typename?: 'Mutation', deleteSSOIdentityProvider: { __typename?: 'DeleteSsoOutput', identityProviderId: string } }; + +export type EditSsoIdentityProviderMutationVariables = Exact<{ + input: EditSsoInput; }>; -export type GetAisqlQueryQuery = { __typename?: 'Query', getAISQLQuery: { __typename?: 'AISQLQueryResult', sqlQuery: string, sqlQueryResult?: string | null, queryFailedErrorMessage?: string | null } }; +export type EditSsoIdentityProviderMutation = { __typename?: 'Mutation', editSSOIdentityProvider: { __typename?: 'EditSsoOutput', id: string, type: IdpType, issuer: string, name: string, status: SsoIdentityProviderStatus } }; + +export type ListSsoIdentityProvidersByWorkspaceIdQueryVariables = Exact<{ [key: string]: never; }>; + + +export type ListSsoIdentityProvidersByWorkspaceIdQuery = { __typename?: 'Query', listSSOIdentityProvidersByWorkspaceId: Array<{ __typename?: 'FindAvailableSSOIDPOutput', type: IdpType, id: string, name: string, issuer: string, status: SsoIdentityProviderStatus }> }; -export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }; +export type UserQueryFragmentFragment = { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> }; export type DeleteUserAccountMutationVariables = Exact<{ [key: string]: never; }>; @@ -1535,7 +1837,7 @@ export type UploadProfilePictureMutation = { __typename?: 'Mutation', uploadProf export type GetCurrentUserQueryVariables = Exact<{ [key: string]: never; }>; -export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } }; +export type GetCurrentUserQuery = { __typename?: 'Query', currentUser: { __typename?: 'User', id: any, firstName: string, lastName: string, email: string, canImpersonate: boolean, supportUserHash?: string | null, onboardingStatus?: OnboardingStatus | null, userVars: any, analyticsTinybirdJwts?: { __typename?: 'AnalyticsTinybirdJwtMap', getWebhookAnalytics: string, getPageviewsAnalytics: string, getUsersAnalytics: string, getServerlessFunctionDuration: string, getServerlessFunctionSuccessRate: string, getServerlessFunctionErrorCount: string } | null, workspaceMember?: { __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } } | null, workspaceMembers?: Array<{ __typename?: 'WorkspaceMember', id: any, colorScheme: string, avatarUrl?: string | null, locale?: string | null, timeZone?: string | null, dateFormat?: WorkspaceMemberDateFormatEnum | null, timeFormat?: WorkspaceMemberTimeFormatEnum | null, name: { __typename?: 'FullName', firstName: string, lastName: string } }> | null, defaultWorkspace: { __typename?: 'Workspace', id: any, displayName?: string | null, logo?: string | null, domainName?: string | null, inviteHash?: string | null, allowImpersonation: boolean, activationStatus: WorkspaceActivationStatus, isPublicInviteLinkEnabled: boolean, hasValidEntrepriseKey: boolean, metadataVersion: number, workspaceMembersCount?: number | null, featureFlags?: Array<{ __typename?: 'FeatureFlag', id: any, key: string, value: boolean, workspaceId: string }> | null, currentBillingSubscription?: { __typename?: 'BillingSubscription', id: any, status: SubscriptionStatus, interval?: SubscriptionInterval | null } | null }, workspaces: Array<{ __typename?: 'UserWorkspace', workspace?: { __typename?: 'Workspace', id: any, logo?: string | null, displayName?: string | null, domainName?: string | null } | null }> } }; export type ActivateWorkflowVersionMutationVariables = Exact<{ workflowVersionId: Scalars['String']; @@ -1544,6 +1846,13 @@ export type ActivateWorkflowVersionMutationVariables = Exact<{ export type ActivateWorkflowVersionMutation = { __typename?: 'Mutation', activateWorkflowVersion: boolean }; +export type ComputeStepOutputSchemaMutationVariables = Exact<{ + input: ComputeStepOutputSchemaInput; +}>; + + +export type ComputeStepOutputSchemaMutation = { __typename?: 'Mutation', computeStepOutputSchema: any }; + export type DeactivateWorkflowVersionMutationVariables = Exact<{ workflowVersionId: Scalars['String']; }>; @@ -1551,6 +1860,13 @@ export type DeactivateWorkflowVersionMutationVariables = Exact<{ export type DeactivateWorkflowVersionMutation = { __typename?: 'Mutation', deactivateWorkflowVersion: boolean }; +export type RunWorkflowVersionMutationVariables = Exact<{ + input: RunWorkflowVersionInput; +}>; + + +export type RunWorkflowVersionMutation = { __typename?: 'Mutation', runWorkflowVersion: { __typename?: 'WorkflowRun', workflowRunId: any } }; + export type DeleteWorkspaceInvitationMutationVariables = Exact<{ appTokenId: Scalars['String']; }>; @@ -1713,6 +2029,18 @@ export const AuthTokensFragmentFragmentDoc = gql` } } ${AuthTokenFragmentFragmentDoc}`; +export const AvailableSsoIdentityProvidersFragmentFragmentDoc = gql` + fragment AvailableSSOIdentityProvidersFragment on FindAvailableSSOIDPOutput { + id + issuer + name + status + workspace { + id + displayName + } +} + `; export const WorkspaceMemberQueryFragmentFragmentDoc = gql` fragment WorkspaceMemberQueryFragment on WorkspaceMember { id @@ -1736,6 +2064,14 @@ export const UserQueryFragmentFragmentDoc = gql` email canImpersonate supportUserHash + analyticsTinybirdJwts { + getWebhookAnalytics + getPageviewsAnalytics + getUsersAnalytics + getServerlessFunctionDuration + getServerlessFunctionSuccessRate + getServerlessFunctionErrorCount + } onboardingStatus workspaceMember { ...WorkspaceMemberQueryFragment @@ -1751,6 +2087,8 @@ export const UserQueryFragmentFragmentDoc = gql` inviteHash allowImpersonation activationStatus + isPublicInviteLinkEnabled + hasValidEntrepriseKey featureFlags { id key @@ -1962,7 +2300,7 @@ export type TrackMutationFn = Apollo.MutationFunction; export type EmailPasswordResetLinkMutationResult = Apollo.MutationResult; export type EmailPasswordResetLinkMutationOptions = Apollo.BaseMutationOptions; +export const FindAvailableSsoIdentityProvidersDocument = gql` + mutation FindAvailableSSOIdentityProviders($input: FindAvailableSSOIDPInput!) { + findAvailableSSOIdentityProviders(input: $input) { + ...AvailableSSOIdentityProvidersFragment + } +} + ${AvailableSsoIdentityProvidersFragmentFragmentDoc}`; +export type FindAvailableSsoIdentityProvidersMutationFn = Apollo.MutationFunction; + +/** + * __useFindAvailableSsoIdentityProvidersMutation__ + * + * To run a mutation, you first call `useFindAvailableSsoIdentityProvidersMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useFindAvailableSsoIdentityProvidersMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [findAvailableSsoIdentityProvidersMutation, { data, loading, error }] = useFindAvailableSsoIdentityProvidersMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useFindAvailableSsoIdentityProvidersMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(FindAvailableSsoIdentityProvidersDocument, options); + } +export type FindAvailableSsoIdentityProvidersMutationHookResult = ReturnType; +export type FindAvailableSsoIdentityProvidersMutationResult = Apollo.MutationResult; +export type FindAvailableSsoIdentityProvidersMutationOptions = Apollo.BaseMutationOptions; export const GenerateApiKeyTokenDocument = gql` mutation GenerateApiKeyToken($apiKeyId: String!, $expiresAt: String!) { generateApiKeyToken(apiKeyId: $apiKeyId, expiresAt: $expiresAt) { @@ -2184,12 +2555,26 @@ export type GenerateApiKeyTokenMutationOptions = Apollo.BaseMutationOptions; /** @@ -2250,6 +2635,41 @@ export function useGenerateTransientTokenMutation(baseOptions?: Apollo.MutationH export type GenerateTransientTokenMutationHookResult = ReturnType; export type GenerateTransientTokenMutationResult = Apollo.MutationResult; export type GenerateTransientTokenMutationOptions = Apollo.BaseMutationOptions; +export const GetAuthorizationUrlDocument = gql` + mutation GetAuthorizationUrl($input: GetAuthorizationUrlInput!) { + getAuthorizationUrl(input: $input) { + id + type + authorizationURL + } +} + `; +export type GetAuthorizationUrlMutationFn = Apollo.MutationFunction; + +/** + * __useGetAuthorizationUrlMutation__ + * + * To run a mutation, you first call `useGetAuthorizationUrlMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useGetAuthorizationUrlMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [getAuthorizationUrlMutation, { data, loading, error }] = useGetAuthorizationUrlMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useGetAuthorizationUrlMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(GetAuthorizationUrlDocument, options); + } +export type GetAuthorizationUrlMutationHookResult = ReturnType; +export type GetAuthorizationUrlMutationResult = Apollo.MutationResult; +export type GetAuthorizationUrlMutationOptions = Apollo.BaseMutationOptions; export const ImpersonateDocument = gql` mutation Impersonate($userId: String!) { impersonate(userId: $userId) { @@ -2668,6 +3088,7 @@ export const GetClientConfigDocument = gql` google password microsoft + sso } billing { isBillingEnabled @@ -2677,6 +3098,7 @@ export const GetClientConfigDocument = gql` signInPrefilled signUpDisabled debugMode + analyticsEnabled support { supportDriver supportFrontChatId @@ -2756,43 +3178,188 @@ export function useSkipSyncEmailOnboardingStepMutation(baseOptions?: Apollo.Muta export type SkipSyncEmailOnboardingStepMutationHookResult = ReturnType; export type SkipSyncEmailOnboardingStepMutationResult = Apollo.MutationResult; export type SkipSyncEmailOnboardingStepMutationOptions = Apollo.BaseMutationOptions; -export const GetAisqlQueryDocument = gql` - query GetAISQLQuery($text: String!) { - getAISQLQuery(text: $text) { - sqlQuery - sqlQueryResult - queryFailedErrorMessage +export const CreateOidcIdentityProviderDocument = gql` + mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) { + createOIDCIdentityProvider(input: $input) { + id + type + issuer + name + status + } +} + `; +export type CreateOidcIdentityProviderMutationFn = Apollo.MutationFunction; + +/** + * __useCreateOidcIdentityProviderMutation__ + * + * To run a mutation, you first call `useCreateOidcIdentityProviderMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateOidcIdentityProviderMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [createOidcIdentityProviderMutation, { data, loading, error }] = useCreateOidcIdentityProviderMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useCreateOidcIdentityProviderMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateOidcIdentityProviderDocument, options); + } +export type CreateOidcIdentityProviderMutationHookResult = ReturnType; +export type CreateOidcIdentityProviderMutationResult = Apollo.MutationResult; +export type CreateOidcIdentityProviderMutationOptions = Apollo.BaseMutationOptions; +export const CreateSamlIdentityProviderDocument = gql` + mutation CreateSAMLIdentityProvider($input: SetupSAMLSsoInput!) { + createSAMLIdentityProvider(input: $input) { + id + type + issuer + name + status + } +} + `; +export type CreateSamlIdentityProviderMutationFn = Apollo.MutationFunction; + +/** + * __useCreateSamlIdentityProviderMutation__ + * + * To run a mutation, you first call `useCreateSamlIdentityProviderMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useCreateSamlIdentityProviderMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [createSamlIdentityProviderMutation, { data, loading, error }] = useCreateSamlIdentityProviderMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useCreateSamlIdentityProviderMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(CreateSamlIdentityProviderDocument, options); + } +export type CreateSamlIdentityProviderMutationHookResult = ReturnType; +export type CreateSamlIdentityProviderMutationResult = Apollo.MutationResult; +export type CreateSamlIdentityProviderMutationOptions = Apollo.BaseMutationOptions; +export const DeleteSsoIdentityProviderDocument = gql` + mutation DeleteSSOIdentityProvider($input: DeleteSsoInput!) { + deleteSSOIdentityProvider(input: $input) { + identityProviderId + } +} + `; +export type DeleteSsoIdentityProviderMutationFn = Apollo.MutationFunction; + +/** + * __useDeleteSsoIdentityProviderMutation__ + * + * To run a mutation, you first call `useDeleteSsoIdentityProviderMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useDeleteSsoIdentityProviderMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [deleteSsoIdentityProviderMutation, { data, loading, error }] = useDeleteSsoIdentityProviderMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useDeleteSsoIdentityProviderMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(DeleteSsoIdentityProviderDocument, options); + } +export type DeleteSsoIdentityProviderMutationHookResult = ReturnType; +export type DeleteSsoIdentityProviderMutationResult = Apollo.MutationResult; +export type DeleteSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions; +export const EditSsoIdentityProviderDocument = gql` + mutation EditSSOIdentityProvider($input: EditSsoInput!) { + editSSOIdentityProvider(input: $input) { + id + type + issuer + name + status + } +} + `; +export type EditSsoIdentityProviderMutationFn = Apollo.MutationFunction; + +/** + * __useEditSsoIdentityProviderMutation__ + * + * To run a mutation, you first call `useEditSsoIdentityProviderMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useEditSsoIdentityProviderMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [editSsoIdentityProviderMutation, { data, loading, error }] = useEditSsoIdentityProviderMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useEditSsoIdentityProviderMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(EditSsoIdentityProviderDocument, options); + } +export type EditSsoIdentityProviderMutationHookResult = ReturnType; +export type EditSsoIdentityProviderMutationResult = Apollo.MutationResult; +export type EditSsoIdentityProviderMutationOptions = Apollo.BaseMutationOptions; +export const ListSsoIdentityProvidersByWorkspaceIdDocument = gql` + query ListSSOIdentityProvidersByWorkspaceId { + listSSOIdentityProvidersByWorkspaceId { + type + id + name + issuer + status } } `; /** - * __useGetAisqlQueryQuery__ + * __useListSsoIdentityProvidersByWorkspaceIdQuery__ * - * To run a query within a React component, call `useGetAisqlQueryQuery` and pass it any options that fit your needs. - * When your component renders, `useGetAisqlQueryQuery` returns an object from Apollo Client that contains loading, error, and data properties + * To run a query within a React component, call `useListSsoIdentityProvidersByWorkspaceIdQuery` and pass it any options that fit your needs. + * When your component renders, `useListSsoIdentityProvidersByWorkspaceIdQuery` returns an object from Apollo Client that contains loading, error, and data properties * you can use to render your UI. * * @param baseOptions options that will be passed into the query, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options; * * @example - * const { data, loading, error } = useGetAisqlQueryQuery({ + * const { data, loading, error } = useListSsoIdentityProvidersByWorkspaceIdQuery({ * variables: { - * text: // value for 'text' * }, * }); */ -export function useGetAisqlQueryQuery(baseOptions: Apollo.QueryHookOptions) { +export function useListSsoIdentityProvidersByWorkspaceIdQuery(baseOptions?: Apollo.QueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useQuery(GetAisqlQueryDocument, options); + return Apollo.useQuery(ListSsoIdentityProvidersByWorkspaceIdDocument, options); } -export function useGetAisqlQueryLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { +export function useListSsoIdentityProvidersByWorkspaceIdLazyQuery(baseOptions?: Apollo.LazyQueryHookOptions) { const options = {...defaultOptions, ...baseOptions} - return Apollo.useLazyQuery(GetAisqlQueryDocument, options); + return Apollo.useLazyQuery(ListSsoIdentityProvidersByWorkspaceIdDocument, options); } -export type GetAisqlQueryQueryHookResult = ReturnType; -export type GetAisqlQueryLazyQueryHookResult = ReturnType; -export type GetAisqlQueryQueryResult = Apollo.QueryResult; +export type ListSsoIdentityProvidersByWorkspaceIdQueryHookResult = ReturnType; +export type ListSsoIdentityProvidersByWorkspaceIdLazyQueryHookResult = ReturnType; +export type ListSsoIdentityProvidersByWorkspaceIdQueryResult = Apollo.QueryResult; export const DeleteUserAccountDocument = gql` mutation DeleteUserAccount { deleteUser { @@ -2921,6 +3488,37 @@ export function useActivateWorkflowVersionMutation(baseOptions?: Apollo.Mutation export type ActivateWorkflowVersionMutationHookResult = ReturnType; export type ActivateWorkflowVersionMutationResult = Apollo.MutationResult; export type ActivateWorkflowVersionMutationOptions = Apollo.BaseMutationOptions; +export const ComputeStepOutputSchemaDocument = gql` + mutation ComputeStepOutputSchema($input: ComputeStepOutputSchemaInput!) { + computeStepOutputSchema(input: $input) +} + `; +export type ComputeStepOutputSchemaMutationFn = Apollo.MutationFunction; + +/** + * __useComputeStepOutputSchemaMutation__ + * + * To run a mutation, you first call `useComputeStepOutputSchemaMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useComputeStepOutputSchemaMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [computeStepOutputSchemaMutation, { data, loading, error }] = useComputeStepOutputSchemaMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useComputeStepOutputSchemaMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(ComputeStepOutputSchemaDocument, options); + } +export type ComputeStepOutputSchemaMutationHookResult = ReturnType; +export type ComputeStepOutputSchemaMutationResult = Apollo.MutationResult; +export type ComputeStepOutputSchemaMutationOptions = Apollo.BaseMutationOptions; export const DeactivateWorkflowVersionDocument = gql` mutation DeactivateWorkflowVersion($workflowVersionId: String!) { deactivateWorkflowVersion(workflowVersionId: $workflowVersionId) @@ -2952,6 +3550,39 @@ export function useDeactivateWorkflowVersionMutation(baseOptions?: Apollo.Mutati export type DeactivateWorkflowVersionMutationHookResult = ReturnType; export type DeactivateWorkflowVersionMutationResult = Apollo.MutationResult; export type DeactivateWorkflowVersionMutationOptions = Apollo.BaseMutationOptions; +export const RunWorkflowVersionDocument = gql` + mutation RunWorkflowVersion($input: RunWorkflowVersionInput!) { + runWorkflowVersion(input: $input) { + workflowRunId + } +} + `; +export type RunWorkflowVersionMutationFn = Apollo.MutationFunction; + +/** + * __useRunWorkflowVersionMutation__ + * + * To run a mutation, you first call `useRunWorkflowVersionMutation` within a React component and pass it any options that fit your needs. + * When your component renders, `useRunWorkflowVersionMutation` returns a tuple that includes: + * - A mutate function that you can call at any time to execute the mutation + * - An object with fields that represent the current status of the mutation's execution + * + * @param baseOptions options that will be passed into the mutation, supported options are listed on: https://www.apollographql.com/docs/react/api/react-hooks/#options-2; + * + * @example + * const [runWorkflowVersionMutation, { data, loading, error }] = useRunWorkflowVersionMutation({ + * variables: { + * input: // value for 'input' + * }, + * }); + */ +export function useRunWorkflowVersionMutation(baseOptions?: Apollo.MutationHookOptions) { + const options = {...defaultOptions, ...baseOptions} + return Apollo.useMutation(RunWorkflowVersionDocument, options); + } +export type RunWorkflowVersionMutationHookResult = ReturnType; +export type RunWorkflowVersionMutationResult = Apollo.MutationResult; +export type RunWorkflowVersionMutationOptions = Apollo.BaseMutationOptions; export const DeleteWorkspaceInvitationDocument = gql` mutation DeleteWorkspaceInvitation($appTokenId: String!) { deleteWorkspaceInvitation(appTokenId: $appTokenId) diff --git a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts index 95c2a58b79c6..a7b683e660f7 100644 --- a/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts +++ b/packages/twenty-front/src/hooks/__tests__/usePageChangeEffectNavigateLocation.test.ts @@ -2,6 +2,7 @@ import { useIsLogged } from '@/auth/hooks/useIsLogged'; import { useDefaultHomePagePath } from '@/navigation/hooks/useDefaultHomePagePath'; import { useOnboardingStatus } from '@/onboarding/hooks/useOnboardingStatus'; import { AppPath } from '@/types/AppPath'; + import { useSubscriptionStatus } from '@/workspace/hooks/useSubscriptionStatus'; import { OnboardingStatus, SubscriptionStatus } from '~/generated/graphql'; diff --git a/packages/twenty-front/src/hooks/useCleanRecoilState.ts b/packages/twenty-front/src/hooks/useCleanRecoilState.ts index 3f0a41df4aed..9da98c006af9 100644 --- a/packages/twenty-front/src/hooks/useCleanRecoilState.ts +++ b/packages/twenty-front/src/hooks/useCleanRecoilState.ts @@ -1,8 +1,9 @@ -import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; -import { SettingsPath } from '@/types/SettingsPath'; import { apiKeyTokenState } from '@/settings/developers/states/generatedApiKeyTokenState'; -import { useRecoilValue, useResetRecoilState } from 'recoil'; import { AppPath } from '@/types/AppPath'; +import { SettingsPath } from '@/types/SettingsPath'; +import { useRecoilValue, useResetRecoilState } from 'recoil'; +import { useIsMatchingLocation } from '~/hooks/useIsMatchingLocation'; + import { isDefined } from '~/utils/isDefined'; export const useCleanRecoilState = () => { diff --git a/packages/twenty-front/src/index.css b/packages/twenty-front/src/index.css index 808fd8917db4..22b25687aafd 100644 --- a/packages/twenty-front/src/index.css +++ b/packages/twenty-front/src/index.css @@ -9,7 +9,11 @@ html { font-size: 13px; } +button { + font-size: 13px; +} + /* https://stackoverflow.com/questions/44543157/how-to-hide-the-google-invisible-recaptcha-badge */ -.grecaptcha-badge { +.grecaptcha-badge { visibility: hidden !important; } diff --git a/packages/twenty-front/src/loading/components/LeftPanelSkeletonLoader.tsx b/packages/twenty-front/src/loading/components/LeftPanelSkeletonLoader.tsx index c8de2f64c46a..b3ea1eda955e 100644 --- a/packages/twenty-front/src/loading/components/LeftPanelSkeletonLoader.tsx +++ b/packages/twenty-front/src/loading/components/LeftPanelSkeletonLoader.tsx @@ -4,7 +4,7 @@ import Skeleton, { SkeletonTheme } from 'react-loading-skeleton'; import { ANIMATION, BACKGROUND_LIGHT, GRAY_SCALE } from 'twenty-ui'; import { SKELETON_LOADER_HEIGHT_SIZES } from '@/activities/components/SkeletonLoader'; -import { DESKTOP_NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/DesktopNavDrawerWidths'; +import { NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/NavDrawerWidths'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { MainNavigationDrawerItemsSkeletonLoader } from '~/loading/components/MainNavigationDrawerItemsSkeletonLoader'; @@ -47,14 +47,14 @@ const StyledSkeletonTitleContainer = styled.div` export const LeftPanelSkeletonLoader = () => { const isMobile = useIsMobile(); - const mobileWidth = isMobile ? 0 : '100%'; - const desktopWidth = !mobileWidth ? 12 : DESKTOP_NAV_DRAWER_WIDTHS.menu; return ( { - const isCurrentUserLoaded = useRecoilValue(isCurrentUserLoadedState); - const objectMetadataItems = useRecoilValue(objectMetadataItemsState); - - return !isCurrentUserLoaded || objectMetadataItems.length === 0; -}; diff --git a/packages/twenty-front/src/modules/action-menu/actions/global-actions/components/GlobalActionMenuEntriesSetter.tsx b/packages/twenty-front/src/modules/action-menu/actions/global-actions/components/GlobalActionMenuEntriesSetter.tsx new file mode 100644 index 000000000000..35dc9b0706f4 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/global-actions/components/GlobalActionMenuEntriesSetter.tsx @@ -0,0 +1,8 @@ +import { WorkflowRunActionEffect } from '@/action-menu/actions/global-actions/workflow-run-actions/components/WorkflowRunActionEffect'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; + +export const GlobalActionMenuEntriesSetter = () => { + const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED'); + + return <>{isWorkflowEnabled && }; +}; diff --git a/packages/twenty-front/src/modules/action-menu/actions/global-actions/workflow-run-actions/components/WorkflowRunActionEffect.tsx b/packages/twenty-front/src/modules/action-menu/actions/global-actions/workflow-run-actions/components/WorkflowRunActionEffect.tsx new file mode 100644 index 000000000000..7a9f78d869ee --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/global-actions/workflow-run-actions/components/WorkflowRunActionEffect.tsx @@ -0,0 +1,68 @@ +import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useAllActiveWorkflowVersions } from '@/workflow/hooks/useAllActiveWorkflowVersions'; +import { useRunWorkflowVersion } from '@/workflow/hooks/useRunWorkflowVersion'; + +import { useTheme } from '@emotion/react'; +import { useEffect } from 'react'; +import { IconSettingsAutomation } from 'twenty-ui'; +import { capitalize } from '~/utils/string/capitalize'; + +export const WorkflowRunActionEffect = () => { + const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries(); + + const { records: activeWorkflowVersions } = useAllActiveWorkflowVersions({ + triggerType: 'MANUAL', + }); + + const { runWorkflowVersion } = useRunWorkflowVersion(); + + const { enqueueSnackBar } = useSnackBar(); + + const theme = useTheme(); + + useEffect(() => { + for (const [ + index, + activeWorkflowVersion, + ] of activeWorkflowVersions.entries()) { + addActionMenuEntry({ + type: 'workflow-run', + key: `workflow-run-${activeWorkflowVersion.id}`, + label: capitalize(activeWorkflowVersion.workflow.name), + position: index, + Icon: IconSettingsAutomation, + onClick: async () => { + await runWorkflowVersion(activeWorkflowVersion.id); + + enqueueSnackBar('', { + variant: SnackBarVariant.Success, + title: `${capitalize(activeWorkflowVersion.workflow.name)} starting...`, + icon: ( + + ), + }); + }, + }); + } + + return () => { + for (const activeWorkflowVersion of activeWorkflowVersions) { + removeActionMenuEntry(`workflow-run-${activeWorkflowVersion.id}`); + } + }; + }, [ + activeWorkflowVersions, + addActionMenuEntry, + enqueueSnackBar, + removeActionMenuEntry, + runWorkflowVersion, + theme.snackBar.success.color, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/DeleteRecordsActionEffect.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/DeleteRecordsActionEffect.tsx index 89243ead97c5..515852428a20 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/DeleteRecordsActionEffect.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/DeleteRecordsActionEffect.tsx @@ -1,60 +1,115 @@ +import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext'; import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries'; -import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState'; -import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState'; -import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; +import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState'; +import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; +import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; +import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters'; +import { useFavorites } from '@/favorites/hooks/useFavorites'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { DELETE_MAX_COUNT } from '@/object-record/constants/DeleteMaxCount'; -import { useDeleteTableData } from '@/object-record/record-index/options/hooks/useDeleteTableData'; +import { useDeleteManyRecords } from '@/object-record/hooks/useDeleteManyRecords'; +import { useFetchAllRecordIds } from '@/object-record/hooks/useFetchAllRecordIds'; +import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; -import { useCallback, useEffect, useState } from 'react'; -import { useRecoilValue } from 'recoil'; -import { IconTrash } from 'twenty-ui'; +import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useCallback, useContext, useEffect, useState } from 'react'; +import { IconTrash, isDefined } from 'twenty-ui'; export const DeleteRecordsActionEffect = ({ position, + objectMetadataItem, }: { position: number; + objectMetadataItem: ObjectMetadataItem; }) => { const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries(); - const contextStoreTargetedRecordIds = useRecoilValue( - contextStoreTargetedRecordIdsState, + const [isDeleteRecordsModalOpen, setIsDeleteRecordsModalOpen] = + useState(false); + + const { resetTableRowSelection } = useRecordTable({ + recordTableId: objectMetadataItem.namePlural, + }); + + const { deleteManyRecords } = useDeleteManyRecords({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + + const { favorites, deleteFavorite } = useFavorites(); + + const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2( + contextStoreNumberOfSelectedRecordsComponentState, ); - const contextStoreCurrentObjectMetadataId = useRecoilValue( - contextStoreCurrentObjectMetadataIdState, + const contextStoreTargetedRecordsRule = useRecoilComponentValueV2( + contextStoreTargetedRecordsRuleComponentState, ); - const { objectMetadataItem } = useObjectMetadataItemById({ - objectId: contextStoreCurrentObjectMetadataId, - }); + const contextStoreFilters = useRecoilComponentValueV2( + contextStoreFiltersComponentState, + ); - const [isDeleteRecordsModalOpen, setIsDeleteRecordsModalOpen] = - useState(false); + const graphqlFilter = computeContextStoreFilters( + contextStoreTargetedRecordsRule, + contextStoreFilters, + objectMetadataItem, + ); - const { deleteTableData } = useDeleteTableData({ - objectNameSingular: objectMetadataItem?.nameSingular ?? '', - recordIndexId: objectMetadataItem?.namePlural ?? '', + const { fetchAllRecordIds } = useFetchAllRecordIds({ + objectNameSingular: objectMetadataItem.nameSingular, + filter: graphqlFilter, }); - const handleDeleteClick = useCallback(() => { - deleteTableData(contextStoreTargetedRecordIds); - }, [deleteTableData, contextStoreTargetedRecordIds]); + const { closeRightDrawer } = useRightDrawer(); + + const handleDeleteClick = useCallback(async () => { + const recordIdsToDelete = await fetchAllRecordIds(); - const isRemoteObject = objectMetadataItem?.isRemote ?? false; + resetTableRowSelection(); - const numberOfSelectedRecords = contextStoreTargetedRecordIds.length; + for (const recordIdToDelete of recordIdsToDelete) { + const foundFavorite = favorites?.find( + (favorite) => favorite.recordId === recordIdToDelete, + ); + + if (foundFavorite !== undefined) { + deleteFavorite(foundFavorite.id); + } + } + + await deleteManyRecords(recordIdsToDelete, { + delayInMsBetweenRequests: 50, + }); + }, [ + deleteFavorite, + deleteManyRecords, + favorites, + fetchAllRecordIds, + resetTableRowSelection, + ]); + + const isRemoteObject = objectMetadataItem.isRemote; const canDelete = - !isRemoteObject && numberOfSelectedRecords < DELETE_MAX_COUNT; + !isRemoteObject && + isDefined(contextStoreNumberOfSelectedRecords) && + contextStoreNumberOfSelectedRecords < DELETE_MAX_COUNT && + contextStoreNumberOfSelectedRecords > 0; + + const { isInRightDrawer, onActionExecutedCallback } = + useContext(ActionMenuContext); useEffect(() => { if (canDelete) { addActionMenuEntry({ + type: 'standard', key: 'delete', label: 'Delete', position, Icon: IconTrash, accent: 'danger', + isPinned: true, onClick: () => { setIsDeleteRecordsModalOpen(true); }, @@ -62,17 +117,25 @@ export const DeleteRecordsActionEffect = ({ handleDeleteClick()} + onConfirmClick={() => { + handleDeleteClick(); + onActionExecutedCallback?.(); + if (isInRightDrawer) { + closeRightDrawer(); + } + }} deleteButtonText={`Delete ${ - numberOfSelectedRecords > 1 ? 'Records' : 'Record' + contextStoreNumberOfSelectedRecords > 1 ? 'Records' : 'Record' }`} /> ), @@ -80,14 +143,21 @@ export const DeleteRecordsActionEffect = ({ } else { removeActionMenuEntry('delete'); } + + return () => { + removeActionMenuEntry('delete'); + }; }, [ - canDelete, addActionMenuEntry, - removeActionMenuEntry, - isDeleteRecordsModalOpen, - numberOfSelectedRecords, + canDelete, + closeRightDrawer, + contextStoreNumberOfSelectedRecords, handleDeleteClick, + isDeleteRecordsModalOpen, + isInRightDrawer, + onActionExecutedCallback, position, + removeActionMenuEntry, ]); return null; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ExportRecordsActionEffect.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ExportRecordsActionEffect.tsx index d7b50ddaf0d3..870eb85a8d35 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ExportRecordsActionEffect.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ExportRecordsActionEffect.tsx @@ -1,46 +1,44 @@ import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries'; -import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState'; -import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; import { displayedExportProgress, - useExportTableData, -} from '@/object-record/record-index/options/hooks/useExportTableData'; + useExportRecordData, +} from '@/action-menu/hooks/useExportRecordData'; +import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { IconDatabaseExport } from 'twenty-ui'; + import { useEffect } from 'react'; -import { useRecoilValue } from 'recoil'; -import { IconFileExport } from 'twenty-ui'; export const ExportRecordsActionEffect = ({ position, + objectMetadataItem, }: { position: number; + objectMetadataItem: ObjectMetadataItem; }) => { const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries(); - - const contextStoreCurrentObjectMetadataId = useRecoilValue( - contextStoreCurrentObjectMetadataIdState, + const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2( + contextStoreNumberOfSelectedRecordsComponentState, ); - const { objectMetadataItem } = useObjectMetadataItemById({ - objectId: contextStoreCurrentObjectMetadataId, - }); - - const baseTableDataParams = { + const { progress, download } = useExportRecordData({ delayMs: 100, - objectNameSingular: objectMetadataItem?.nameSingular ?? '', - recordIndexId: objectMetadataItem?.namePlural ?? '', - }; - - const { progress, download } = useExportTableData({ - ...baseTableDataParams, - filename: `${objectMetadataItem?.nameSingular}.csv`, + objectMetadataItem, + recordIndexId: objectMetadataItem.namePlural, + filename: `${objectMetadataItem.nameSingular}.csv`, }); useEffect(() => { addActionMenuEntry({ + type: 'standard', key: 'export', position, - label: displayedExportProgress(progress), - Icon: IconFileExport, + label: displayedExportProgress( + contextStoreNumberOfSelectedRecords > 0 ? 'selection' : 'all', + progress, + ), + Icon: IconDatabaseExport, accent: 'default', onClick: () => download(), }); @@ -48,6 +46,14 @@ export const ExportRecordsActionEffect = ({ return () => { removeActionMenuEntry('export'); }; - }, [download, progress, addActionMenuEntry, removeActionMenuEntry, position]); + }, [ + contextStoreNumberOfSelectedRecords, + download, + progress, + addActionMenuEntry, + removeActionMenuEntry, + position, + ]); + return null; }; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ManageFavoritesActionEffect.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ManageFavoritesActionEffect.tsx index e9767b034203..25d03d071664 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ManageFavoritesActionEffect.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/ManageFavoritesActionEffect.tsx @@ -1,39 +1,37 @@ import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries'; -import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState'; -import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState'; +import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { useFavorites } from '@/favorites/hooks/useFavorites'; -import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useEffect } from 'react'; import { useRecoilValue } from 'recoil'; import { IconHeart, IconHeartOff, isDefined } from 'twenty-ui'; export const ManageFavoritesActionEffect = ({ position, + objectMetadataItem, }: { position: number; + objectMetadataItem: ObjectMetadataItem; }) => { const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries(); - const contextStoreTargetedRecordIds = useRecoilValue( - contextStoreTargetedRecordIdsState, - ); - const contextStoreCurrentObjectMetadataId = useRecoilValue( - contextStoreCurrentObjectMetadataIdState, + const contextStoreTargetedRecordsRule = useRecoilComponentValueV2( + contextStoreTargetedRecordsRuleComponentState, ); const { favorites, createFavorite, deleteFavorite } = useFavorites(); - const selectedRecordId = contextStoreTargetedRecordIds[0]; + const selectedRecordId = + contextStoreTargetedRecordsRule.mode === 'selection' + ? contextStoreTargetedRecordsRule.selectedRecordIds[0] + : undefined; const selectedRecord = useRecoilValue( - recordStoreFamilyState(selectedRecordId), + recordStoreFamilyState(selectedRecordId ?? ''), ); - const { objectMetadataItem } = useObjectMetadataItemById({ - objectId: contextStoreCurrentObjectMetadataId, - }); - const foundFavorite = favorites?.find( (favorite) => favorite.recordId === selectedRecordId, ); @@ -46,6 +44,7 @@ export const ManageFavoritesActionEffect = ({ } addActionMenuEntry({ + type: 'standard', key: 'manage-favorites', label: isFavorite ? 'Remove from favorites' : 'Add to favorites', position, diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/MultipleRecordsActionMenuEntriesSetter.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/MultipleRecordsActionMenuEntriesSetter.tsx deleted file mode 100644 index 69bfd3305094..000000000000 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/MultipleRecordsActionMenuEntriesSetter.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { DeleteRecordsActionEffect } from '@/action-menu/actions/record-actions/components/DeleteRecordsActionEffect'; -import { ExportRecordsActionEffect } from '@/action-menu/actions/record-actions/components/ExportRecordsActionEffect'; - -const actionEffects = [ExportRecordsActionEffect, DeleteRecordsActionEffect]; - -export const MultipleRecordsActionMenuEntriesSetter = () => { - return ( - <> - {actionEffects.map((ActionEffect, index) => ( - - ))} - - ); -}; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter.tsx index 75267e445d49..7d64ec72c0de 100644 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter.tsx +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter.tsx @@ -1,20 +1,67 @@ -import { MultipleRecordsActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/MultipleRecordsActionMenuEntriesSetter'; -import { SingleRecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/SingleRecordActionMenuEntriesSetter'; -import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState'; -import { useRecoilValue } from 'recoil'; +import { DeleteRecordsActionEffect } from '@/action-menu/actions/record-actions/components/DeleteRecordsActionEffect'; +import { ExportRecordsActionEffect } from '@/action-menu/actions/record-actions/components/ExportRecordsActionEffect'; +import { ManageFavoritesActionEffect } from '@/action-menu/actions/record-actions/components/ManageFavoritesActionEffect'; +import { WorkflowRunRecordActionEffect } from '@/action-menu/actions/record-actions/workflow-run-record-actions/components/WorkflowRunRecordActionEffect'; +import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState'; +import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; +import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; + +const noSelectionRecordActionEffects = [ExportRecordsActionEffect]; + +const singleRecordActionEffects = [ + ManageFavoritesActionEffect, + DeleteRecordsActionEffect, +]; + +const multipleRecordActionEffects = [ + ExportRecordsActionEffect, + DeleteRecordsActionEffect, +]; export const RecordActionMenuEntriesSetter = () => { - const contextStoreTargetedRecordIds = useRecoilValue( - contextStoreTargetedRecordIdsState, + const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2( + contextStoreNumberOfSelectedRecordsComponentState, ); - if (contextStoreTargetedRecordIds.length === 0) { - return null; - } + const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2( + contextStoreCurrentObjectMetadataIdComponentState, + ); - if (contextStoreTargetedRecordIds.length === 1) { - return ; + const { objectMetadataItem } = useObjectMetadataItemById({ + objectId: contextStoreCurrentObjectMetadataId ?? '', + }); + + const isWorkflowEnabled = useIsFeatureEnabled('IS_WORKFLOW_ENABLED'); + + if (!objectMetadataItem) { + throw new Error( + `Object metadata item not found for id ${contextStoreCurrentObjectMetadataId}`, + ); } - return ; + const actions = + contextStoreNumberOfSelectedRecords === 0 + ? noSelectionRecordActionEffects + : contextStoreNumberOfSelectedRecords === 1 + ? singleRecordActionEffects + : multipleRecordActionEffects; + + return ( + <> + {actions.map((ActionEffect, index) => ( + + ))} + {contextStoreNumberOfSelectedRecords === 1 && isWorkflowEnabled && ( + + )} + + ); }; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/SingleRecordActionMenuEntriesSetter.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/SingleRecordActionMenuEntriesSetter.tsx deleted file mode 100644 index feeba5aabc61..000000000000 --- a/packages/twenty-front/src/modules/action-menu/actions/record-actions/components/SingleRecordActionMenuEntriesSetter.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { DeleteRecordsActionEffect } from '@/action-menu/actions/record-actions/components/DeleteRecordsActionEffect'; -import { ExportRecordsActionEffect } from '@/action-menu/actions/record-actions/components/ExportRecordsActionEffect'; -import { ManageFavoritesActionEffect } from '@/action-menu/actions/record-actions/components/ManageFavoritesActionEffect'; - -export const SingleRecordActionMenuEntriesSetter = () => { - const actionEffects = [ - ExportRecordsActionEffect, - DeleteRecordsActionEffect, - ManageFavoritesActionEffect, - ]; - return ( - <> - {actionEffects.map((ActionEffect, index) => ( - - ))} - - ); -}; diff --git a/packages/twenty-front/src/modules/action-menu/actions/record-actions/workflow-run-record-actions/components/WorkflowRunRecordActionEffect.tsx b/packages/twenty-front/src/modules/action-menu/actions/record-actions/workflow-run-record-actions/components/WorkflowRunRecordActionEffect.tsx new file mode 100644 index 000000000000..9c8d509e3eb0 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/actions/record-actions/workflow-run-record-actions/components/WorkflowRunRecordActionEffect.tsx @@ -0,0 +1,101 @@ +import { useActionMenuEntries } from '@/action-menu/hooks/useActionMenuEntries'; +import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useAllActiveWorkflowVersions } from '@/workflow/hooks/useAllActiveWorkflowVersions'; +import { useRunWorkflowVersion } from '@/workflow/hooks/useRunWorkflowVersion'; + +import { useTheme } from '@emotion/react'; +import { useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; +import { IconSettingsAutomation, isDefined } from 'twenty-ui'; +import { capitalize } from '~/utils/string/capitalize'; + +export const WorkflowRunRecordActionEffect = ({ + objectMetadataItem, +}: { + objectMetadataItem: ObjectMetadataItem; +}) => { + const { addActionMenuEntry, removeActionMenuEntry } = useActionMenuEntries(); + + const contextStoreTargetedRecordsRule = useRecoilComponentValueV2( + contextStoreTargetedRecordsRuleComponentState, + ); + + const selectedRecordId = + contextStoreTargetedRecordsRule.mode === 'selection' + ? contextStoreTargetedRecordsRule.selectedRecordIds[0] + : undefined; + + const selectedRecord = useRecoilValue( + recordStoreFamilyState(selectedRecordId ?? ''), + ); + + const { records: activeWorkflowVersions } = useAllActiveWorkflowVersions({ + objectMetadataItem, + triggerType: 'MANUAL', + }); + + const { runWorkflowVersion } = useRunWorkflowVersion(); + + const { enqueueSnackBar } = useSnackBar(); + + const theme = useTheme(); + + useEffect(() => { + if (!isDefined(objectMetadataItem) || objectMetadataItem.isRemote) { + return; + } + + for (const [ + index, + activeWorkflowVersion, + ] of activeWorkflowVersions.entries()) { + addActionMenuEntry({ + type: 'workflow-run', + key: `workflow-run-${activeWorkflowVersion.id}`, + label: capitalize(activeWorkflowVersion.workflow.name), + position: index, + Icon: IconSettingsAutomation, + onClick: async () => { + if (!isDefined(selectedRecord)) { + return; + } + + await runWorkflowVersion(activeWorkflowVersion.id, selectedRecord); + + enqueueSnackBar('', { + variant: SnackBarVariant.Success, + title: `${capitalize(activeWorkflowVersion.workflow.name)} starting...`, + icon: ( + + ), + }); + }, + }); + } + + return () => { + for (const activeWorkflowVersion of activeWorkflowVersions) { + removeActionMenuEntry(`workflow-run-${activeWorkflowVersion.id}`); + } + }; + }, [ + activeWorkflowVersions, + addActionMenuEntry, + enqueueSnackBar, + objectMetadataItem, + removeActionMenuEntry, + runWorkflowVersion, + selectedRecord, + theme.snackBar.success.color, + ]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/ActionMenuConfirmationModals.tsx b/packages/twenty-front/src/modules/action-menu/components/ActionMenuConfirmationModals.tsx index 0b3e52032c59..2dd9ab9071cf 100644 --- a/packages/twenty-front/src/modules/action-menu/components/ActionMenuConfirmationModals.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/ActionMenuConfirmationModals.tsx @@ -1,5 +1,10 @@ import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import styled from '@emotion/styled'; + +const StyledActionMenuConfirmationModals = styled.div` + position: absolute; +`; export const ActionMenuConfirmationModals = () => { const actionMenuEntries = useRecoilComponentValueV2( @@ -7,12 +12,12 @@ export const ActionMenuConfirmationModals = () => { ); return ( -
+ {actionMenuEntries.map((actionMenuEntry, index) => actionMenuEntry.ConfirmationModal ? (
{actionMenuEntry.ConfirmationModal}
) : null, )} -
+ ); }; diff --git a/packages/twenty-front/src/modules/action-menu/components/ActionMenuEffect.tsx b/packages/twenty-front/src/modules/action-menu/components/ActionMenuEffect.tsx deleted file mode 100644 index 60355cc9256f..000000000000 --- a/packages/twenty-front/src/modules/action-menu/components/ActionMenuEffect.tsx +++ /dev/null @@ -1,46 +0,0 @@ -import { useActionMenu } from '@/action-menu/hooks/useActionMenu'; -import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; -import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState'; -import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; -import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; -import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; -import { useEffect } from 'react'; -import { useRecoilValue } from 'recoil'; - -export const ActionMenuEffect = () => { - const contextStoreTargetedRecordIds = useRecoilValue( - contextStoreTargetedRecordIdsState, - ); - - const actionMenuId = useAvailableComponentInstanceIdOrThrow( - ActionMenuComponentInstanceContext, - ); - - const { openActionBar, closeActionBar } = useActionMenu(actionMenuId); - - const isDropdownOpen = useRecoilValue( - extractComponentState( - isDropdownOpenComponentState, - `action-menu-dropdown-${actionMenuId}`, - ), - ); - - useEffect(() => { - if (contextStoreTargetedRecordIds.length > 0 && !isDropdownOpen) { - // We only handle opening the ActionMenuBar here, not the Dropdown. - // The Dropdown is already managed by sync handlers for events like - // right-click to open and click outside to close. - openActionBar(); - } - if (contextStoreTargetedRecordIds.length === 0) { - closeActionBar(); - } - }, [ - contextStoreTargetedRecordIds, - openActionBar, - closeActionBar, - isDropdownOpen, - ]); - - return null; -}; diff --git a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenu.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenu.tsx new file mode 100644 index 000000000000..baa2be8b9d34 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenu.tsx @@ -0,0 +1,36 @@ +import { GlobalActionMenuEntriesSetter } from '@/action-menu/actions/global-actions/components/GlobalActionMenuEntriesSetter'; +import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter'; +import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals'; +import { RecordIndexActionMenuBar } from '@/action-menu/components/RecordIndexActionMenuBar'; +import { RecordIndexActionMenuDropdown } from '@/action-menu/components/RecordIndexActionMenuDropdown'; +import { RecordIndexActionMenuEffect } from '@/action-menu/components/RecordIndexActionMenuEffect'; +import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext'; + +import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; + +export const RecordIndexActionMenu = () => { + const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2( + contextStoreCurrentObjectMetadataIdComponentState, + ); + + return ( + <> + {contextStoreCurrentObjectMetadataId && ( + {}, + }} + > + + + + + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/ActionMenuBar.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuBar.tsx similarity index 55% rename from packages/twenty-front/src/modules/action-menu/components/ActionMenuBar.tsx rename to packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuBar.tsx index 258683347919..fcc93f5bc50e 100644 --- a/packages/twenty-front/src/modules/action-menu/components/ActionMenuBar.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuBar.tsx @@ -1,14 +1,15 @@ import styled from '@emotion/styled'; -import { ActionMenuBarEntry } from '@/action-menu/components/ActionMenuBarEntry'; +import { RecordIndexActionMenuBarAllActionsButton } from '@/action-menu/components/RecordIndexActionMenuBarAllActionsButton'; +import { RecordIndexActionMenuBarEntry } from '@/action-menu/components/RecordIndexActionMenuBarEntry'; import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; import { ActionBarHotkeyScope } from '@/action-menu/types/ActionBarHotKeyScope'; -import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState'; +import { getActionBarIdFromActionMenuId } from '@/action-menu/utils/getActionBarIdFromActionMenuId'; +import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; import { BottomBar } from '@/ui/layout/bottom-bar/components/BottomBar'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; -import { useRecoilValue } from 'recoil'; const StyledLabel = styled.div` color: ${({ theme }) => theme.font.color.tertiary}; @@ -18,9 +19,9 @@ const StyledLabel = styled.div` padding-right: ${({ theme }) => theme.spacing(2)}; `; -export const ActionMenuBar = () => { - const contextStoreTargetedRecordIds = useRecoilValue( - contextStoreTargetedRecordIdsState, +export const RecordIndexActionMenuBar = () => { + const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2( + contextStoreNumberOfSelectedRecordsComponentState, ); const actionMenuId = useAvailableComponentInstanceIdOrThrow( @@ -31,23 +32,24 @@ export const ActionMenuBar = () => { actionMenuEntriesComponentSelector, ); - if (actionMenuEntries.length === 0) { + const pinnedEntries = actionMenuEntries.filter((entry) => entry.isPinned); + + if (contextStoreNumberOfSelectedRecords === 0) { return null; } return ( - - {contextStoreTargetedRecordIds.length} selected: - - {actionMenuEntries.map((entry, index) => ( - + {contextStoreNumberOfSelectedRecords} selected: + {pinnedEntries.map((entry, index) => ( + ))} + ); }; diff --git a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuBarAllActionsButton.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuBarAllActionsButton.tsx new file mode 100644 index 000000000000..336b4f734879 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuBarAllActionsButton.tsx @@ -0,0 +1,53 @@ +import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { IconLayoutSidebarRightExpand } from 'twenty-ui'; + +const StyledButton = styled.div` + border-radius: ${({ theme }) => theme.border.radius.sm}; + color: ${({ theme }) => theme.font.color.secondary}; + cursor: pointer; + display: flex; + justify-content: center; + + padding: ${({ theme }) => theme.spacing(2)}; + transition: background ${({ theme }) => theme.animation.duration.fast} ease; + user-select: none; + + &:hover { + background: ${({ theme }) => theme.background.tertiary}; + } +`; + +const StyledButtonLabel = styled.div` + font-weight: ${({ theme }) => theme.font.weight.medium}; + margin-left: ${({ theme }) => theme.spacing(1)}; +`; + +const StyledShortcutLabel = styled.div` + color: ${({ theme }) => theme.font.color.light}; + font-weight: ${({ theme }) => theme.font.weight.medium}; +`; + +const StyledSeparator = styled.div<{ size: 'sm' | 'md' }>` + background: ${({ theme }) => theme.border.color.light}; + height: ${({ theme, size }) => theme.spacing(size === 'sm' ? 4 : 8)}; + margin: 0 ${({ theme }) => theme.spacing(1)}; + width: 1px; +`; + +export const RecordIndexActionMenuBarAllActionsButton = () => { + const theme = useTheme(); + const { openCommandMenu } = useCommandMenu(); + return ( + <> + + openCommandMenu()}> + + All Actions + + ⌘K + + + ); +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/ActionMenuBarEntry.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuBarEntry.tsx similarity index 52% rename from packages/twenty-front/src/modules/action-menu/components/ActionMenuBarEntry.tsx rename to packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuBarEntry.tsx index 02802ec4a616..ffa52d20590b 100644 --- a/packages/twenty-front/src/modules/action-menu/components/ActionMenuBarEntry.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuBarEntry.tsx @@ -2,31 +2,24 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry'; -import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent'; -type ActionMenuBarEntryProps = { +type RecordIndexActionMenuBarEntryProps = { entry: ActionMenuEntry; }; -const StyledButton = styled.div<{ accent: MenuItemAccent }>` +const StyledButton = styled.div` border-radius: ${({ theme }) => theme.border.radius.sm}; - color: ${(props) => - props.accent === 'danger' - ? props.theme.color.red - : props.theme.font.color.secondary}; + color: ${({ theme }) => theme.font.color.secondary}; cursor: pointer; display: flex; justify-content: center; padding: ${({ theme }) => theme.spacing(2)}; - transition: background 0.1s ease; + transition: background ${({ theme }) => theme.animation.duration.fast} ease; user-select: none; &:hover { - background: ${({ theme, accent }) => - accent === 'danger' - ? theme.background.danger - : theme.background.tertiary}; + background: ${({ theme }) => theme.background.tertiary}; } `; @@ -35,13 +28,12 @@ const StyledButtonLabel = styled.div` margin-left: ${({ theme }) => theme.spacing(1)}; `; -export const ActionMenuBarEntry = ({ entry }: ActionMenuBarEntryProps) => { +export const RecordIndexActionMenuBarEntry = ({ + entry, +}: RecordIndexActionMenuBarEntryProps) => { const theme = useTheme(); return ( - entry.onClick?.()} - > + entry.onClick?.()}> {entry.Icon && } {entry.label} diff --git a/packages/twenty-front/src/modules/action-menu/components/ActionMenuDropdown.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuDropdown.tsx similarity index 67% rename from packages/twenty-front/src/modules/action-menu/components/ActionMenuDropdown.tsx rename to packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuDropdown.tsx index 18ebdac7667e..1a62cab35e7d 100644 --- a/packages/twenty-front/src/modules/action-menu/components/ActionMenuDropdown.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuDropdown.tsx @@ -3,15 +3,17 @@ import { useRecoilValue } from 'recoil'; import { PositionType } from '../types/PositionType'; -import { actionMenuDropdownPositionComponentState } from '@/action-menu/states/actionMenuDropdownPositionComponentState'; import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; +import { recordIndexActionMenuDropdownPositionComponentState } from '@/action-menu/states/recordIndexActionMenuDropdownPositionComponentState'; import { ActionMenuDropdownHotkeyScope } from '@/action-menu/types/ActionMenuDropdownHotKeyScope'; +import { getActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getActionMenuDropdownIdFromActionMenuId'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; -import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; +import { MenuItem } from 'twenty-ui'; type StyledContainerProps = { position: PositionType; @@ -34,7 +36,7 @@ const StyledContainerActionMenuDropdown = styled.div` width: auto; `; -export const ActionMenuDropdown = () => { +export const RecordIndexActionMenuDropdown = () => { const actionMenuEntries = useRecoilComponentValueV2( actionMenuEntriesComponentSelector, ); @@ -45,15 +47,11 @@ export const ActionMenuDropdown = () => { const actionMenuDropdownPosition = useRecoilValue( extractComponentState( - actionMenuDropdownPositionComponentState, - `action-menu-dropdown-${actionMenuId}`, + recordIndexActionMenuDropdownPositionComponentState, + getActionMenuDropdownIdFromActionMenuId(actionMenuId), ), ); - if (actionMenuEntries.length === 0) { - return null; - } - //TODO: remove this const width = actionMenuEntries.some( (actionMenuEntry) => actionMenuEntry.label === 'Remove from favorites', @@ -64,24 +62,28 @@ export const ActionMenuDropdown = () => { return ( ( - - ))} + dropdownComponents={ + + {actionMenuEntries.map((item, index) => ( + + ))} + + } /> ); diff --git a/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuEffect.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuEffect.tsx new file mode 100644 index 000000000000..3306d9fb0fb0 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/RecordIndexActionMenuEffect.tsx @@ -0,0 +1,69 @@ +import { useActionMenu } from '@/action-menu/hooks/useActionMenu'; +import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; +import { getActionBarIdFromActionMenuId } from '@/action-menu/utils/getActionBarIdFromActionMenuId'; +import { getActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getActionMenuDropdownIdFromActionMenuId'; +import { isCommandMenuOpenedState } from '@/command-menu/states/isCommandMenuOpenedState'; +import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; +import { isBottomBarOpenedComponentState } from '@/ui/layout/bottom-bar/states/isBottomBarOpenedComponentState'; +import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; +import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; +import { useEffect } from 'react'; +import { useRecoilValue } from 'recoil'; + +export const RecordIndexActionMenuEffect = () => { + const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2( + contextStoreNumberOfSelectedRecordsComponentState, + ); + + const actionMenuId = useAvailableComponentInstanceIdOrThrow( + ActionMenuComponentInstanceContext, + ); + + const { openActionBar, closeActionBar } = useActionMenu(actionMenuId); + + // Using closeActionBar here was causing a bug because it goes back to the + // previous hotkey scope, and we don't want that here. + const setIsBottomBarOpened = useSetRecoilComponentStateV2( + isBottomBarOpenedComponentState, + getActionBarIdFromActionMenuId(actionMenuId), + ); + + const isDropdownOpen = useRecoilValue( + extractComponentState( + isDropdownOpenComponentState, + getActionMenuDropdownIdFromActionMenuId(actionMenuId), + ), + ); + const { isRightDrawerOpen } = useRightDrawer(); + + const isCommandMenuOpened = useRecoilValue(isCommandMenuOpenedState); + + useEffect(() => { + if (contextStoreNumberOfSelectedRecords > 0 && !isDropdownOpen) { + // We only handle opening the ActionMenuBar here, not the Dropdown. + // The Dropdown is already managed by sync handlers for events like + // right-click to open and click outside to close. + openActionBar(); + } + if (contextStoreNumberOfSelectedRecords === 0 && isDropdownOpen) { + closeActionBar(); + } + }, [ + contextStoreNumberOfSelectedRecords, + openActionBar, + closeActionBar, + isDropdownOpen, + ]); + + useEffect(() => { + if (isRightDrawerOpen || isCommandMenuOpened) { + setIsBottomBarOpened(false); + } + }, [isRightDrawerOpen, isCommandMenuOpened, setIsBottomBarOpened]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/RecordShowActionMenu.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordShowActionMenu.tsx new file mode 100644 index 000000000000..d92ae5603deb --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/RecordShowActionMenu.tsx @@ -0,0 +1,56 @@ +import { GlobalActionMenuEntriesSetter } from '@/action-menu/actions/global-actions/components/GlobalActionMenuEntriesSetter'; +import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter'; +import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals'; +import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext'; + +import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { RecordShowPageBaseHeader } from '~/pages/object-record/RecordShowPageBaseHeader'; + +export const RecordShowActionMenu = ({ + isFavorite, + handleFavoriteButtonClick, + record, + objectMetadataItem, + objectNameSingular, +}: { + isFavorite: boolean; + handleFavoriteButtonClick: () => void; + record: ObjectRecord | undefined; + objectMetadataItem: ObjectMetadataItem; + objectNameSingular: string; +}) => { + const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2( + contextStoreCurrentObjectMetadataIdComponentState, + ); + + // TODO: refactor RecordShowPageBaseHeader to use the context store + + return ( + <> + {contextStoreCurrentObjectMetadataId && ( + {}, + }} + > + + + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/RecordShowRightDrawerActionMenu.tsx b/packages/twenty-front/src/modules/action-menu/components/RecordShowRightDrawerActionMenu.tsx new file mode 100644 index 000000000000..ba964f27a8d8 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/RecordShowRightDrawerActionMenu.tsx @@ -0,0 +1,32 @@ +import { GlobalActionMenuEntriesSetter } from '@/action-menu/actions/global-actions/components/GlobalActionMenuEntriesSetter'; +import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter'; +import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals'; +import { RightDrawerActionMenuDropdown } from '@/action-menu/components/RightDrawerActionMenuDropdown'; +import { ActionMenuContext } from '@/action-menu/contexts/ActionMenuContext'; + +import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; + +export const RecordShowRightDrawerActionMenu = () => { + const contextStoreCurrentObjectMetadataId = useRecoilComponentValueV2( + contextStoreCurrentObjectMetadataIdComponentState, + ); + + return ( + <> + {contextStoreCurrentObjectMetadataId && ( + {}, + }} + > + + + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/RightDrawerActionMenuDropdown.tsx b/packages/twenty-front/src/modules/action-menu/components/RightDrawerActionMenuDropdown.tsx new file mode 100644 index 000000000000..ed3c1ee2277f --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/RightDrawerActionMenuDropdown.tsx @@ -0,0 +1,87 @@ +import { actionMenuEntriesComponentSelector } from '@/action-menu/states/actionMenuEntriesComponentSelector'; +import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; +import { RightDrawerActionMenuDropdownHotkeyScope } from '@/action-menu/types/RightDrawerActionMenuDropdownHotkeyScope'; +import { getRightDrawerActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getRightDrawerActionMenuDropdownIdFromActionMenuId'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2'; +import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useTheme } from '@emotion/react'; +import { Key } from 'ts-key-enum'; +import { Button, MenuItem } from 'twenty-ui'; + +export const RightDrawerActionMenuDropdown = () => { + const actionMenuEntries = useRecoilComponentValueV2( + actionMenuEntriesComponentSelector, + ); + + const actionMenuId = useAvailableComponentInstanceIdOrThrow( + ActionMenuComponentInstanceContext, + ); + + const { closeDropdown, openDropdown } = useDropdownV2(); + + const theme = useTheme(); + + useScopedHotkeys( + [Key.Escape, 'ctrl+o,meta+o'], + () => { + closeDropdown( + getRightDrawerActionMenuDropdownIdFromActionMenuId(actionMenuId), + ); + }, + RightDrawerActionMenuDropdownHotkeyScope.RightDrawerActionMenuDropdown, + [closeDropdown], + ); + + useScopedHotkeys( + ['ctrl+o,meta+o'], + () => { + openDropdown( + getRightDrawerActionMenuDropdownIdFromActionMenuId(actionMenuId), + ); + }, + RightDrawerHotkeyScope.RightDrawer, + [openDropdown], + ); + + return ( + } + dropdownPlacement="top-end" + dropdownOffset={{ + y: parseInt(theme.spacing(2)), + }} + dropdownComponents={ + + {actionMenuEntries.map((item, index) => ( + { + closeDropdown( + getRightDrawerActionMenuDropdownIdFromActionMenuId( + actionMenuId, + ), + ); + item.onClick?.(); + }} + text={item.label} + /> + ))} + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/__stories__/ActionMenuBar.stories.tsx b/packages/twenty-front/src/modules/action-menu/components/__stories__/ActionMenuBar.stories.tsx deleted file mode 100644 index 34d709d1685d..000000000000 --- a/packages/twenty-front/src/modules/action-menu/components/__stories__/ActionMenuBar.stories.tsx +++ /dev/null @@ -1,111 +0,0 @@ -import { expect, jest } from '@storybook/jest'; -import { Meta, StoryObj } from '@storybook/react'; -import { RecoilRoot } from 'recoil'; - -import { ActionMenuBar } from '@/action-menu/components/ActionMenuBar'; -import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; -import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; -import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState'; -import { isBottomBarOpenedComponentState } from '@/ui/layout/bottom-bar/states/isBottomBarOpenedComponentState'; -import { userEvent, waitFor, within } from '@storybook/test'; -import { IconCheckbox, IconTrash } from 'twenty-ui'; - -const deleteMock = jest.fn(); -const markAsDoneMock = jest.fn(); - -const meta: Meta = { - title: 'Modules/ActionMenu/ActionMenuBar', - component: ActionMenuBar, - decorators: [ - (Story) => ( - { - set(contextStoreTargetedRecordIdsState, ['1', '2', '3']); - set( - actionMenuEntriesComponentState.atomFamily({ - instanceId: 'story-action-menu', - }), - new Map([ - [ - 'delete', - { - key: 'delete', - label: 'Delete', - position: 0, - Icon: IconTrash, - onClick: deleteMock, - }, - ], - [ - 'markAsDone', - { - key: 'markAsDone', - label: 'Mark as done', - position: 1, - Icon: IconCheckbox, - onClick: markAsDoneMock, - }, - ], - ]), - ); - set( - isBottomBarOpenedComponentState.atomFamily({ - instanceId: 'action-bar-story-action-menu', - }), - true, - ); - }} - > - - - - - ), - ], - args: { - actionMenuId: 'story-action-menu', - }, -}; - -export default meta; - -type Story = StoryObj; - -export const Default: Story = { - args: { - actionMenuId: 'story-action-menu', - }, -}; - -export const WithCustomSelection: Story = { - args: { - actionMenuId: 'story-action-menu', - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - const selectionText = await canvas.findByText('3 selected:'); - expect(selectionText).toBeInTheDocument(); - }, -}; - -export const WithButtonClicks: Story = { - args: { - actionMenuId: 'story-action-menu', - }, - play: async ({ canvasElement }) => { - const canvas = within(canvasElement); - - const deleteButton = await canvas.findByText('Delete'); - await userEvent.click(deleteButton); - - const markAsDoneButton = await canvas.findByText('Mark as done'); - await userEvent.click(markAsDoneButton); - - await waitFor(() => { - expect(deleteMock).toHaveBeenCalled(); - expect(markAsDoneMock).toHaveBeenCalled(); - }); - }, -}; diff --git a/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuBar.stories.tsx b/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuBar.stories.tsx new file mode 100644 index 000000000000..b7752d42f6f6 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuBar.stories.tsx @@ -0,0 +1,123 @@ +import { expect, jest } from '@storybook/jest'; +import { Meta, StoryObj } from '@storybook/react'; +import { RecoilRoot } from 'recoil'; + +import { RecordIndexActionMenuBar } from '@/action-menu/components/RecordIndexActionMenuBar'; +import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; +import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; +import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry'; +import { getActionBarIdFromActionMenuId } from '@/action-menu/utils/getActionBarIdFromActionMenuId'; +import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; +import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; +import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; +import { isBottomBarOpenedComponentState } from '@/ui/layout/bottom-bar/states/isBottomBarOpenedComponentState'; +import { userEvent, waitFor, within } from '@storybook/test'; +import { IconTrash, RouterDecorator } from 'twenty-ui'; + +const deleteMock = jest.fn(); + +const meta: Meta = { + title: 'Modules/ActionMenu/RecordIndexActionMenuBar', + component: RecordIndexActionMenuBar, + decorators: [ + RouterDecorator, + (Story) => ( + + { + set( + contextStoreTargetedRecordsRuleComponentState.atomFamily({ + instanceId: 'story-action-menu', + }), + { + mode: 'selection', + selectedRecordIds: ['1', '2', '3'], + }, + ); + + set( + contextStoreNumberOfSelectedRecordsComponentState.atomFamily({ + instanceId: 'story-action-menu', + }), + 3, + ); + + const map = new Map(); + + map.set('delete', { + isPinned: true, + type: 'standard', + key: 'delete', + label: 'Delete', + position: 0, + Icon: IconTrash, + onClick: deleteMock, + }); + + set( + actionMenuEntriesComponentState.atomFamily({ + instanceId: 'story-action-menu', + }), + map, + ); + + set( + isBottomBarOpenedComponentState.atomFamily({ + instanceId: getActionBarIdFromActionMenuId('story-action-menu'), + }), + true, + ); + }} + > + + + + + + ), + ], + args: { + actionMenuId: 'story-action-menu', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + actionMenuId: 'story-action-menu', + }, +}; + +export const WithCustomSelection: Story = { + args: { + actionMenuId: 'story-action-menu', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + const selectionText = await canvas.findByText('3 selected:'); + expect(selectionText).toBeInTheDocument(); + }, +}; + +export const WithButtonClicks: Story = { + args: { + actionMenuId: 'story-action-menu', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + const deleteButton = await canvas.findByText('Delete'); + await userEvent.click(deleteButton); + + await waitFor(() => { + expect(deleteMock).toHaveBeenCalled(); + }); + }, +}; diff --git a/packages/twenty-front/src/modules/action-menu/components/__stories__/ActionMenuBarEntry.stories.tsx b/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuBarEntry.stories.tsx similarity index 75% rename from packages/twenty-front/src/modules/action-menu/components/__stories__/ActionMenuBarEntry.stories.tsx rename to packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuBarEntry.stories.tsx index a9c7b26b8430..275058bc276c 100644 --- a/packages/twenty-front/src/modules/action-menu/components/__stories__/ActionMenuBarEntry.stories.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuBarEntry.stories.tsx @@ -1,19 +1,19 @@ +import { RecordIndexActionMenuBarEntry } from '@/action-menu/components/RecordIndexActionMenuBarEntry'; import { expect, jest } from '@storybook/jest'; import { Meta, StoryObj } from '@storybook/react'; import { userEvent, within } from '@storybook/testing-library'; import { ComponentDecorator, IconCheckbox, IconTrash } from 'twenty-ui'; -import { ActionMenuBarEntry } from '../ActionMenuBarEntry'; -const meta: Meta = { - title: 'Modules/ActionMenu/ActionMenuBarEntry', - component: ActionMenuBarEntry, +const meta: Meta = { + title: 'Modules/ActionMenu/RecordIndexActionMenuBarEntry', + component: RecordIndexActionMenuBarEntry, decorators: [ComponentDecorator], }; export default meta; -type Story = StoryObj; +type Story = StoryObj; const deleteMock = jest.fn(); const markAsDoneMock = jest.fn(); @@ -21,6 +21,7 @@ const markAsDoneMock = jest.fn(); export const Default: Story = { args: { entry: { + type: 'standard', key: 'delete', label: 'Delete', position: 0, @@ -33,6 +34,7 @@ export const Default: Story = { export const WithDangerAccent: Story = { args: { entry: { + type: 'standard', key: 'delete', label: 'Delete', position: 0, @@ -46,6 +48,7 @@ export const WithDangerAccent: Story = { export const WithInteraction: Story = { args: { entry: { + type: 'standard', key: 'markAsDone', label: 'Mark as done', position: 0, diff --git a/packages/twenty-front/src/modules/action-menu/components/__stories__/ActionMenuDropdown.stories.tsx b/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuDropdown.stories.tsx similarity index 63% rename from packages/twenty-front/src/modules/action-menu/components/__stories__/ActionMenuDropdown.stories.tsx rename to packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuDropdown.stories.tsx index 53a0714cee9e..e9ba359dfa2e 100644 --- a/packages/twenty-front/src/modules/action-menu/components/__stories__/ActionMenuDropdown.stories.tsx +++ b/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordIndexActionMenuDropdown.stories.tsx @@ -3,10 +3,11 @@ import { Meta, StoryObj } from '@storybook/react'; import { userEvent, within } from '@storybook/testing-library'; import { RecoilRoot } from 'recoil'; -import { ActionMenuDropdown } from '@/action-menu/components/ActionMenuDropdown'; -import { actionMenuDropdownPositionComponentState } from '@/action-menu/states/actionMenuDropdownPositionComponentState'; +import { RecordIndexActionMenuDropdown } from '@/action-menu/components/RecordIndexActionMenuDropdown'; import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; +import { recordIndexActionMenuDropdownPositionComponentState } from '@/action-menu/states/recordIndexActionMenuDropdownPositionComponentState'; +import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry'; import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; import { IconCheckbox, IconHeart, IconTrash } from 'twenty-ui'; @@ -15,57 +16,57 @@ const deleteMock = jest.fn(); const markAsDoneMock = jest.fn(); const addToFavoritesMock = jest.fn(); -const meta: Meta = { - title: 'Modules/ActionMenu/ActionMenuDropdown', - component: ActionMenuDropdown, +const meta: Meta = { + title: 'Modules/ActionMenu/RecordIndexActionMenuDropdown', + component: RecordIndexActionMenuDropdown, decorators: [ (Story) => ( { set( extractComponentState( - actionMenuDropdownPositionComponentState, + recordIndexActionMenuDropdownPositionComponentState, 'action-menu-dropdown-story', ), { x: 10, y: 10 }, ); + + const map = new Map(); + set( actionMenuEntriesComponentState.atomFamily({ instanceId: 'story-action-menu', }), - new Map([ - [ - 'delete', - { - key: 'delete', - label: 'Delete', - position: 0, - Icon: IconTrash, - onClick: deleteMock, - }, - ], - [ - 'markAsDone', - { - key: 'markAsDone', - label: 'Mark as done', - position: 1, - Icon: IconCheckbox, - onClick: markAsDoneMock, - }, - ], - [ - 'addToFavorites', - { - key: 'addToFavorites', - label: 'Add to favorites', - position: 2, - Icon: IconHeart, - onClick: addToFavoritesMock, - }, - ], - ]), + map, ); + + map.set('delete', { + type: 'standard', + key: 'delete', + label: 'Delete', + position: 0, + Icon: IconTrash, + onClick: deleteMock, + }); + + map.set('markAsDone', { + type: 'standard', + key: 'markAsDone', + label: 'Mark as done', + position: 1, + Icon: IconCheckbox, + onClick: markAsDoneMock, + }); + + map.set('addToFavorites', { + type: 'standard', + key: 'addToFavorites', + label: 'Add to favorites', + position: 2, + Icon: IconHeart, + onClick: addToFavoritesMock, + }); + set( extractComponentState( isDropdownOpenComponentState, @@ -87,7 +88,7 @@ const meta: Meta = { export default meta; -type Story = StoryObj; +type Story = StoryObj; export const Default: Story = { args: { diff --git a/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordShowActionMenuBar.stories.tsx b/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordShowActionMenuBar.stories.tsx new file mode 100644 index 000000000000..4a86046bd508 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/components/__stories__/RecordShowActionMenuBar.stories.tsx @@ -0,0 +1,140 @@ +import { expect, jest } from '@storybook/jest'; +import { Meta, StoryObj } from '@storybook/react'; +import { RecoilRoot } from 'recoil'; + +import { RightDrawerActionMenuDropdown } from '@/action-menu/components/RightDrawerActionMenuDropdown'; +import { actionMenuEntriesComponentState } from '@/action-menu/states/actionMenuEntriesComponentState'; +import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; +import { ActionMenuEntry } from '@/action-menu/types/ActionMenuEntry'; +import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; +import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; +import { userEvent, waitFor, within } from '@storybook/test'; +import { + ComponentDecorator, + IconFileExport, + IconHeart, + IconTrash, + MenuItemAccent, +} from 'twenty-ui'; + +const deleteMock = jest.fn(); +const addToFavoritesMock = jest.fn(); +const exportMock = jest.fn(); + +const meta: Meta = { + title: 'Modules/ActionMenu/RightDrawerActionMenuDropdown', + component: RightDrawerActionMenuDropdown, + decorators: [ + (Story) => ( + { + set( + contextStoreTargetedRecordsRuleComponentState.atomFamily({ + instanceId: 'story-action-menu', + }), + { + mode: 'selection', + selectedRecordIds: ['1'], + }, + ); + set( + contextStoreNumberOfSelectedRecordsComponentState.atomFamily({ + instanceId: 'story-action-menu', + }), + 1, + ); + + const map = new Map(); + + set( + actionMenuEntriesComponentState.atomFamily({ + instanceId: 'story-action-menu', + }), + map, + ); + + map.set('addToFavorites', { + type: 'standard', + key: 'addToFavorites', + label: 'Add to favorites', + position: 0, + Icon: IconHeart, + onClick: addToFavoritesMock, + }); + + map.set('export', { + type: 'standard', + key: 'export', + label: 'Export', + position: 1, + Icon: IconFileExport, + onClick: exportMock, + }); + + map.set('delete', { + type: 'standard', + key: 'delete', + label: 'Delete', + position: 2, + Icon: IconTrash, + onClick: deleteMock, + accent: 'danger' as MenuItemAccent, + }); + }} + > + + + + + ), + ComponentDecorator, + ], + args: { + actionMenuId: 'story-action-menu', + }, +}; + +export default meta; + +type Story = StoryObj; + +export const Default: Story = { + args: { + actionMenuId: 'story-action-menu', + }, +}; + +export const WithButtonClicks: Story = { + args: { + actionMenuId: 'story-action-menu', + }, + play: async ({ canvasElement }) => { + const canvas = within(canvasElement); + + let actionButton = await canvas.findByText('Actions'); + await userEvent.click(actionButton); + + const deleteButton = await canvas.findByText('Delete'); + await userEvent.click(deleteButton); + + actionButton = await canvas.findByText('Actions'); + await userEvent.click(actionButton); + + const addToFavoritesButton = await canvas.findByText('Add to favorites'); + await userEvent.click(addToFavoritesButton); + + actionButton = await canvas.findByText('Actions'); + await userEvent.click(actionButton); + + const exportButton = await canvas.findByText('Export'); + await userEvent.click(exportButton); + + await waitFor(() => { + expect(deleteMock).toHaveBeenCalled(); + expect(addToFavoritesMock).toHaveBeenCalled(); + expect(exportMock).toHaveBeenCalled(); + }); + }, +}; diff --git a/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContext.ts b/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContext.ts new file mode 100644 index 000000000000..0c1482f40b93 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/contexts/ActionMenuContext.ts @@ -0,0 +1,11 @@ +import { createContext } from 'react'; + +type ActionMenuContextType = { + isInRightDrawer: boolean; + onActionExecutedCallback: () => void; +}; + +export const ActionMenuContext = createContext({ + isInRightDrawer: false, + onActionExecutedCallback: () => {}, +}); diff --git a/packages/twenty-front/src/modules/action-menu/hooks/__tests__/useActionMenu.test.ts b/packages/twenty-front/src/modules/action-menu/hooks/__tests__/useActionMenu.test.ts index 0f37475adedc..aa00785961c0 100644 --- a/packages/twenty-front/src/modules/action-menu/hooks/__tests__/useActionMenu.test.ts +++ b/packages/twenty-front/src/modules/action-menu/hooks/__tests__/useActionMenu.test.ts @@ -1,3 +1,5 @@ +import { getActionBarIdFromActionMenuId } from '@/action-menu/utils/getActionBarIdFromActionMenuId'; +import { getActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getActionMenuDropdownIdFromActionMenuId'; import { renderHook } from '@testing-library/react'; import { act } from 'react'; import { useActionMenu } from '../useActionMenu'; @@ -23,6 +25,9 @@ jest.mock('@/ui/layout/dropdown/hooks/useDropdownV2', () => ({ describe('useActionMenu', () => { const actionMenuId = 'test-action-menu'; + const actionBarId = getActionBarIdFromActionMenuId(actionMenuId); + const actionMenuDropdownId = + getActionMenuDropdownIdFromActionMenuId(actionMenuId); it('should return the correct functions', () => { const { result } = renderHook(() => useActionMenu(actionMenuId)); @@ -40,10 +45,8 @@ describe('useActionMenu', () => { result.current.openActionMenuDropdown(); }); - expect(closeBottomBar).toHaveBeenCalledWith(`action-bar-${actionMenuId}`); - expect(openDropdown).toHaveBeenCalledWith( - `action-menu-dropdown-${actionMenuId}`, - ); + expect(closeBottomBar).toHaveBeenCalledWith(actionBarId); + expect(openDropdown).toHaveBeenCalledWith(actionMenuDropdownId); }); it('should call the correct functions when opening action bar', () => { @@ -53,10 +56,8 @@ describe('useActionMenu', () => { result.current.openActionBar(); }); - expect(closeDropdown).toHaveBeenCalledWith( - `action-menu-dropdown-${actionMenuId}`, - ); - expect(openBottomBar).toHaveBeenCalledWith(`action-bar-${actionMenuId}`); + expect(closeDropdown).toHaveBeenCalledWith(actionMenuDropdownId); + expect(openBottomBar).toHaveBeenCalledWith(actionBarId); }); it('should call the correct function when closing action menu dropdown', () => { @@ -66,9 +67,7 @@ describe('useActionMenu', () => { result.current.closeActionMenuDropdown(); }); - expect(closeDropdown).toHaveBeenCalledWith( - `action-menu-dropdown-${actionMenuId}`, - ); + expect(closeDropdown).toHaveBeenCalledWith(actionMenuDropdownId); }); it('should call the correct function when closing action bar', () => { @@ -78,6 +77,6 @@ describe('useActionMenu', () => { result.current.closeActionBar(); }); - expect(closeBottomBar).toHaveBeenCalledWith(`action-bar-${actionMenuId}`); + expect(closeBottomBar).toHaveBeenCalledWith(actionBarId); }); }); diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/__tests__/useExportTableData.test.ts b/packages/twenty-front/src/modules/action-menu/hooks/__tests__/useExportRecordData.test.ts similarity index 95% rename from packages/twenty-front/src/modules/object-record/record-index/options/hooks/__tests__/useExportTableData.test.ts rename to packages/twenty-front/src/modules/action-menu/hooks/__tests__/useExportRecordData.test.ts index 0494fd32f023..34f9aab740c9 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/__tests__/useExportTableData.test.ts +++ b/packages/twenty-front/src/modules/action-menu/hooks/__tests__/useExportRecordData.test.ts @@ -7,7 +7,7 @@ import { displayedExportProgress, download, generateCsv, -} from '../useExportTableData'; +} from '../useExportRecordData'; jest.useFakeTimers(); @@ -86,7 +86,7 @@ describe('csvDownloader', () => { describe('displayedExportProgress', () => { it.each([ - [undefined, undefined, 'percentage', 'Export'], + [undefined, undefined, 'percentage', 'Export View as CSV'], [20, 50, 'percentage', 'Export (40%)'], [0, 100, 'number', 'Export (0)'], [10, 10, 'percentage', 'Export (100%)'], @@ -96,7 +96,7 @@ describe('displayedExportProgress', () => { 'displays the export progress', (exportedRecordCount, totalRecordCount, displayType, expected) => { expect( - displayedExportProgress({ + displayedExportProgress('all', { exportedRecordCount, totalRecordCount, displayType: displayType as 'percentage' | 'number', diff --git a/packages/twenty-front/src/modules/action-menu/hooks/useActionMenu.ts b/packages/twenty-front/src/modules/action-menu/hooks/useActionMenu.ts index 881cadd694e1..109bd1ee5932 100644 --- a/packages/twenty-front/src/modules/action-menu/hooks/useActionMenu.ts +++ b/packages/twenty-front/src/modules/action-menu/hooks/useActionMenu.ts @@ -1,3 +1,5 @@ +import { getActionBarIdFromActionMenuId } from '@/action-menu/utils/getActionBarIdFromActionMenuId'; +import { getActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getActionMenuDropdownIdFromActionMenuId'; import { useBottomBar } from '@/ui/layout/bottom-bar/hooks/useBottomBar'; import { useDropdownV2 } from '@/ui/layout/dropdown/hooks/useDropdownV2'; @@ -5,22 +7,26 @@ export const useActionMenu = (actionMenuId: string) => { const { openDropdown, closeDropdown } = useDropdownV2(); const { openBottomBar, closeBottomBar } = useBottomBar(); + const actionBarId = getActionBarIdFromActionMenuId(actionMenuId); + const actionMenuDropdownId = + getActionMenuDropdownIdFromActionMenuId(actionMenuId); + const openActionMenuDropdown = () => { - closeBottomBar(`action-bar-${actionMenuId}`); - openDropdown(`action-menu-dropdown-${actionMenuId}`); + closeBottomBar(actionBarId); + openDropdown(actionMenuDropdownId); }; const openActionBar = () => { - closeDropdown(`action-menu-dropdown-${actionMenuId}`); - openBottomBar(`action-bar-${actionMenuId}`); + closeDropdown(actionMenuDropdownId); + openBottomBar(actionBarId); }; const closeActionMenuDropdown = () => { - closeDropdown(`action-menu-dropdown-${actionMenuId}`); + closeDropdown(actionMenuDropdownId); }; const closeActionBar = () => { - closeBottomBar(`action-bar-${actionMenuId}`); + closeBottomBar(actionBarId); }; return { diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts b/packages/twenty-front/src/modules/action-menu/hooks/useExportRecordData.ts similarity index 87% rename from packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts rename to packages/twenty-front/src/modules/action-menu/hooks/useExportRecordData.ts index 532b8e0aa59b..7ae40eb4d656 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/hooks/useExportTableData.ts +++ b/packages/twenty-front/src/modules/action-menu/hooks/useExportRecordData.ts @@ -4,10 +4,11 @@ import { useMemo } from 'react'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; import { EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE } from '@/object-record/record-index/options/constants/ExportTableDataDefaultPageSize'; import { useProcessRecordsForCSVExport } from '@/object-record/record-index/options/hooks/useProcessRecordsForCSVExport'; + import { - useTableData, - UseTableDataOptions, -} from '@/object-record/record-index/options/hooks/useTableData'; + UseRecordDataOptions, + useRecordData, +} from '@/object-record/record-index/options/hooks/useRecordData'; import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { RelationDefinitionType } from '~/generated-metadata/graphql'; @@ -107,9 +108,12 @@ const percentage = (part: number, whole: number): number => { return Math.round((part / whole) * 100); }; -export const displayedExportProgress = (progress?: ExportProgress): string => { +export const displayedExportProgress = ( + mode: 'all' | 'selection' = 'all', + progress?: ExportProgress, +): string => { if (isUndefinedOrNull(progress?.exportedRecordCount)) { - return 'Export'; + return mode === 'all' ? 'Export View as CSV' : 'Export Selection as CSV'; } if ( @@ -134,21 +138,22 @@ const downloader = (mimeType: string, generator: GenerateExport) => { export const csvDownloader = downloader('text/csv', generateCsv); -type UseExportTableDataOptions = Omit & { +type UseExportTableDataOptions = Omit & { filename: string; }; -export const useExportTableData = ({ +export const useExportRecordData = ({ delayMs, filename, maximumRequests = 100, - objectNameSingular, + objectMetadataItem, pageSize = EXPORT_TABLE_DATA_DEFAULT_PAGE_SIZE, recordIndexId, viewType, }: UseExportTableDataOptions) => { - const { processRecordsForCSVExport } = - useProcessRecordsForCSVExport(objectNameSingular); + const { processRecordsForCSVExport } = useProcessRecordsForCSVExport( + objectMetadataItem.nameSingular, + ); const downloadCsv = useMemo( () => @@ -160,10 +165,10 @@ export const useExportTableData = ({ [filename, processRecordsForCSVExport], ); - const { getTableData: download, progress } = useTableData({ + const { getTableData: download, progress } = useRecordData({ delayMs, maximumRequests, - objectNameSingular, + objectMetadataItem, pageSize, recordIndexId, callback: downloadCsv, diff --git a/packages/twenty-front/src/modules/action-menu/states/actionMenuEntriesComponentSelector.ts b/packages/twenty-front/src/modules/action-menu/states/actionMenuEntriesComponentSelector.ts index 921f97b38f52..9a87deb49435 100644 --- a/packages/twenty-front/src/modules/action-menu/states/actionMenuEntriesComponentSelector.ts +++ b/packages/twenty-front/src/modules/action-menu/states/actionMenuEntriesComponentSelector.ts @@ -8,7 +8,7 @@ export const actionMenuEntriesComponentSelector = createComponentSelectorV2< ActionMenuEntry[] >({ key: 'actionMenuEntriesComponentSelector', - instanceContext: ActionMenuComponentInstanceContext, + componentInstanceContext: ActionMenuComponentInstanceContext, get: ({ instanceId }) => ({ get }) => diff --git a/packages/twenty-front/src/modules/action-menu/states/actionMenuDropdownPositionComponentState.ts b/packages/twenty-front/src/modules/action-menu/states/recordIndexActionMenuDropdownPositionComponentState.ts similarity index 67% rename from packages/twenty-front/src/modules/action-menu/states/actionMenuDropdownPositionComponentState.ts rename to packages/twenty-front/src/modules/action-menu/states/recordIndexActionMenuDropdownPositionComponentState.ts index f2f8f06b1372..4be2f83c5bac 100644 --- a/packages/twenty-front/src/modules/action-menu/states/actionMenuDropdownPositionComponentState.ts +++ b/packages/twenty-front/src/modules/action-menu/states/recordIndexActionMenuDropdownPositionComponentState.ts @@ -1,9 +1,9 @@ import { PositionType } from '@/action-menu/types/PositionType'; import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; -export const actionMenuDropdownPositionComponentState = +export const recordIndexActionMenuDropdownPositionComponentState = createComponentState({ - key: 'actionMenuDropdownPositionComponentState', + key: 'recordIndexActionMenuDropdownPositionComponentState', defaultValue: { x: null, y: null, diff --git a/packages/twenty-front/src/modules/action-menu/types/ActionMenuEntry.ts b/packages/twenty-front/src/modules/action-menu/types/ActionMenuEntry.ts index 4fe180955238..568bd3a33b83 100644 --- a/packages/twenty-front/src/modules/action-menu/types/ActionMenuEntry.ts +++ b/packages/twenty-front/src/modules/action-menu/types/ActionMenuEntry.ts @@ -1,13 +1,13 @@ import { MouseEvent, ReactNode } from 'react'; -import { IconComponent } from 'twenty-ui'; - -import { MenuItemAccent } from '@/ui/navigation/menu-item/types/MenuItemAccent'; +import { IconComponent, MenuItemAccent } from 'twenty-ui'; export type ActionMenuEntry = { + type: 'standard' | 'workflow-run'; key: string; label: string; position: number; Icon: IconComponent; + isPinned?: boolean; accent?: MenuItemAccent; onClick?: (event?: MouseEvent) => void; ConfirmationModal?: ReactNode; diff --git a/packages/twenty-front/src/modules/action-menu/types/RightDrawerActionMenuDropdownHotkeyScope.ts b/packages/twenty-front/src/modules/action-menu/types/RightDrawerActionMenuDropdownHotkeyScope.ts new file mode 100644 index 000000000000..74505c320f16 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/types/RightDrawerActionMenuDropdownHotkeyScope.ts @@ -0,0 +1,3 @@ +export enum RightDrawerActionMenuDropdownHotkeyScope { + RightDrawerActionMenuDropdown = 'right-drawer-action-menu-dropdown', +} diff --git a/packages/twenty-front/src/modules/action-menu/utils/__tests__/getActionBarIdFromActionMenuId.test.ts b/packages/twenty-front/src/modules/action-menu/utils/__tests__/getActionBarIdFromActionMenuId.test.ts new file mode 100644 index 000000000000..bf9990909926 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/utils/__tests__/getActionBarIdFromActionMenuId.test.ts @@ -0,0 +1,9 @@ +import { getActionBarIdFromActionMenuId } from '@/action-menu/utils/getActionBarIdFromActionMenuId'; + +describe('getActionBarIdFromActionMenuId', () => { + it('should return the correct action bar id', () => { + expect(getActionBarIdFromActionMenuId('action-menu-id')).toBe( + 'action-bar-action-menu-id', + ); + }); +}); diff --git a/packages/twenty-front/src/modules/action-menu/utils/__tests__/getActionMenuDropdownIdFromActionMenuId.test.ts b/packages/twenty-front/src/modules/action-menu/utils/__tests__/getActionMenuDropdownIdFromActionMenuId.test.ts new file mode 100644 index 000000000000..55fdebed4812 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/utils/__tests__/getActionMenuDropdownIdFromActionMenuId.test.ts @@ -0,0 +1,9 @@ +import { getActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getActionMenuDropdownIdFromActionMenuId'; + +describe('getActionMenuDropdownIdFromActionMenuId', () => { + it('should return the correct action menu dropdown id', () => { + expect(getActionMenuDropdownIdFromActionMenuId('action-menu-id')).toBe( + 'action-menu-dropdown-action-menu-id', + ); + }); +}); diff --git a/packages/twenty-front/src/modules/action-menu/utils/__tests__/getActionMenuIdFromRecordIndexId.test.ts b/packages/twenty-front/src/modules/action-menu/utils/__tests__/getActionMenuIdFromRecordIndexId.test.ts new file mode 100644 index 000000000000..ff547aa6d6e6 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/utils/__tests__/getActionMenuIdFromRecordIndexId.test.ts @@ -0,0 +1,9 @@ +import { getActionMenuIdFromRecordIndexId } from '@/action-menu/utils/getActionMenuIdFromRecordIndexId'; + +describe('getActionMenuIdFromRecordIndexId', () => { + it('should return the correct action menu id', () => { + expect(getActionMenuIdFromRecordIndexId('record-index-id')).toBe( + 'action-menu-record-index-record-index-id', + ); + }); +}); diff --git a/packages/twenty-front/src/modules/action-menu/utils/__tests__/getRightDrawerActionMenuDropdownIdFromActionMenuId.test.ts b/packages/twenty-front/src/modules/action-menu/utils/__tests__/getRightDrawerActionMenuDropdownIdFromActionMenuId.test.ts new file mode 100644 index 000000000000..209bdf99afb6 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/utils/__tests__/getRightDrawerActionMenuDropdownIdFromActionMenuId.test.ts @@ -0,0 +1,9 @@ +import { getRightDrawerActionMenuDropdownIdFromActionMenuId } from '../getRightDrawerActionMenuDropdownIdFromActionMenuId'; + +describe('getRightDrawerActionMenuDropdownIdFromActionMenuId', () => { + it('should return the right drawer action menu dropdown id', () => { + expect( + getRightDrawerActionMenuDropdownIdFromActionMenuId('action-menu-id'), + ).toBe('right-drawer-action-menu-dropdown-action-menu-id'); + }); +}); diff --git a/packages/twenty-front/src/modules/action-menu/utils/getActionBarIdFromActionMenuId.ts b/packages/twenty-front/src/modules/action-menu/utils/getActionBarIdFromActionMenuId.ts new file mode 100644 index 000000000000..2005c48308b5 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/utils/getActionBarIdFromActionMenuId.ts @@ -0,0 +1,3 @@ +export const getActionBarIdFromActionMenuId = (actionMenuId: string) => { + return `action-bar-${actionMenuId}`; +}; diff --git a/packages/twenty-front/src/modules/action-menu/utils/getActionMenuDropdownIdFromActionMenuId.ts b/packages/twenty-front/src/modules/action-menu/utils/getActionMenuDropdownIdFromActionMenuId.ts new file mode 100644 index 000000000000..40b21dbe6c84 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/utils/getActionMenuDropdownIdFromActionMenuId.ts @@ -0,0 +1,5 @@ +export const getActionMenuDropdownIdFromActionMenuId = ( + actionMenuId: string, +) => { + return `action-menu-dropdown-${actionMenuId}`; +}; diff --git a/packages/twenty-front/src/modules/action-menu/utils/getActionMenuIdFromRecordIndexId.ts b/packages/twenty-front/src/modules/action-menu/utils/getActionMenuIdFromRecordIndexId.ts new file mode 100644 index 000000000000..883beab4fd9e --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/utils/getActionMenuIdFromRecordIndexId.ts @@ -0,0 +1,3 @@ +export const getActionMenuIdFromRecordIndexId = (recordIndexId: string) => { + return `action-menu-record-index-${recordIndexId}`; +}; diff --git a/packages/twenty-front/src/modules/action-menu/utils/getRightDrawerActionMenuDropdownIdFromActionMenuId.ts b/packages/twenty-front/src/modules/action-menu/utils/getRightDrawerActionMenuDropdownIdFromActionMenuId.ts new file mode 100644 index 000000000000..8e1d49133d50 --- /dev/null +++ b/packages/twenty-front/src/modules/action-menu/utils/getRightDrawerActionMenuDropdownIdFromActionMenuId.ts @@ -0,0 +1,5 @@ +export const getRightDrawerActionMenuDropdownIdFromActionMenuId = ( + actionMenuId: string, +) => { + return `right-drawer-action-menu-dropdown-${actionMenuId}`; +}; diff --git a/packages/twenty-front/src/modules/activities/blocks/FileBlock.tsx b/packages/twenty-front/src/modules/activities/blocks/components/FileBlock.tsx similarity index 92% rename from packages/twenty-front/src/modules/activities/blocks/FileBlock.tsx rename to packages/twenty-front/src/modules/activities/blocks/components/FileBlock.tsx index 680ac551f662..5c8de03869d5 100644 --- a/packages/twenty-front/src/modules/activities/blocks/FileBlock.tsx +++ b/packages/twenty-front/src/modules/activities/blocks/components/FileBlock.tsx @@ -3,14 +3,14 @@ import styled from '@emotion/styled'; import { isNonEmptyString } from '@sniptt/guards'; import { ChangeEvent, useRef } from 'react'; -import { Button } from '@/ui/input/button/components/Button'; import { AppThemeProvider } from '@/ui/theme/components/AppThemeProvider'; +import { Button } from 'twenty-ui'; import { isDefined } from '~/utils/isDefined'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; -import { AttachmentIcon } from '../files/components/AttachmentIcon'; -import { AttachmentType } from '../files/types/Attachment'; -import { getFileType } from '../files/utils/getFileType'; +import { AttachmentIcon } from '../../files/components/AttachmentIcon'; +import { AttachmentType } from '../../files/types/Attachment'; +import { getFileType } from '../../files/utils/getFileType'; const StyledFileInput = styled.input` display: none; diff --git a/packages/twenty-front/src/modules/activities/blocks/schema.ts b/packages/twenty-front/src/modules/activities/blocks/constants/Schema.ts similarity index 57% rename from packages/twenty-front/src/modules/activities/blocks/schema.ts rename to packages/twenty-front/src/modules/activities/blocks/constants/Schema.ts index d6ea82eac19b..2584f3c7b8c5 100644 --- a/packages/twenty-front/src/modules/activities/blocks/schema.ts +++ b/packages/twenty-front/src/modules/activities/blocks/constants/Schema.ts @@ -1,8 +1,8 @@ import { BlockNoteSchema, defaultBlockSpecs } from '@blocknote/core'; -import { FileBlock } from './FileBlock'; +import { FileBlock } from '../components/FileBlock'; -export const blockSchema = BlockNoteSchema.create({ +export const BLOCK_SCHEMA = BlockNoteSchema.create({ blockSpecs: { ...defaultBlockSpecs, file: FileBlock, diff --git a/packages/twenty-front/src/modules/activities/blocks/slashMenu.tsx b/packages/twenty-front/src/modules/activities/blocks/utils/getSlashMenu.ts similarity index 90% rename from packages/twenty-front/src/modules/activities/blocks/slashMenu.tsx rename to packages/twenty-front/src/modules/activities/blocks/utils/getSlashMenu.ts index 34a161bab747..760a778b57b2 100644 --- a/packages/twenty-front/src/modules/activities/blocks/slashMenu.tsx +++ b/packages/twenty-front/src/modules/activities/blocks/utils/getSlashMenu.ts @@ -18,7 +18,7 @@ import { import { SuggestionItem } from '@/ui/input/editor/components/CustomSlashMenu'; -import { blockSchema } from './schema'; +import { BLOCK_SCHEMA } from '../constants/Schema'; const Icons: Record = { 'Heading 1': IconH1, @@ -35,7 +35,7 @@ const Icons: Record = { Emoji: IconMoodSmile, }; -export const getSlashMenu = (editor: typeof blockSchema.BlockNoteEditor) => { +export const getSlashMenu = (editor: typeof BLOCK_SCHEMA.BlockNoteEditor) => { const items: SuggestionItem[] = [ ...getDefaultReactSlashMenuItems(editor).map((x) => ({ ...x, diff --git a/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx b/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx index 360eccbbcc2a..0be4c731adda 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/Calendar.tsx @@ -1,27 +1,27 @@ import styled from '@emotion/styled'; import { format, getYear } from 'date-fns'; -import { H3Title } from 'twenty-ui'; +import { + AnimatedPlaceholder, + AnimatedPlaceholderEmptyContainer, + AnimatedPlaceholderEmptySubTitle, + AnimatedPlaceholderEmptyTextContainer, + AnimatedPlaceholderEmptyTitle, + EMPTY_PLACEHOLDER_TRANSITION_PROPS, + H3Title, + Section, +} from 'twenty-ui'; import { CalendarMonthCard } from '@/activities/calendar/components/CalendarMonthCard'; import { TIMELINE_CALENDAR_EVENTS_DEFAULT_PAGE_SIZE } from '@/activities/calendar/constants/Calendar'; import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext'; +import { getTimelineCalendarEventsFromCompanyId } from '@/activities/calendar/graphql/queries/getTimelineCalendarEventsFromCompanyId'; +import { getTimelineCalendarEventsFromPersonId } from '@/activities/calendar/graphql/queries/getTimelineCalendarEventsFromPersonId'; import { useCalendarEvents } from '@/activities/calendar/hooks/useCalendarEvents'; -import { getTimelineCalendarEventsFromCompanyId } from '@/activities/calendar/queries/getTimelineCalendarEventsFromCompanyId'; -import { getTimelineCalendarEventsFromPersonId } from '@/activities/calendar/queries/getTimelineCalendarEventsFromPersonId'; import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader'; import { SkeletonLoader } from '@/activities/components/SkeletonLoader'; import { useCustomResolver } from '@/activities/hooks/useCustomResolver'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder'; -import { - AnimatedPlaceholderEmptyContainer, - AnimatedPlaceholderEmptySubTitle, - AnimatedPlaceholderEmptyTextContainer, - AnimatedPlaceholderEmptyTitle, - EMPTY_PLACEHOLDER_TRANSITION_PROPS, -} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled'; -import { Section } from '@/ui/layout/section/components/Section'; import { TimelineCalendarEventsWithTotal } from '~/generated/graphql'; const StyledContainer = styled.div` @@ -38,6 +38,10 @@ const StyledYear = styled.span` color: ${({ theme }) => theme.font.color.light}; `; +const StyledTitleContainer = styled.div` + margin-bottom: ${({ theme }) => theme.spacing(4)}; +`; + export const Calendar = ({ targetableObject, }: { @@ -131,14 +135,16 @@ export const Calendar = ({ return (
- - {monthLabel} - {isLastMonthOfYear && {year}} - - } - /> + + + {monthLabel} + {isLastMonthOfYear && {year}} + + } + /> +
); diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx index ca656f3b52c1..1ccf037161a3 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarDayCardContent.tsx @@ -4,7 +4,7 @@ import { differenceInSeconds, endOfDay, format } from 'date-fns'; import { CalendarEventRow } from '@/activities/calendar/components/CalendarEventRow'; import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate'; -import { CardContent } from '@/ui/layout/card/components/CardContent'; +import { CardContent } from 'twenty-ui'; import { TimelineCalendarEvent } from '~/generated/graphql'; type CalendarDayCardContentProps = { diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx index cb9548f8bafa..5874898e0040 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarEventRow.tsx @@ -9,6 +9,8 @@ import { IconArrowRight, IconLock, isDefined, + Card, + CardContent, } from 'twenty-ui'; import { CalendarCurrentEventCursor } from '@/activities/calendar/components/CalendarCurrentEventCursor'; @@ -18,8 +20,6 @@ import { getCalendarEventEndDate } from '@/activities/calendar/utils/getCalendar import { getCalendarEventStartDate } from '@/activities/calendar/utils/getCalendarEventStartDate'; import { hasCalendarEventEnded } from '@/activities/calendar/utils/hasCalendarEventEnded'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; -import { Card } from '@/ui/layout/card/components/Card'; -import { CardContent } from '@/ui/layout/card/components/CardContent'; import { CalendarChannelVisibility, TimelineCalendarEvent, diff --git a/packages/twenty-front/src/modules/activities/calendar/components/CalendarMonthCard.tsx b/packages/twenty-front/src/modules/activities/calendar/components/CalendarMonthCard.tsx index f42f2e35f594..97ca1d0297e6 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/CalendarMonthCard.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/CalendarMonthCard.tsx @@ -2,7 +2,7 @@ import { useContext } from 'react'; import { CalendarDayCardContent } from '@/activities/calendar/components/CalendarDayCardContent'; import { CalendarContext } from '@/activities/calendar/contexts/CalendarContext'; -import { Card } from '@/ui/layout/card/components/Card'; +import { Card } from 'twenty-ui'; type CalendarMonthCardProps = { dayTimes: number[]; diff --git a/packages/twenty-front/src/modules/activities/calendar/components/__stories__/Calendar.stories.tsx b/packages/twenty-front/src/modules/activities/calendar/components/__stories__/Calendar.stories.tsx index b2732df86544..eb4aa38eda72 100644 --- a/packages/twenty-front/src/modules/activities/calendar/components/__stories__/Calendar.stories.tsx +++ b/packages/twenty-front/src/modules/activities/calendar/components/__stories__/Calendar.stories.tsx @@ -1,10 +1,10 @@ import { getOperationName } from '@apollo/client/utilities'; import { Meta, StoryObj } from '@storybook/react'; -import { graphql, HttpResponse } from 'msw'; +import { HttpResponse, graphql } from 'msw'; import { ComponentDecorator } from 'twenty-ui'; import { Calendar } from '@/activities/calendar/components/Calendar'; -import { getTimelineCalendarEventsFromCompanyId } from '@/activities/calendar/queries/getTimelineCalendarEventsFromCompanyId'; +import { getTimelineCalendarEventsFromCompanyId } from '@/activities/calendar/graphql/queries/getTimelineCalendarEventsFromCompanyId'; import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; import { graphqlMocks } from '~/testing/graphqlMocks'; diff --git a/packages/twenty-front/src/modules/activities/calendar/queries/fragments/timelineCalendarEventFragment.ts b/packages/twenty-front/src/modules/activities/calendar/graphql/queries/fragments/timelineCalendarEventFragment.ts similarity index 84% rename from packages/twenty-front/src/modules/activities/calendar/queries/fragments/timelineCalendarEventFragment.ts rename to packages/twenty-front/src/modules/activities/calendar/graphql/queries/fragments/timelineCalendarEventFragment.ts index d98c7bcf81b2..eb152294ccea 100644 --- a/packages/twenty-front/src/modules/activities/calendar/queries/fragments/timelineCalendarEventFragment.ts +++ b/packages/twenty-front/src/modules/activities/calendar/graphql/queries/fragments/timelineCalendarEventFragment.ts @@ -1,7 +1,6 @@ +import { timelineCalendarEventParticipantFragment } from '@/activities/calendar/graphql/queries/fragments/timelineCalendarEventParticipantFragment'; import { gql } from '@apollo/client'; -import { timelineCalendarEventParticipantFragment } from '@/activities/calendar/queries/fragments/timelineCalendarEventParticipantFragment'; - export const timelineCalendarEventFragment = gql` fragment TimelineCalendarEventFragment on TimelineCalendarEvent { id diff --git a/packages/twenty-front/src/modules/activities/calendar/queries/fragments/timelineCalendarEventParticipantFragment.ts b/packages/twenty-front/src/modules/activities/calendar/graphql/queries/fragments/timelineCalendarEventParticipantFragment.ts similarity index 100% rename from packages/twenty-front/src/modules/activities/calendar/queries/fragments/timelineCalendarEventParticipantFragment.ts rename to packages/twenty-front/src/modules/activities/calendar/graphql/queries/fragments/timelineCalendarEventParticipantFragment.ts diff --git a/packages/twenty-front/src/modules/activities/calendar/queries/fragments/timelineCalendarEventWithTotalFragment.ts b/packages/twenty-front/src/modules/activities/calendar/graphql/queries/fragments/timelineCalendarEventWithTotalFragment.ts similarity index 86% rename from packages/twenty-front/src/modules/activities/calendar/queries/fragments/timelineCalendarEventWithTotalFragment.ts rename to packages/twenty-front/src/modules/activities/calendar/graphql/queries/fragments/timelineCalendarEventWithTotalFragment.ts index 2a76f0f7fa41..58de733417c1 100644 --- a/packages/twenty-front/src/modules/activities/calendar/queries/fragments/timelineCalendarEventWithTotalFragment.ts +++ b/packages/twenty-front/src/modules/activities/calendar/graphql/queries/fragments/timelineCalendarEventWithTotalFragment.ts @@ -1,7 +1,6 @@ +import { timelineCalendarEventFragment } from '@/activities/calendar/graphql/queries/fragments/timelineCalendarEventFragment'; import { gql } from '@apollo/client'; -import { timelineCalendarEventFragment } from '@/activities/calendar/queries/fragments/timelineCalendarEventFragment'; - export const timelineCalendarEventWithTotalFragment = gql` fragment TimelineCalendarEventsWithTotalFragment on TimelineCalendarEventsWithTotal { totalNumberOfCalendarEvents diff --git a/packages/twenty-front/src/modules/activities/calendar/queries/getTimelineCalendarEventsFromCompanyId.ts b/packages/twenty-front/src/modules/activities/calendar/graphql/queries/getTimelineCalendarEventsFromCompanyId.ts similarity index 86% rename from packages/twenty-front/src/modules/activities/calendar/queries/getTimelineCalendarEventsFromCompanyId.ts rename to packages/twenty-front/src/modules/activities/calendar/graphql/queries/getTimelineCalendarEventsFromCompanyId.ts index e454e67452f3..c43d197e43ff 100644 --- a/packages/twenty-front/src/modules/activities/calendar/queries/getTimelineCalendarEventsFromCompanyId.ts +++ b/packages/twenty-front/src/modules/activities/calendar/graphql/queries/getTimelineCalendarEventsFromCompanyId.ts @@ -1,7 +1,6 @@ +import { timelineCalendarEventWithTotalFragment } from '@/activities/calendar/graphql/queries/fragments/timelineCalendarEventWithTotalFragment'; import { gql } from '@apollo/client'; -import { timelineCalendarEventWithTotalFragment } from '@/activities/calendar/queries/fragments/timelineCalendarEventWithTotalFragment'; - export const getTimelineCalendarEventsFromCompanyId = gql` query GetTimelineCalendarEventsFromCompanyId( $companyId: UUID! diff --git a/packages/twenty-front/src/modules/activities/calendar/queries/getTimelineCalendarEventsFromPersonId.ts b/packages/twenty-front/src/modules/activities/calendar/graphql/queries/getTimelineCalendarEventsFromPersonId.ts similarity index 86% rename from packages/twenty-front/src/modules/activities/calendar/queries/getTimelineCalendarEventsFromPersonId.ts rename to packages/twenty-front/src/modules/activities/calendar/graphql/queries/getTimelineCalendarEventsFromPersonId.ts index 7d9f221fbc78..3285fb475d23 100644 --- a/packages/twenty-front/src/modules/activities/calendar/queries/getTimelineCalendarEventsFromPersonId.ts +++ b/packages/twenty-front/src/modules/activities/calendar/graphql/queries/getTimelineCalendarEventsFromPersonId.ts @@ -1,7 +1,6 @@ +import { timelineCalendarEventWithTotalFragment } from '@/activities/calendar/graphql/queries/fragments/timelineCalendarEventWithTotalFragment'; import { gql } from '@apollo/client'; -import { timelineCalendarEventWithTotalFragment } from '@/activities/calendar/queries/fragments/timelineCalendarEventWithTotalFragment'; - export const getTimelineCalendarEventsFromPersonId = gql` query GetTimelineCalendarEventsFromPersonId( $personId: UUID! diff --git a/packages/twenty-front/src/modules/activities/components/ActivityList.tsx b/packages/twenty-front/src/modules/activities/components/ActivityList.tsx index b8b8b2f61d5f..0bb0e367e5db 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityList.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityList.tsx @@ -1,5 +1,5 @@ -import { Card } from '@/ui/layout/card/components/Card'; import styled from '@emotion/styled'; +import { Card } from 'twenty-ui'; const StyledList = styled(Card)` & > :not(:last-child) { diff --git a/packages/twenty-front/src/modules/activities/components/ActivityRow.tsx b/packages/twenty-front/src/modules/activities/components/ActivityRow.tsx index 00fdbb1a68f8..075758fc8208 100644 --- a/packages/twenty-front/src/modules/activities/components/ActivityRow.tsx +++ b/packages/twenty-front/src/modules/activities/components/ActivityRow.tsx @@ -1,5 +1,5 @@ -import { CardContent } from '@/ui/layout/card/components/CardContent'; import styled from '@emotion/styled'; +import { CardContent } from 'twenty-ui'; import React from 'react'; const StyledRowContent = styled(CardContent)<{ diff --git a/packages/twenty-front/src/modules/activities/components/RichTextEditor.tsx b/packages/twenty-front/src/modules/activities/components/RichTextEditor.tsx index c6842395f64f..bff2bef3cb1b 100644 --- a/packages/twenty-front/src/modules/activities/components/RichTextEditor.tsx +++ b/packages/twenty-front/src/modules/activities/components/RichTextEditor.tsx @@ -1,13 +1,12 @@ import { useApolloClient } from '@apollo/client'; import { useCreateBlockNote } from '@blocknote/react'; import { isArray, isNonEmptyString } from '@sniptt/guards'; -import { ClipboardEvent, useCallback, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useRecoilCallback, useRecoilState } from 'recoil'; import { Key } from 'ts-key-enum'; import { useDebouncedCallback } from 'use-debounce'; import { v4 } from 'uuid'; -import { blockSchema } from '@/activities/blocks/schema'; import { useUpsertActivity } from '@/activities/hooks/useUpsertActivity'; import { activityBodyFamilyState } from '@/activities/states/activityBodyFamilyState'; import { activityTitleHasBeenSetFamilyState } from '@/activities/states/activityTitleHasBeenSetFamilyState'; @@ -21,19 +20,16 @@ import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDraw import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { isNonTextWritingKey } from '@/ui/utilities/hotkey/utils/isNonTextWritingKey'; -import { FileFolder, useUploadFileMutation } from '~/generated/graphql'; import { isDefined } from '~/utils/isDefined'; -import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; - -import { getFileType } from '../files/utils/getFileType'; +import { BLOCK_SCHEMA } from '@/activities/blocks/constants/Schema'; +import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile'; import { Note } from '@/activities/types/Note'; import { Task } from '@/activities/types/Task'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import '@blocknote/core/fonts/inter.css'; import '@blocknote/mantine/style.css'; import '@blocknote/react/style.css'; -import { getFileAbsoluteURI } from '~/utils/file/getFileAbsoluteURI'; type RichTextEditorProps = { activityId: string; @@ -121,22 +117,13 @@ export const RichTextEditor = ({ canCreateActivityState, ); - const [uploadFile] = useUploadFileMutation(); + const { uploadAttachmentFile } = useUploadAttachmentFile(); - const handleUploadAttachment = async (file: File): Promise => { - if (isUndefinedOrNull(file)) { - return ''; - } - const result = await uploadFile({ - variables: { - file, - fileFolder: FileFolder.Attachment, - }, + const handleUploadAttachment = async (file: File) => { + return await uploadAttachmentFile(file, { + id: activityId, + targetObjectNameSingular: activityObjectNameSingular, }); - if (!result?.data?.uploadFile) { - throw new Error("Couldn't upload Image"); - } - return getFileAbsoluteURI(result.data.uploadFile); }; const prepareBody = (newStringifiedBody: string) => { @@ -152,8 +139,6 @@ export const RichTextEditor = ({ const imageProps = block.props; const imageUrl = new URL(imageProps.url); - imageUrl.searchParams.delete('token'); - return { ...block, props: { @@ -284,65 +269,19 @@ export const RichTextEditor = ({ } }, [activity, activityBody]); + const handleEditorBuiltInUploadFile = async (file: File) => { + const { attachementAbsoluteURL } = await handleUploadAttachment(file); + + return attachementAbsoluteURL; + }; + const editor = useCreateBlockNote({ initialContent: initialBody, domAttributes: { editor: { class: 'editor' } }, - schema: blockSchema, - uploadFile: handleUploadAttachment, + schema: BLOCK_SCHEMA, + uploadFile: handleEditorBuiltInUploadFile, }); - const handleImagePaste = async (event: ClipboardEvent) => { - const clipboardItems = event.clipboardData?.items; - - if (isDefined(clipboardItems)) { - for (let i = 0; i < clipboardItems.length; i++) { - if (clipboardItems[i].kind === 'file') { - const isImage = clipboardItems[i].type.match('^image/'); - const pastedFile = clipboardItems[i].getAsFile(); - if (!pastedFile) { - return; - } - - const attachmentUrl = await handleUploadAttachment(pastedFile); - - if (!attachmentUrl) { - return; - } - - if (isDefined(isImage)) { - editor?.insertBlocks( - [ - { - type: 'image', - props: { - url: attachmentUrl, - }, - }, - ], - editor?.getTextCursorPosition().block, - 'after', - ); - } else { - editor?.insertBlocks( - [ - { - type: 'file', - props: { - url: attachmentUrl, - fileType: getFileType(pastedFile.name), - name: pastedFile.name, - }, - }, - ], - editor?.getTextCursorPosition().block, - 'after', - ); - } - } - } - } - }; - useScopedHotkeys( Key.Escape, () => { @@ -411,6 +350,10 @@ export const RichTextEditor = ({ editor.focus(); }, RightDrawerHotkeyScope.RightDrawer, + [], + { + preventDefault: false, + }, ); const handleBlockEditorFocus = () => { @@ -427,7 +370,6 @@ export const RichTextEditor = ({ diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailLoader.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailLoader.tsx index f497344606eb..444685852b7c 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailLoader.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailLoader.tsx @@ -1,10 +1,10 @@ -import { Loader } from '@/ui/feedback/loader/components/Loader'; -import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder'; import { + AnimatedPlaceholder, AnimatedPlaceholderEmptyContainer, AnimatedPlaceholderEmptyTextContainer, AnimatedPlaceholderEmptyTitle, -} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled'; + Loader, +} from 'twenty-ui'; export const EmailLoader = ({ loadingText }: { loadingText?: string }) => ( diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadBottomBar.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadBottomBar.tsx index c010da2cd1fb..902039f45241 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadBottomBar.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadBottomBar.tsx @@ -1,7 +1,5 @@ import styled from '@emotion/styled'; -import { IconArrowBackUp, IconUserCircle } from 'twenty-ui'; - -import { Button } from '@/ui/input/button/components/Button'; +import { Button, IconArrowBackUp, IconUserCircle } from 'twenty-ui'; const StyledThreadBottomBar = styled.div` align-items: center; diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageBody.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageBody.tsx index 128197c6093d..b31fa54fe214 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageBody.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreadMessageBody.tsx @@ -1,8 +1,6 @@ -import React from 'react'; import styled from '@emotion/styled'; import { motion } from 'framer-motion'; - -import { AnimatedEaseInOut } from '@/ui/utilities/animation/components/AnimatedEaseInOut'; +import { AnimatedEaseInOut } from 'twenty-ui'; const StyledThreadMessageBody = styled(motion.div)` color: ${({ theme }) => theme.font.color.primary}; diff --git a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx b/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx index 8a3eef7ea33f..38f150dcc30d 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/EmailThreads.tsx @@ -1,25 +1,26 @@ import styled from '@emotion/styled'; -import { H1Title, H1TitleFontColor } from 'twenty-ui'; +import { + AnimatedPlaceholder, + AnimatedPlaceholderEmptyContainer, + AnimatedPlaceholderEmptySubTitle, + AnimatedPlaceholderEmptyTextContainer, + AnimatedPlaceholderEmptyTitle, + EMPTY_PLACEHOLDER_TRANSITION_PROPS, + H1Title, + H1TitleFontColor, + Section, +} from 'twenty-ui'; import { ActivityList } from '@/activities/components/ActivityList'; import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader'; import { SkeletonLoader } from '@/activities/components/SkeletonLoader'; import { EmailThreadPreview } from '@/activities/emails/components/EmailThreadPreview'; import { TIMELINE_THREADS_DEFAULT_PAGE_SIZE } from '@/activities/emails/constants/Messaging'; -import { getTimelineThreadsFromCompanyId } from '@/activities/emails/queries/getTimelineThreadsFromCompanyId'; -import { getTimelineThreadsFromPersonId } from '@/activities/emails/queries/getTimelineThreadsFromPersonId'; +import { getTimelineThreadsFromCompanyId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId'; +import { getTimelineThreadsFromPersonId } from '@/activities/emails/graphql/queries/getTimelineThreadsFromPersonId'; import { useCustomResolver } from '@/activities/hooks/useCustomResolver'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder'; -import { - AnimatedPlaceholderEmptyContainer, - AnimatedPlaceholderEmptySubTitle, - AnimatedPlaceholderEmptyTextContainer, - AnimatedPlaceholderEmptyTitle, - EMPTY_PLACEHOLDER_TRANSITION_PROPS, -} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled'; -import { Section } from '@/ui/layout/section/components/Section'; import { TimelineThread, TimelineThreadsWithTotal } from '~/generated/graphql'; const StyledContainer = styled.div` diff --git a/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscriberDropdownAddSubscriberMenuItem.tsx b/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscriberDropdownAddSubscriberMenuItem.tsx index b56cdc0809cb..04de8d7184c6 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscriberDropdownAddSubscriberMenuItem.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscriberDropdownAddSubscriberMenuItem.tsx @@ -1,9 +1,8 @@ import { MessageThreadSubscriber } from '@/activities/emails/types/MessageThreadSubscriber'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; -import { MenuItemAvatar } from '@/ui/navigation/menu-item/components/MenuItemAvatar'; import { WorkspaceMember } from '@/workspace-member/types/WorkspaceMember'; -import { IconPlus } from 'twenty-ui'; +import { IconPlus, MenuItemAvatar } from 'twenty-ui'; export const MessageThreadSubscriberDropdownAddSubscriberMenuItem = ({ workspaceMember, diff --git a/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscribersDropdownButton.tsx b/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscribersDropdownButton.tsx index 7f7c42e7ae26..2fdf1d634fdd 100644 --- a/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscribersDropdownButton.tsx +++ b/packages/twenty-front/src/modules/activities/emails/components/MessageThreadSubscribersDropdownButton.tsx @@ -1,5 +1,5 @@ import { offset } from '@floating-ui/react'; -import { IconMinus, IconPlus } from 'twenty-ui'; +import { IconMinus, IconPlus, MenuItem, MenuItemAvatar } from 'twenty-ui'; import { MessageThreadSubscriberDropdownAddSubscriber } from '@/activities/emails/components/MessageThreadSubscriberDropdownAddSubscriber'; import { MessageThreadSubscribersChip } from '@/activities/emails/components/MessageThreadSubscribersChip'; @@ -10,8 +10,6 @@ import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/Drop import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { useListenRightDrawerClose } from '@/ui/layout/right-drawer/hooks/useListenRightDrawerClose'; -import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; -import { MenuItemAvatar } from '@/ui/navigation/menu-item/components/MenuItemAvatar'; import { useState } from 'react'; export const MESSAGE_THREAD_SUBSCRIBER_DROPDOWN_ID = diff --git a/packages/twenty-front/src/modules/activities/emails/queries/__tests__/getTimelineThreadsFromCompanyId.test.ts b/packages/twenty-front/src/modules/activities/emails/graphql/queries/__tests__/getTimelineThreadsFromCompanyId.test.ts similarity index 100% rename from packages/twenty-front/src/modules/activities/emails/queries/__tests__/getTimelineThreadsFromCompanyId.test.ts rename to packages/twenty-front/src/modules/activities/emails/graphql/queries/__tests__/getTimelineThreadsFromCompanyId.test.ts diff --git a/packages/twenty-front/src/modules/activities/emails/queries/__tests__/getTimelineThreadsFromPersonId.test.ts b/packages/twenty-front/src/modules/activities/emails/graphql/queries/__tests__/getTimelineThreadsFromPersonId.test.ts similarity index 100% rename from packages/twenty-front/src/modules/activities/emails/queries/__tests__/getTimelineThreadsFromPersonId.test.ts rename to packages/twenty-front/src/modules/activities/emails/graphql/queries/__tests__/getTimelineThreadsFromPersonId.test.ts diff --git a/packages/twenty-front/src/modules/activities/emails/queries/fragments/participantFragment.ts b/packages/twenty-front/src/modules/activities/emails/graphql/queries/fragments/participantFragment.ts similarity index 100% rename from packages/twenty-front/src/modules/activities/emails/queries/fragments/participantFragment.ts rename to packages/twenty-front/src/modules/activities/emails/graphql/queries/fragments/participantFragment.ts diff --git a/packages/twenty-front/src/modules/activities/emails/queries/fragments/timelineThreadFragment.ts b/packages/twenty-front/src/modules/activities/emails/graphql/queries/fragments/timelineThreadFragment.ts similarity index 80% rename from packages/twenty-front/src/modules/activities/emails/queries/fragments/timelineThreadFragment.ts rename to packages/twenty-front/src/modules/activities/emails/graphql/queries/fragments/timelineThreadFragment.ts index d5728f23efaf..7d8f8ab9c351 100644 --- a/packages/twenty-front/src/modules/activities/emails/queries/fragments/timelineThreadFragment.ts +++ b/packages/twenty-front/src/modules/activities/emails/graphql/queries/fragments/timelineThreadFragment.ts @@ -1,6 +1,6 @@ import { gql } from '@apollo/client'; -import { participantFragment } from '@/activities/emails/queries/fragments/participantFragment'; +import { participantFragment } from '@/activities/emails/graphql/queries/fragments/participantFragment'; export const timelineThreadFragment = gql` fragment TimelineThreadFragment on TimelineThread { diff --git a/packages/twenty-front/src/modules/activities/emails/queries/fragments/timelineThreadWithTotalFragment.ts b/packages/twenty-front/src/modules/activities/emails/graphql/queries/fragments/timelineThreadWithTotalFragment.ts similarity index 71% rename from packages/twenty-front/src/modules/activities/emails/queries/fragments/timelineThreadWithTotalFragment.ts rename to packages/twenty-front/src/modules/activities/emails/graphql/queries/fragments/timelineThreadWithTotalFragment.ts index 89dc76d19b11..b5a8f351dacb 100644 --- a/packages/twenty-front/src/modules/activities/emails/queries/fragments/timelineThreadWithTotalFragment.ts +++ b/packages/twenty-front/src/modules/activities/emails/graphql/queries/fragments/timelineThreadWithTotalFragment.ts @@ -1,7 +1,6 @@ +import { timelineThreadFragment } from '@/activities/emails/graphql/queries/fragments/timelineThreadFragment'; import { gql } from '@apollo/client'; -import { timelineThreadFragment } from '@/activities/emails/queries/fragments/timelineThreadFragment'; - export const timelineThreadWithTotalFragment = gql` fragment TimelineThreadsWithTotalFragment on TimelineThreadsWithTotal { totalNumberOfThreads diff --git a/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromCompanyId.ts b/packages/twenty-front/src/modules/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId.ts similarity index 88% rename from packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromCompanyId.ts rename to packages/twenty-front/src/modules/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId.ts index 589905550c7d..e999e676b94e 100644 --- a/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromCompanyId.ts +++ b/packages/twenty-front/src/modules/activities/emails/graphql/queries/getTimelineThreadsFromCompanyId.ts @@ -1,7 +1,6 @@ +import { timelineThreadWithTotalFragment } from '@/activities/emails/graphql/queries/fragments/timelineThreadWithTotalFragment'; import { gql } from '@apollo/client'; -import { timelineThreadWithTotalFragment } from '@/activities/emails/queries/fragments/timelineThreadWithTotalFragment'; - export const getTimelineThreadsFromCompanyId = gql` query GetTimelineThreadsFromCompanyId( $companyId: UUID! diff --git a/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromPersonId.ts b/packages/twenty-front/src/modules/activities/emails/graphql/queries/getTimelineThreadsFromPersonId.ts similarity index 87% rename from packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromPersonId.ts rename to packages/twenty-front/src/modules/activities/emails/graphql/queries/getTimelineThreadsFromPersonId.ts index 84cd7053791e..7f1877f112eb 100644 --- a/packages/twenty-front/src/modules/activities/emails/queries/getTimelineThreadsFromPersonId.ts +++ b/packages/twenty-front/src/modules/activities/emails/graphql/queries/getTimelineThreadsFromPersonId.ts @@ -1,6 +1,6 @@ import { gql } from '@apollo/client'; -import { timelineThreadWithTotalFragment } from '@/activities/emails/queries/fragments/timelineThreadWithTotalFragment'; +import { timelineThreadWithTotalFragment } from '@/activities/emails/graphql/queries/fragments/timelineThreadWithTotalFragment'; export const getTimelineThreadsFromPersonId = gql` query GetTimelineThreadsFromPersonId( diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/components/IntermediaryMessages.tsx b/packages/twenty-front/src/modules/activities/emails/right-drawer/components/IntermediaryMessages.tsx index 3ef96d57310e..aee31f1e1b46 100644 --- a/packages/twenty-front/src/modules/activities/emails/right-drawer/components/IntermediaryMessages.tsx +++ b/packages/twenty-front/src/modules/activities/emails/right-drawer/components/IntermediaryMessages.tsx @@ -1,10 +1,9 @@ import styled from '@emotion/styled'; import { useState } from 'react'; -import { IconArrowsVertical } from 'twenty-ui'; +import { Button, IconArrowsVertical } from 'twenty-ui'; import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMessage'; import { EmailThreadMessageWithSender } from '@/activities/emails/types/EmailThreadMessageWithSender'; -import { Button } from '@/ui/input/button/components/Button'; const StyledButtonContainer = styled.div` border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; diff --git a/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx b/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx index 42367f2f9adf..1ac7fb65194c 100644 --- a/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx +++ b/packages/twenty-front/src/modules/activities/emails/right-drawer/components/RightDrawerEmailThread.tsx @@ -9,12 +9,11 @@ import { EmailThreadMessage } from '@/activities/emails/components/EmailThreadMe import { IntermediaryMessages } from '@/activities/emails/right-drawer/components/IntermediaryMessages'; import { useRightDrawerEmailThread } from '@/activities/emails/right-drawer/hooks/useRightDrawerEmailThread'; import { emailThreadIdWhenEmailThreadWasClosedState } from '@/activities/emails/states/lastViewableEmailThreadIdState'; -import { Button } from '@/ui/input/button/components/Button'; import { RIGHT_DRAWER_CLICK_OUTSIDE_LISTENER_ID } from '@/ui/layout/right-drawer/constants/RightDrawerClickOutsideListener'; import { messageThreadState } from '@/ui/layout/right-drawer/states/messageThreadState'; import { useClickOutsideListener } from '@/ui/utilities/pointer-event/hooks/useClickOutsideListener'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; -import { IconArrowBackUp } from 'twenty-ui'; +import { Button, IconArrowBackUp } from 'twenty-ui'; const StyledWrapper = styled.div` display: flex; diff --git a/packages/twenty-front/src/modules/activities/files/components/AttachmentDropdown.tsx b/packages/twenty-front/src/modules/activities/files/components/AttachmentDropdown.tsx index 7dc80b171729..18f926d6425a 100644 --- a/packages/twenty-front/src/modules/activities/files/components/AttachmentDropdown.tsx +++ b/packages/twenty-front/src/modules/activities/files/components/AttachmentDropdown.tsx @@ -3,14 +3,13 @@ import { IconDownload, IconPencil, IconTrash, + LightIconButton, + MenuItem, } from 'twenty-ui'; -import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; -import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; -import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; type AttachmentDropdownProps = { onDownload: () => void; @@ -50,27 +49,26 @@ export const AttachmentDropdown = ({ clickableComponent={ } + dropdownMenuWidth={160} dropdownComponents={ - - - - - - - + + + + + } dropdownHotkeyScope={{ scope: dropdownId, diff --git a/packages/twenty-front/src/modules/activities/files/components/Attachments.tsx b/packages/twenty-front/src/modules/activities/files/components/Attachments.tsx index c0bf6908e207..1cc6de187a09 100644 --- a/packages/twenty-front/src/modules/activities/files/components/Attachments.tsx +++ b/packages/twenty-front/src/modules/activities/files/components/Attachments.tsx @@ -1,6 +1,15 @@ import styled from '@emotion/styled'; import { ChangeEvent, useRef, useState } from 'react'; -import { IconPlus } from 'twenty-ui'; +import { + AnimatedPlaceholder, + AnimatedPlaceholderEmptyContainer, + AnimatedPlaceholderEmptySubTitle, + AnimatedPlaceholderEmptyTextContainer, + AnimatedPlaceholderEmptyTitle, + Button, + EMPTY_PLACEHOLDER_TRANSITION_PROPS, + IconPlus, +} from 'twenty-ui'; import { SkeletonLoader } from '@/activities/components/SkeletonLoader'; import { AttachmentList } from '@/activities/files/components/AttachmentList'; @@ -8,15 +17,6 @@ import { DropZone } from '@/activities/files/components/DropZone'; import { useAttachments } from '@/activities/files/hooks/useAttachments'; import { useUploadAttachmentFile } from '@/activities/files/hooks/useUploadAttachmentFile'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; -import { Button } from '@/ui/input/button/components/Button'; -import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder'; -import { - AnimatedPlaceholderEmptyContainer, - AnimatedPlaceholderEmptySubTitle, - AnimatedPlaceholderEmptyTextContainer, - AnimatedPlaceholderEmptyTitle, - EMPTY_PLACEHOLDER_TRANSITION_PROPS, -} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled'; import { isDefined } from '~/utils/isDefined'; const StyledAttachmentsContainer = styled.div` diff --git a/packages/twenty-front/src/modules/activities/files/hooks/useUploadAttachmentFile.tsx b/packages/twenty-front/src/modules/activities/files/hooks/useUploadAttachmentFile.tsx index 301a324668a7..bbd8d6152af4 100644 --- a/packages/twenty-front/src/modules/activities/files/hooks/useUploadAttachmentFile.tsx +++ b/packages/twenty-front/src/modules/activities/files/hooks/useUploadAttachmentFile.tsx @@ -7,7 +7,9 @@ import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivi import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; +import { isNonEmptyString } from '@sniptt/guards'; import { FileFolder, useUploadFileMutation } from '~/generated/graphql'; +import { getFileAbsoluteURI } from '~/utils/file/getFileAbsoluteURI'; // Note: This is probably not the right way to do this. export const computePathWithoutToken = (attachmentPath: string): string => { @@ -36,8 +38,8 @@ export const useUploadAttachmentFile = () => { const attachmentPath = result?.data?.uploadFile; - if (!attachmentPath) { - return; + if (!isNonEmptyString(attachmentPath)) { + throw new Error("Couldn't upload the attachment."); } const targetableObjectFieldIdName = getActivityTargetObjectFieldIdName({ @@ -55,6 +57,10 @@ export const useUploadAttachmentFile = () => { } as Partial; await createOneAttachment(attachmentToCreate); + + const attachementAbsoluteURL = getFileAbsoluteURI(attachmentPath); + + return { attachementAbsoluteURL }; }; return { uploadAttachmentFile }; diff --git a/packages/twenty-front/src/modules/activities/files/types/Attachment.ts b/packages/twenty-front/src/modules/activities/files/types/Attachment.ts index e37bcf8f92ad..20cb73984cf8 100644 --- a/packages/twenty-front/src/modules/activities/files/types/Attachment.ts +++ b/packages/twenty-front/src/modules/activities/files/types/Attachment.ts @@ -5,7 +5,6 @@ export type Attachment = { type: AttachmentType; companyId: string; personId: string; - activityId: string; authorId: string; createdAt: string; __typename: string; diff --git a/packages/twenty-front/src/modules/activities/graphql/operation-signatures/factories/findActivityTargetsOperationSignatureFactory.ts b/packages/twenty-front/src/modules/activities/graphql/operation-signatures/factories/findActivityTargetsOperationSignatureFactory.ts index f71407866c4f..b9e18aaf42fe 100644 --- a/packages/twenty-front/src/modules/activities/graphql/operation-signatures/factories/findActivityTargetsOperationSignatureFactory.ts +++ b/packages/twenty-front/src/modules/activities/graphql/operation-signatures/factories/findActivityTargetsOperationSignatureFactory.ts @@ -19,8 +19,6 @@ export const findActivityTargetsOperationSignatureFactory: RecordGqlOperationSig __typename: true, createdAt: true, updatedAt: true, - activity: true, - activityId: true, ...generateActivityTargetMorphFieldKeys(objectMetadataItems), }, }); diff --git a/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivities.test.tsx b/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivities.test.tsx index 23229f581696..10e2351fd5ae 100644 --- a/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivities.test.tsx +++ b/packages/twenty-front/src/modules/activities/hooks/__tests__/useActivities.test.tsx @@ -18,7 +18,6 @@ const mockActivityTarget = { updatedAt: '2021-08-03T19:20:06.000Z', createdAt: '2021-08-03T19:20:06.000Z', personId: '1', - activityId: '234', companyId: '1', id: '123', }; diff --git a/packages/twenty-front/src/modules/activities/hooks/__tests__/useCreateActivityInDB.test.tsx b/packages/twenty-front/src/modules/activities/hooks/__tests__/useCreateActivityInDB.test.tsx index baddb1029bda..8907d6db06e1 100644 --- a/packages/twenty-front/src/modules/activities/hooks/__tests__/useCreateActivityInDB.test.tsx +++ b/packages/twenty-front/src/modules/activities/hooks/__tests__/useCreateActivityInDB.test.tsx @@ -37,7 +37,6 @@ const mocks: MockedResponse[] = [ edges { node { __typename - activityId authorId companyId createdAt diff --git a/packages/twenty-front/src/modules/activities/hooks/useRefreshShowPageFindManyActivitiesQueries.ts b/packages/twenty-front/src/modules/activities/hooks/useRefreshShowPageFindManyActivitiesQueries.ts index e054014aa2be..e3295d57e991 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useRefreshShowPageFindManyActivitiesQueries.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useRefreshShowPageFindManyActivitiesQueries.ts @@ -1,7 +1,7 @@ import { useRecoilValue } from 'recoil'; import { usePrepareFindManyActivitiesQuery } from '@/activities/hooks/usePrepareFindManyActivitiesQuery'; -import { objectShowPageTargetableObjectState } from '@/activities/timelineActivities/states/objectShowPageTargetableObjectIdState'; +import { objectShowPageTargetableObjectState } from '@/activities/timeline-activities/states/objectShowPageTargetableObjectIdState'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { isDefined } from '~/utils/isDefined'; diff --git a/packages/twenty-front/src/modules/activities/hooks/useUpsertActivity.ts b/packages/twenty-front/src/modules/activities/hooks/useUpsertActivity.ts index 4c1c6e2499fc..76e91ec8bf53 100644 --- a/packages/twenty-front/src/modules/activities/hooks/useUpsertActivity.ts +++ b/packages/twenty-front/src/modules/activities/hooks/useUpsertActivity.ts @@ -4,7 +4,7 @@ import { useCreateActivityInDB } from '@/activities/hooks/useCreateActivityInDB' import { useRefreshShowPageFindManyActivitiesQueries } from '@/activities/hooks/useRefreshShowPageFindManyActivitiesQueries'; import { isActivityInCreateModeState } from '@/activities/states/isActivityInCreateModeState'; import { isUpsertingActivityInDBState } from '@/activities/states/isCreatingActivityInDBState'; -import { objectShowPageTargetableObjectState } from '@/activities/timelineActivities/states/objectShowPageTargetableObjectIdState'; +import { objectShowPageTargetableObjectState } from '@/activities/timeline-activities/states/objectShowPageTargetableObjectIdState'; import { Note } from '@/activities/types/Note'; import { Task } from '@/activities/types/Task'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; diff --git a/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetInlineCellEditMode.tsx b/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetInlineCellEditMode.tsx index 4ed47174a550..7719f4576bed 100644 --- a/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetInlineCellEditMode.tsx +++ b/packages/twenty-front/src/modules/activities/inline-cell/components/ActivityTargetInlineCellEditMode.tsx @@ -27,6 +27,7 @@ import { import { useInlineCell } from '@/object-record/record-inline-cell/hooks/useInlineCell'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { ActivityTargetInlineCellEditModeMultiRecordsEffect } from '@/object-record/relation-picker/components/ActivityTargetInlineCellEditModeMultiRecordsEffect'; +import { ActivityTargetInlineCellEditModeMultiRecordsSearchFilterEffect } from '@/object-record/relation-picker/components/ActivityTargetInlineCellEditModeMultiRecordsSearchFilterEffect'; import { MultiRecordSelect } from '@/object-record/relation-picker/components/MultiRecordSelect'; import { RelationPickerScope } from '@/object-record/relation-picker/scopes/RelationPickerScope'; import { prefillRecord } from '@/object-record/utils/prefillRecord'; @@ -185,10 +186,18 @@ export const ActivityTargetInlineCellEditMode = ({ activityObjectNameSingular === CoreObjectNameSingular.Task ? activity.id : null, + task: + activityObjectNameSingular === CoreObjectNameSingular.Task + ? activity + : null, noteId: activityObjectNameSingular === CoreObjectNameSingular.Note ? activity.id : null, + note: + activityObjectNameSingular === CoreObjectNameSingular.Note + ? activity + : null, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), [fieldName]: record.record, @@ -279,6 +288,7 @@ export const ActivityTargetInlineCellEditMode = ({ + diff --git a/packages/twenty-front/src/modules/activities/notes/components/Notes.tsx b/packages/twenty-front/src/modules/activities/notes/components/Notes.tsx index b26624e1a9d6..c99a8e57c5c7 100644 --- a/packages/twenty-front/src/modules/activities/notes/components/Notes.tsx +++ b/packages/twenty-front/src/modules/activities/notes/components/Notes.tsx @@ -1,21 +1,20 @@ -import styled from '@emotion/styled'; -import { IconPlus } from 'twenty-ui'; - import { SkeletonLoader } from '@/activities/components/SkeletonLoader'; import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { NoteList } from '@/activities/notes/components/NoteList'; import { useNotes } from '@/activities/notes/hooks/useNotes'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { Button } from '@/ui/input/button/components/Button'; -import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder'; +import styled from '@emotion/styled'; import { + AnimatedPlaceholder, AnimatedPlaceholderEmptyContainer, AnimatedPlaceholderEmptySubTitle, AnimatedPlaceholderEmptyTextContainer, AnimatedPlaceholderEmptyTitle, + Button, EMPTY_PLACEHOLDER_TRANSITION_PROPS, -} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled'; + IconPlus, +} from 'twenty-ui'; const StyledNotesContainer = styled.div` display: flex; diff --git a/packages/twenty-front/src/modules/activities/notes/hooks/useNotes.ts b/packages/twenty-front/src/modules/activities/notes/hooks/useNotes.ts index 04aa231d4c84..1a8248543747 100644 --- a/packages/twenty-front/src/modules/activities/notes/hooks/useNotes.ts +++ b/packages/twenty-front/src/modules/activities/notes/hooks/useNotes.ts @@ -3,7 +3,7 @@ import { useRecoilState } from 'recoil'; import { useActivities } from '@/activities/hooks/useActivities'; import { currentNotesQueryVariablesState } from '@/activities/notes/states/currentNotesQueryVariablesState'; -import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timelineActivities/constants/FindManyTimelineActivitiesOrderBy'; +import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timeline-activities/constants/FindManyTimelineActivitiesOrderBy'; import { Note } from '@/activities/types/Note'; import { RecordGqlOperationVariables } from '@/object-record/graphql/types/RecordGqlOperationVariables'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; diff --git a/packages/twenty-front/src/modules/activities/tasks/components/AddTaskButton.tsx b/packages/twenty-front/src/modules/activities/tasks/components/AddTaskButton.tsx index 869e2f1adef6..0004f351d669 100644 --- a/packages/twenty-front/src/modules/activities/tasks/components/AddTaskButton.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/components/AddTaskButton.tsx @@ -1,10 +1,9 @@ import { isNonEmptyArray } from '@sniptt/guards'; -import { IconPlus } from 'twenty-ui'; +import { Button, IconPlus } from 'twenty-ui'; import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { Button } from '@/ui/input/button/components/Button'; export const AddTaskButton = ({ activityTargetableObjects, diff --git a/packages/twenty-front/src/modules/activities/tasks/components/PageAddTaskButton.tsx b/packages/twenty-front/src/modules/activities/tasks/components/PageAddTaskButton.tsx index 265780072a7d..a7168d35ec85 100644 --- a/packages/twenty-front/src/modules/activities/tasks/components/PageAddTaskButton.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/components/PageAddTaskButton.tsx @@ -1,6 +1,6 @@ import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { PageAddButton } from '@/ui/layout/page/PageAddButton'; +import { PageAddButton } from '@/ui/layout/page/components/PageAddButton'; export const PageAddTaskButton = () => { const openCreateActivity = useOpenCreateActivityDrawer({ diff --git a/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx b/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx index 16ebbec0f38a..b32d17ed6f5f 100644 --- a/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/components/TaskGroups.tsx @@ -1,25 +1,24 @@ import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; -import { IconPlus } from 'twenty-ui'; - -import { SkeletonLoader } from '@/activities/components/SkeletonLoader'; -import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; -import { TASKS_TAB_LIST_COMPONENT_ID } from '@/activities/tasks/constants/TasksTabListComponentId'; -import { useTasks } from '@/activities/tasks/hooks/useTasks'; -import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; -import { Button } from '@/ui/input/button/components/Button'; -import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder'; import { + AnimatedPlaceholder, AnimatedPlaceholderEmptyContainer, AnimatedPlaceholderEmptySubTitle, AnimatedPlaceholderEmptyTextContainer, AnimatedPlaceholderEmptyTitle, + Button, EMPTY_PLACEHOLDER_TRANSITION_PROPS, -} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled'; -import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; + IconPlus, +} from 'twenty-ui'; +import { SkeletonLoader } from '@/activities/components/SkeletonLoader'; +import { useOpenCreateActivityDrawer } from '@/activities/hooks/useOpenCreateActivityDrawer'; +import { TASKS_TAB_LIST_COMPONENT_ID } from '@/activities/tasks/constants/TasksTabListComponentId'; +import { useTasks } from '@/activities/tasks/hooks/useTasks'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { Task } from '@/activities/types/Task'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; import groupBy from 'lodash.groupby'; import { AddTaskButton } from './AddTaskButton'; import { TaskList } from './TaskList'; diff --git a/packages/twenty-front/src/modules/activities/tasks/components/TaskRow.tsx b/packages/twenty-front/src/modules/activities/tasks/components/TaskRow.tsx index ad46a8a43b15..90e11ab06448 100644 --- a/packages/twenty-front/src/modules/activities/tasks/components/TaskRow.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/components/TaskRow.tsx @@ -1,11 +1,15 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { IconCalendar, OverflowingTextWithTooltip } from 'twenty-ui'; +import { + Checkbox, + CheckboxShape, + IconCalendar, + OverflowingTextWithTooltip, +} from 'twenty-ui'; import { useOpenActivityRightDrawer } from '@/activities/hooks/useOpenActivityRightDrawer'; import { ActivityTargetsInlineCell } from '@/activities/inline-cell/components/ActivityTargetsInlineCell'; import { getActivitySummary } from '@/activities/utils/getActivitySummary'; -import { Checkbox, CheckboxShape } from '@/ui/input/components/Checkbox'; import { beautifyExactDate, hasDatePassed } from '~/utils/date-utils'; import { ActivityRow } from '@/activities/components/ActivityRow'; diff --git a/packages/twenty-front/src/modules/activities/tasks/__stories__/TaskGroups.stories.tsx b/packages/twenty-front/src/modules/activities/tasks/components/__stories__/TaskGroups.stories.tsx similarity index 100% rename from packages/twenty-front/src/modules/activities/tasks/__stories__/TaskGroups.stories.tsx rename to packages/twenty-front/src/modules/activities/tasks/components/__stories__/TaskGroups.stories.tsx diff --git a/packages/twenty-front/src/modules/activities/tasks/__stories__/TaskList.stories.tsx b/packages/twenty-front/src/modules/activities/tasks/components/__stories__/TaskList.stories.tsx similarity index 100% rename from packages/twenty-front/src/modules/activities/tasks/__stories__/TaskList.stories.tsx rename to packages/twenty-front/src/modules/activities/tasks/components/__stories__/TaskList.stories.tsx diff --git a/packages/twenty-front/src/modules/activities/tasks/hooks/__tests__/useCompleteTask.test.tsx b/packages/twenty-front/src/modules/activities/tasks/hooks/__tests__/useCompleteTask.test.tsx index 814b72fb6ce1..f99668b33a0a 100644 --- a/packages/twenty-front/src/modules/activities/tasks/hooks/__tests__/useCompleteTask.test.tsx +++ b/packages/twenty-front/src/modules/activities/tasks/hooks/__tests__/useCompleteTask.test.tsx @@ -51,7 +51,6 @@ const mocks: MockedResponse[] = [ edges { node { __typename - activityId authorId companyId createdAt @@ -95,6 +94,8 @@ const mocks: MockedResponse[] = [ updatedAt viewId workflowId + workflowRunId + workflowVersionId workspaceMemberId } } @@ -138,6 +139,9 @@ const mocks: MockedResponse[] = [ rocketId taskId updatedAt + workflowId + workflowRunId + workflowVersionId workspaceMemberId } } diff --git a/packages/twenty-front/src/modules/activities/tasks/hooks/useTasks.ts b/packages/twenty-front/src/modules/activities/tasks/hooks/useTasks.ts index ef210d328a25..2085284d130d 100644 --- a/packages/twenty-front/src/modules/activities/tasks/hooks/useTasks.ts +++ b/packages/twenty-front/src/modules/activities/tasks/hooks/useTasks.ts @@ -1,5 +1,5 @@ import { useActivities } from '@/activities/hooks/useActivities'; -import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timelineActivities/constants/FindManyTimelineActivitiesOrderBy'; +import { FIND_MANY_TIMELINE_ACTIVITIES_ORDER_BY } from '@/activities/timeline-activities/constants/FindManyTimelineActivitiesOrderBy'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { Task } from '@/activities/types/Task'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/components/EventList.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/components/EventList.tsx similarity index 83% rename from packages/twenty-front/src/modules/activities/timelineActivities/components/EventList.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/components/EventList.tsx index bf82cc2a42da..a19f0af6a7e0 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/components/EventList.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/components/EventList.tsx @@ -1,10 +1,10 @@ import styled from '@emotion/styled'; import { ReactElement } from 'react'; -import { EventsGroup } from '@/activities/timelineActivities/components/EventsGroup'; -import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; -import { filterOutInvalidTimelineActivities } from '@/activities/timelineActivities/utils/filterOutInvalidTimelineActivities'; -import { groupEventsByMonth } from '@/activities/timelineActivities/utils/groupEventsByMonth'; +import { EventsGroup } from '@/activities/timeline-activities/components/EventsGroup'; +import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity'; +import { filterOutInvalidTimelineActivities } from '@/activities/timeline-activities/utils/filterOutInvalidTimelineActivities'; +import { groupEventsByMonth } from '@/activities/timeline-activities/utils/groupEventsByMonth'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; @@ -26,8 +26,6 @@ const StyledTimelineContainer = styled.div` flex-direction: column; gap: ${({ theme }) => theme.spacing(1)}; justify-content: flex-start; - - width: calc(100% - ${({ theme }) => theme.spacing(8)}); `; export const EventList = ({ events, targetableObject }: EventListProps) => { diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/components/EventRow.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/components/EventRow.tsx similarity index 87% rename from packages/twenty-front/src/modules/activities/timelineActivities/components/EventRow.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/components/EventRow.tsx index e046316132df..e78e4a136d0f 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/components/EventRow.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/components/EventRow.tsx @@ -2,15 +2,16 @@ import styled from '@emotion/styled'; import { useContext } from 'react'; import { useRecoilValue } from 'recoil'; -import { TimelineActivityContext } from '@/activities/timelineActivities/contexts/TimelineActivityContext'; +import { TimelineActivityContext } from '@/activities/timeline-activities/contexts/TimelineActivityContext'; -import { useLinkedObjectObjectMetadataItem } from '@/activities/timelineActivities/hooks/useLinkedObjectObjectMetadataItem'; -import { EventIconDynamicComponent } from '@/activities/timelineActivities/rows/components/EventIconDynamicComponent'; -import { EventRowDynamicComponent } from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent'; -import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; -import { getTimelineActivityAuthorFullName } from '@/activities/timelineActivities/utils/getTimelineActivityAuthorFullName'; +import { useLinkedObjectObjectMetadataItem } from '@/activities/timeline-activities/hooks/useLinkedObjectObjectMetadataItem'; +import { EventIconDynamicComponent } from '@/activities/timeline-activities/rows/components/EventIconDynamicComponent'; +import { EventRowDynamicComponent } from '@/activities/timeline-activities/rows/components/EventRowDynamicComponent'; +import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity'; +import { getTimelineActivityAuthorFullName } from '@/activities/timeline-activities/utils/getTimelineActivityAuthorFullName'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { MOBILE_VIEWPORT } from 'twenty-ui'; import { beautifyPastDateRelativeToNow } from '~/utils/date-utils'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; @@ -62,6 +63,7 @@ const StyledSummary = styled.summary` flex: 1; flex-direction: row; gap: ${({ theme }) => theme.spacing(1)}; + width: 100%; `; const StyledItemContainer = styled.div<{ isMarginBottom?: boolean }>` @@ -77,6 +79,9 @@ const StyledItemContainer = styled.div<{ isMarginBottom?: boolean }>` `; const StyledItemTitleDate = styled.div` + @media (max-width: ${MOBILE_VIEWPORT}px) { + display: none; + } align-items: flex-start; padding-top: ${({ theme }) => theme.spacing(1)}; color: ${({ theme }) => theme.font.color.tertiary}; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/components/EventsGroup.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/components/EventsGroup.tsx similarity index 85% rename from packages/twenty-front/src/modules/activities/timelineActivities/components/EventsGroup.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/components/EventsGroup.tsx index 430968f4f259..590f5657c66c 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/components/EventsGroup.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/components/EventsGroup.tsx @@ -1,7 +1,7 @@ import styled from '@emotion/styled'; -import { EventRow } from '@/activities/timelineActivities/components/EventRow'; -import { EventGroup } from '@/activities/timelineActivities/utils/groupEventsByMonth'; +import { EventRow } from '@/activities/timeline-activities/components/EventRow'; +import { EventGroup } from '@/activities/timeline-activities/utils/groupEventsByMonth'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; type EventsGroupProps = { @@ -20,8 +20,8 @@ const StyledActivityGroup = styled.div` `; const StyledActivityGroupContainer = styled.div` - padding-bottom: ${({ theme }) => theme.spacing(2)}; - padding-top: ${({ theme }) => theme.spacing(2)}; + margin-bottom: ${({ theme }) => theme.spacing(3)}; + margin-top: ${({ theme }) => theme.spacing(3)}; position: relative; `; @@ -29,7 +29,7 @@ const StyledActivityGroupBar = styled.div` align-items: center; background: ${({ theme }) => theme.background.secondary}; border: 1px solid ${({ theme }) => theme.border.color.light}; - border-radius: ${({ theme }) => theme.border.radius.xl}; + border-radius: ${({ theme }) => theme.border.radius.md}; display: flex; flex-direction: column; height: 100%; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/components/TimelineActivities.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/components/TimelineActivities.tsx similarity index 84% rename from packages/twenty-front/src/modules/activities/timelineActivities/components/TimelineActivities.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/components/TimelineActivities.tsx index bbda464681f9..9695f98c58f2 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/components/TimelineActivities.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/components/TimelineActivities.tsx @@ -2,19 +2,20 @@ import styled from '@emotion/styled'; import { CustomResolverFetchMoreLoader } from '@/activities/components/CustomResolverFetchMoreLoader'; import { SkeletonLoader } from '@/activities/components/SkeletonLoader'; -import { EventList } from '@/activities/timelineActivities/components/EventList'; -import { TimelineCreateButtonGroup } from '@/activities/timelineActivities/components/TimelineCreateButtonGroup'; -import { useTimelineActivities } from '@/activities/timelineActivities/hooks/useTimelineActivities'; +import { EventList } from '@/activities/timeline-activities/components/EventList'; +import { TimelineCreateButtonGroup } from '@/activities/timeline-activities/components/TimelineCreateButtonGroup'; +import { useTimelineActivities } from '@/activities/timeline-activities/hooks/useTimelineActivities'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; -import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder'; +import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { + AnimatedPlaceholder, AnimatedPlaceholderEmptyContainer, AnimatedPlaceholderEmptySubTitle, AnimatedPlaceholderEmptyTextContainer, AnimatedPlaceholderEmptyTitle, EMPTY_PLACEHOLDER_TRANSITION_PROPS, -} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled'; -import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; + MOBILE_VIEWPORT, +} from 'twenty-ui'; const StyledMainContainer = styled.div` align-items: flex-start; @@ -31,6 +32,11 @@ const StyledMainContainer = styled.div` padding-right: ${({ theme }) => theme.spacing(6)}; padding-left: ${({ theme }) => theme.spacing(6)}; gap: ${({ theme }) => theme.spacing(4)}; + + @media (max-width: ${MOBILE_VIEWPORT}px) { + padding-right: ${({ theme }) => theme.spacing(1)}; + padding-left: ${({ theme }) => theme.spacing(1)}; + } `; export const TimelineActivities = ({ @@ -46,7 +52,7 @@ export const TimelineActivities = ({ const isTimelineActivitiesEmpty = !timelineActivities || timelineActivities.length === 0; - if (loading) { + if (loading === true) { return ; } diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/components/TimelineCreateButtonGroup.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/components/TimelineCreateButtonGroup.tsx similarity index 79% rename from packages/twenty-front/src/modules/activities/timelineActivities/components/TimelineCreateButtonGroup.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/components/TimelineCreateButtonGroup.tsx index 2889dfc77c0e..e5bc090bc278 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/components/TimelineCreateButtonGroup.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/components/TimelineCreateButtonGroup.tsx @@ -1,10 +1,13 @@ -import { useSetRecoilState } from 'recoil'; -import { IconCheckbox, IconNotes, IconPaperclip } from 'twenty-ui'; - -import { Button } from '@/ui/input/button/components/Button'; -import { ButtonGroup } from '@/ui/input/button/components/ButtonGroup'; -import { TAB_LIST_COMPONENT_ID } from '@/ui/layout/show-page/components/ShowPageRightContainer'; +import { TAB_LIST_COMPONENT_ID } from '@/ui/layout/show-page/components/ShowPageSubContainer'; import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; +import { useSetRecoilState } from 'recoil'; +import { + Button, + ButtonGroup, + IconCheckbox, + IconNotes, + IconPaperclip, +} from 'twenty-ui'; export const TimelineCreateButtonGroup = ({ isInRightDrawer = false, diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/__stories__/TimelineActivities.stories.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/components/__stories__/TimelineActivities.stories.tsx similarity index 89% rename from packages/twenty-front/src/modules/activities/timelineActivities/__stories__/TimelineActivities.stories.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/components/__stories__/TimelineActivities.stories.tsx index d04d8281c761..7c16632eea75 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/__stories__/TimelineActivities.stories.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/components/__stories__/TimelineActivities.stories.tsx @@ -1,9 +1,9 @@ import { Meta, StoryObj } from '@storybook/react'; -import { graphql, HttpResponse } from 'msw'; +import { HttpResponse, graphql } from 'msw'; import { ComponentDecorator } from 'twenty-ui'; -import { TimelineActivities } from '@/activities/timelineActivities/components/TimelineActivities'; -import { TimelineActivityContext } from '@/activities/timelineActivities/contexts/TimelineActivityContext'; +import { TimelineActivities } from '@/activities/timeline-activities/components/TimelineActivities'; +import { TimelineActivityContext } from '@/activities/timeline-activities/contexts/TimelineActivityContext'; import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; import { mockedTimelineActivities } from '~/testing/mock-data/timeline-activities'; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/constants/FindManyTimelineActivitiesOrderBy.ts b/packages/twenty-front/src/modules/activities/timeline-activities/constants/FindManyTimelineActivitiesOrderBy.ts similarity index 100% rename from packages/twenty-front/src/modules/activities/timelineActivities/constants/FindManyTimelineActivitiesOrderBy.ts rename to packages/twenty-front/src/modules/activities/timeline-activities/constants/FindManyTimelineActivitiesOrderBy.ts diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/contexts/TimelineActivityContext.ts b/packages/twenty-front/src/modules/activities/timeline-activities/contexts/TimelineActivityContext.ts similarity index 100% rename from packages/twenty-front/src/modules/activities/timelineActivities/contexts/TimelineActivityContext.ts rename to packages/twenty-front/src/modules/activities/timeline-activities/contexts/TimelineActivityContext.ts diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/hooks/__tests__/useTimelineActivities.test.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/hooks/__tests__/useTimelineActivities.test.tsx similarity index 95% rename from packages/twenty-front/src/modules/activities/timelineActivities/hooks/__tests__/useTimelineActivities.test.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/hooks/__tests__/useTimelineActivities.test.tsx index 2d1989cc68ea..158b628bee6a 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/hooks/__tests__/useTimelineActivities.test.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/hooks/__tests__/useTimelineActivities.test.tsx @@ -1,6 +1,6 @@ import { renderHook } from '@testing-library/react'; -import { useTimelineActivities } from '@/activities/timelineActivities/hooks/useTimelineActivities'; +import { useTimelineActivities } from '@/activities/timeline-activities/hooks/useTimelineActivities'; import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; jest.mock('@/object-record/hooks/useFindManyRecords', () => ({ diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/hooks/useLinkedObjectObjectMetadataItem.ts b/packages/twenty-front/src/modules/activities/timeline-activities/hooks/useLinkedObjectObjectMetadataItem.ts similarity index 100% rename from packages/twenty-front/src/modules/activities/timelineActivities/hooks/useLinkedObjectObjectMetadataItem.ts rename to packages/twenty-front/src/modules/activities/timeline-activities/hooks/useLinkedObjectObjectMetadataItem.ts diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/hooks/useLinkedObjectsTitle.ts b/packages/twenty-front/src/modules/activities/timeline-activities/hooks/useLinkedObjectsTitle.ts similarity index 100% rename from packages/twenty-front/src/modules/activities/timelineActivities/hooks/useLinkedObjectsTitle.ts rename to packages/twenty-front/src/modules/activities/timeline-activities/hooks/useLinkedObjectsTitle.ts diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/hooks/useTimelineActivities.ts b/packages/twenty-front/src/modules/activities/timeline-activities/hooks/useTimelineActivities.ts similarity index 90% rename from packages/twenty-front/src/modules/activities/timelineActivities/hooks/useTimelineActivities.ts rename to packages/twenty-front/src/modules/activities/timeline-activities/hooks/useTimelineActivities.ts index fb65053c151c..96c00233e6d8 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/hooks/useTimelineActivities.ts +++ b/packages/twenty-front/src/modules/activities/timeline-activities/hooks/useTimelineActivities.ts @@ -1,5 +1,5 @@ -import { useLinkedObjectsTitle } from '@/activities/timelineActivities/hooks/useLinkedObjectsTitle'; -import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; +import { useLinkedObjectsTitle } from '@/activities/timeline-activities/hooks/useLinkedObjectsTitle'; +import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity'; import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; import { getActivityTargetObjectFieldIdName } from '@/activities/utils/getActivityTargetObjectFieldIdName'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/activity/components/EventRowActivity.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/activity/components/EventRowActivity.tsx similarity index 92% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/activity/components/EventRowActivity.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/activity/components/EventRowActivity.tsx index 1c1f34e43ade..47f95270d6a4 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/activity/components/EventRowActivity.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/activity/components/EventRowActivity.tsx @@ -5,7 +5,7 @@ import { EventRowDynamicComponentProps, StyledEventRowItemAction, StyledEventRowItemColumn, -} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent'; +} from '@/activities/timeline-activities/rows/components/EventRowDynamicComponent'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; import { isNonEmptyString } from '@sniptt/guards'; @@ -16,6 +16,10 @@ const StyledLinkedActivity = styled.span` color: ${({ theme }) => theme.font.color.primary}; cursor: pointer; text-decoration: underline; + width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; `; export const StyledEventRowItemText = styled.span` diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/calendar/components/EventCardCalendarEvent.tsx similarity index 100% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/calendar/components/EventCardCalendarEvent.tsx diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventRowCalendarEvent.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/calendar/components/EventRowCalendarEvent.tsx similarity index 77% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventRowCalendarEvent.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/calendar/components/EventRowCalendarEvent.tsx index c1ffd1094ef3..a4172b8f4a90 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/EventRowCalendarEvent.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/calendar/components/EventRowCalendarEvent.tsx @@ -1,14 +1,14 @@ -import { useState } from 'react'; import styled from '@emotion/styled'; +import { useState } from 'react'; -import { EventCardCalendarEvent } from '@/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent'; -import { EventCard } from '@/activities/timelineActivities/rows/components/EventCard'; -import { EventCardToggleButton } from '@/activities/timelineActivities/rows/components/EventCardToggleButton'; +import { EventCardCalendarEvent } from '@/activities/timeline-activities/rows/calendar/components/EventCardCalendarEvent'; +import { EventCard } from '@/activities/timeline-activities/rows/components/EventCard'; +import { EventCardToggleButton } from '@/activities/timeline-activities/rows/components/EventCardToggleButton'; import { EventRowDynamicComponentProps, StyledEventRowItemAction, StyledEventRowItemColumn, -} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent'; +} from '@/activities/timeline-activities/rows/components/EventRowDynamicComponent'; type EventRowCalendarEventProps = EventRowDynamicComponentProps; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/__stories__/EventCardCalendarEvent.stories.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/calendar/components/__stories__/EventCardCalendarEvent.stories.tsx similarity index 90% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/__stories__/EventCardCalendarEvent.stories.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/calendar/components/__stories__/EventCardCalendarEvent.stories.tsx index b0c3fd7ca54c..8e8f4b3aaa0b 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/calendar/components/__stories__/EventCardCalendarEvent.stories.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/calendar/components/__stories__/EventCardCalendarEvent.stories.tsx @@ -1,8 +1,8 @@ import { Meta, StoryObj } from '@storybook/react'; -import { graphql, HttpResponse } from 'msw'; +import { HttpResponse, graphql } from 'msw'; import { ComponentDecorator } from 'twenty-ui'; -import { EventCardCalendarEvent } from '@/activities/timelineActivities/rows/calendar/components/EventCardCalendarEvent'; +import { EventCardCalendarEvent } from '@/activities/timeline-activities/rows/calendar/components/EventCardCalendarEvent'; import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventCard.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/components/EventCard.tsx similarity index 89% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventCard.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/components/EventCard.tsx index 67e4f78cd726..267e56f2d83c 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventCard.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/components/EventCard.tsx @@ -1,6 +1,6 @@ import styled from '@emotion/styled'; -import { Card } from '@/ui/layout/card/components/Card'; +import { Card, MOBILE_VIEWPORT } from 'twenty-ui'; type EventCardProps = { children: React.ReactNode; @@ -16,6 +16,10 @@ const StyledCardContainer = styled.div` width: 400px; padding: ${({ theme }) => theme.spacing(2)} 0px ${({ theme }) => theme.spacing(1)} 0px; + + @media (max-width: ${MOBILE_VIEWPORT}px) { + width: 300px; + } `; const StyledCard = styled(Card)` diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventCardToggleButton.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/components/EventCardToggleButton.tsx similarity index 81% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventCardToggleButton.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/components/EventCardToggleButton.tsx index 44a2cb4066a2..2e0795845dd0 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventCardToggleButton.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/components/EventCardToggleButton.tsx @@ -1,7 +1,5 @@ import styled from '@emotion/styled'; -import { IconChevronDown, IconChevronUp } from 'twenty-ui'; - -import { IconButton } from '@/ui/input/button/components/IconButton'; +import { IconButton, IconChevronDown, IconChevronUp } from 'twenty-ui'; type EventCardToggleButtonProps = { isOpen: boolean; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventIconDynamicComponent.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/components/EventIconDynamicComponent.tsx similarity index 88% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventIconDynamicComponent.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/components/EventIconDynamicComponent.tsx index ecb7bc90d51f..6e4b49b8feea 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventIconDynamicComponent.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/components/EventIconDynamicComponent.tsx @@ -1,6 +1,6 @@ import { IconCirclePlus, IconEditCircle, IconTrash, useIcons } from 'twenty-ui'; -import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; +import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; export const EventIconDynamicComponent = ({ diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventRowDynamicComponent.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/components/EventRowDynamicComponent.tsx similarity index 76% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventRowDynamicComponent.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/components/EventRowDynamicComponent.tsx index e5542cd35480..e51556decdb9 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/components/EventRowDynamicComponent.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/components/EventRowDynamicComponent.tsx @@ -1,10 +1,10 @@ import styled from '@emotion/styled'; -import { EventRowActivity } from '@/activities/timelineActivities/rows/activity/components/EventRowActivity'; -import { EventRowCalendarEvent } from '@/activities/timelineActivities/rows/calendar/components/EventRowCalendarEvent'; -import { EventRowMainObject } from '@/activities/timelineActivities/rows/main-object/components/EventRowMainObject'; -import { EventRowMessage } from '@/activities/timelineActivities/rows/message/components/EventRowMessage'; -import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; +import { EventRowActivity } from '@/activities/timeline-activities/rows/activity/components/EventRowActivity'; +import { EventRowCalendarEvent } from '@/activities/timeline-activities/rows/calendar/components/EventRowCalendarEvent'; +import { EventRowMainObject } from '@/activities/timeline-activities/rows/main-object/components/EventRowMainObject'; +import { EventRowMessage } from '@/activities/timeline-activities/rows/message/components/EventRowMessage'; +import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; @@ -35,9 +35,7 @@ export const EventRowDynamicComponent = ({ linkedObjectMetadataItem, authorFullName, }: EventRowDynamicComponentProps) => { - const [eventName] = event.name.split('.'); - - switch (eventName) { + switch (linkedObjectMetadataItem?.nameSingular) { case 'calendarEvent': return ( ); - case 'linked-task': + case 'task': return ( ); - case 'linked-note': + case 'note': return ( ); - case mainObjectMetadataItem?.nameSingular: + default: return ( ); - default: - throw new Error( - `Cannot find event component for event name ${eventName}`, - ); } }; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventFieldDiff.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiff.tsx similarity index 85% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventFieldDiff.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiff.tsx index 94726465f201..e212b15c5576 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventFieldDiff.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiff.tsx @@ -1,8 +1,8 @@ import styled from '@emotion/styled'; -import { EventFieldDiffLabel } from '@/activities/timelineActivities/rows/main-object/components/EventFieldDiffLabel'; -import { EventFieldDiffValue } from '@/activities/timelineActivities/rows/main-object/components/EventFieldDiffValue'; -import { EventFieldDiffValueEffect } from '@/activities/timelineActivities/rows/main-object/components/EventFieldDiffValueEffect'; +import { EventFieldDiffLabel } from '@/activities/timeline-activities/rows/main-object/components/EventFieldDiffLabel'; +import { EventFieldDiffValue } from '@/activities/timeline-activities/rows/main-object/components/EventFieldDiffValue'; +import { EventFieldDiffValueEffect } from '@/activities/timeline-activities/rows/main-object/components/EventFieldDiffValueEffect'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; @@ -20,7 +20,10 @@ const StyledEventFieldDiffContainer = styled.div` flex-direction: row; gap: ${({ theme }) => theme.spacing(1)}; height: 24px; - width: 380px; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; `; const StyledEmptyValue = styled.div` diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventFieldDiffContainer.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiffContainer.tsx similarity index 91% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventFieldDiffContainer.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiffContainer.tsx index 3b4cf60396f8..3a5b36ee6f44 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventFieldDiffContainer.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiffContainer.tsx @@ -1,4 +1,4 @@ -import { EventFieldDiff } from '@/activities/timelineActivities/rows/main-object/components/EventFieldDiff'; +import { EventFieldDiff } from '@/activities/timeline-activities/rows/main-object/components/EventFieldDiff'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventFieldDiffLabel.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiffLabel.tsx similarity index 100% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventFieldDiffLabel.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiffLabel.tsx diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventFieldDiffValue.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiffValue.tsx similarity index 100% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventFieldDiffValue.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiffValue.tsx diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventFieldDiffValueEffect.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiffValueEffect.tsx similarity index 100% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventFieldDiffValueEffect.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventFieldDiffValueEffect.tsx diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventRowMainObject.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventRowMainObject.tsx similarity index 91% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventRowMainObject.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventRowMainObject.tsx index 053e9217bb66..448879073a1b 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventRowMainObject.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventRowMainObject.tsx @@ -1,11 +1,10 @@ -import styled from '@emotion/styled'; - import { EventRowDynamicComponentProps, StyledEventRowItemAction, StyledEventRowItemColumn, -} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent'; -import { EventRowMainObjectUpdated } from '@/activities/timelineActivities/rows/main-object/components/EventRowMainObjectUpdated'; +} from '@/activities/timeline-activities/rows/components/EventRowDynamicComponent'; +import { EventRowMainObjectUpdated } from '@/activities/timeline-activities/rows/main-object/components/EventRowMainObjectUpdated'; +import styled from '@emotion/styled'; type EventRowMainObjectProps = EventRowDynamicComponentProps; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventRowMainObjectUpdated.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventRowMainObjectUpdated.tsx similarity index 84% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventRowMainObjectUpdated.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventRowMainObjectUpdated.tsx index 30e6343bd708..cc2b7102e0d1 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/EventRowMainObjectUpdated.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/EventRowMainObjectUpdated.tsx @@ -1,14 +1,14 @@ -import { useState } from 'react'; import styled from '@emotion/styled'; +import { useState } from 'react'; -import { EventCard } from '@/activities/timelineActivities/rows/components/EventCard'; -import { EventCardToggleButton } from '@/activities/timelineActivities/rows/components/EventCardToggleButton'; +import { EventCard } from '@/activities/timeline-activities/rows/components/EventCard'; +import { EventCardToggleButton } from '@/activities/timeline-activities/rows/components/EventCardToggleButton'; import { StyledEventRowItemAction, StyledEventRowItemColumn, -} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent'; -import { EventFieldDiffContainer } from '@/activities/timelineActivities/rows/main-object/components/EventFieldDiffContainer'; -import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; +} from '@/activities/timeline-activities/rows/components/EventRowDynamicComponent'; +import { EventFieldDiffContainer } from '@/activities/timeline-activities/rows/main-object/components/EventFieldDiffContainer'; +import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity'; import { FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/__stories__/EventRowMainObjectUpdated.stories.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/__stories__/EventRowMainObjectUpdated.stories.tsx similarity index 90% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/__stories__/EventRowMainObjectUpdated.stories.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/__stories__/EventRowMainObjectUpdated.stories.tsx index fe0b549d68da..d4a12a465643 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/main-object/components/__stories__/EventRowMainObjectUpdated.stories.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/main-object/components/__stories__/EventRowMainObjectUpdated.stories.tsx @@ -1,8 +1,8 @@ +import { EventRowMainObjectUpdated } from '@/activities/timeline-activities/rows/main-object/components/EventRowMainObjectUpdated'; +import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity'; import { Meta, StoryObj } from '@storybook/react'; import { ComponentDecorator, RouterDecorator } from 'twenty-ui'; -import { EventRowMainObjectUpdated } from '@/activities/timelineActivities/rows/main-object/components/EventRowMainObjectUpdated'; -import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventCardMessage.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/message/components/EventCardMessage.tsx similarity index 98% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventCardMessage.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/message/components/EventCardMessage.tsx index 899c0414e76b..7114bd427667 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventCardMessage.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/message/components/EventCardMessage.tsx @@ -4,7 +4,7 @@ import { OverflowingTextWithTooltip } from 'twenty-ui'; import { useEmailThread } from '@/activities/emails/hooks/useEmailThread'; import { EmailThreadMessage } from '@/activities/emails/types/EmailThreadMessage'; -import { EventCardMessageNotShared } from '@/activities/timelineActivities/rows/message/components/EventCardMessageNotShared'; +import { EventCardMessageNotShared } from '@/activities/timeline-activities/rows/message/components/EventCardMessageNotShared'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventCardMessageNotShared.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/message/components/EventCardMessageNotShared.tsx similarity index 100% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventCardMessageNotShared.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/message/components/EventCardMessageNotShared.tsx diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventRowMessage.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/message/components/EventRowMessage.tsx similarity index 79% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventRowMessage.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/message/components/EventRowMessage.tsx index 83513994517f..00bd68e93a2a 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/EventRowMessage.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/message/components/EventRowMessage.tsx @@ -1,14 +1,14 @@ -import { useState } from 'react'; import styled from '@emotion/styled'; +import { useState } from 'react'; -import { EventCard } from '@/activities/timelineActivities/rows/components/EventCard'; -import { EventCardToggleButton } from '@/activities/timelineActivities/rows/components/EventCardToggleButton'; +import { EventCard } from '@/activities/timeline-activities/rows/components/EventCard'; +import { EventCardToggleButton } from '@/activities/timeline-activities/rows/components/EventCardToggleButton'; import { EventRowDynamicComponentProps, StyledEventRowItemAction, StyledEventRowItemColumn, -} from '@/activities/timelineActivities/rows/components/EventRowDynamicComponent'; -import { EventCardMessage } from '@/activities/timelineActivities/rows/message/components/EventCardMessage'; +} from '@/activities/timeline-activities/rows/components/EventRowDynamicComponent'; +import { EventCardMessage } from '@/activities/timeline-activities/rows/message/components/EventCardMessage'; type EventRowMessageProps = EventRowDynamicComponentProps; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/__stories__/EventCardMessage.stories.tsx b/packages/twenty-front/src/modules/activities/timeline-activities/rows/message/components/__stories__/EventCardMessage.stories.tsx similarity index 87% rename from packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/__stories__/EventCardMessage.stories.tsx rename to packages/twenty-front/src/modules/activities/timeline-activities/rows/message/components/__stories__/EventCardMessage.stories.tsx index 3e8e08cd06dc..40d27298fd5d 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/rows/message/components/__stories__/EventCardMessage.stories.tsx +++ b/packages/twenty-front/src/modules/activities/timeline-activities/rows/message/components/__stories__/EventCardMessage.stories.tsx @@ -1,9 +1,9 @@ import { Meta, StoryObj } from '@storybook/react'; -import { graphql, HttpResponse } from 'msw'; +import { HttpResponse, graphql } from 'msw'; import { ComponentDecorator } from 'twenty-ui'; -import { TimelineActivityContext } from '@/activities/timelineActivities/contexts/TimelineActivityContext'; -import { EventCardMessage } from '@/activities/timelineActivities/rows/message/components/EventCardMessage'; +import { TimelineActivityContext } from '@/activities/timeline-activities/contexts/TimelineActivityContext'; +import { EventCardMessage } from '@/activities/timeline-activities/rows/message/components/EventCardMessage'; import { ObjectMetadataItemsDecorator } from '~/testing/decorators/ObjectMetadataItemsDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/states/objectShowPageTargetableObjectIdState.ts b/packages/twenty-front/src/modules/activities/timeline-activities/states/objectShowPageTargetableObjectIdState.ts similarity index 100% rename from packages/twenty-front/src/modules/activities/timelineActivities/states/objectShowPageTargetableObjectIdState.ts rename to packages/twenty-front/src/modules/activities/timeline-activities/states/objectShowPageTargetableObjectIdState.ts diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/types/TimelineActivity.ts b/packages/twenty-front/src/modules/activities/timeline-activities/types/TimelineActivity.ts similarity index 100% rename from packages/twenty-front/src/modules/activities/timelineActivities/types/TimelineActivity.ts rename to packages/twenty-front/src/modules/activities/timeline-activities/types/TimelineActivity.ts diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/types/TimelineActivityLinkedObject.ts b/packages/twenty-front/src/modules/activities/timeline-activities/types/TimelineActivityLinkedObject.ts similarity index 100% rename from packages/twenty-front/src/modules/activities/timelineActivities/types/TimelineActivityLinkedObject.ts rename to packages/twenty-front/src/modules/activities/timeline-activities/types/TimelineActivityLinkedObject.ts diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/utils/__tests__/filterOutInvalidTimelineActivities.test.ts b/packages/twenty-front/src/modules/activities/timeline-activities/utils/__tests__/filterOutInvalidTimelineActivities.test.ts similarity index 96% rename from packages/twenty-front/src/modules/activities/timelineActivities/utils/__tests__/filterOutInvalidTimelineActivities.test.ts rename to packages/twenty-front/src/modules/activities/timeline-activities/utils/__tests__/filterOutInvalidTimelineActivities.test.ts index 1dc11441002b..a685d15055c5 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/utils/__tests__/filterOutInvalidTimelineActivities.test.ts +++ b/packages/twenty-front/src/modules/activities/timeline-activities/utils/__tests__/filterOutInvalidTimelineActivities.test.ts @@ -1,5 +1,5 @@ -import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; -import { filterOutInvalidTimelineActivities } from '@/activities/timelineActivities/utils/filterOutInvalidTimelineActivities'; +import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity'; +import { filterOutInvalidTimelineActivities } from '@/activities/timeline-activities/utils/filterOutInvalidTimelineActivities'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/utils/__tests__/getTimelineActivityAuthorFullName.test.ts b/packages/twenty-front/src/modules/activities/timeline-activities/utils/__tests__/getTimelineActivityAuthorFullName.test.ts similarity index 91% rename from packages/twenty-front/src/modules/activities/timelineActivities/utils/__tests__/getTimelineActivityAuthorFullName.test.ts rename to packages/twenty-front/src/modules/activities/timeline-activities/utils/__tests__/getTimelineActivityAuthorFullName.test.ts index 85ac636ea096..7b3d817c64e0 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/utils/__tests__/getTimelineActivityAuthorFullName.test.ts +++ b/packages/twenty-front/src/modules/activities/timeline-activities/utils/__tests__/getTimelineActivityAuthorFullName.test.ts @@ -1,5 +1,5 @@ -import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; -import { getTimelineActivityAuthorFullName } from '@/activities/timelineActivities/utils/getTimelineActivityAuthorFullName'; +import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity'; +import { getTimelineActivityAuthorFullName } from '@/activities/timeline-activities/utils/getTimelineActivityAuthorFullName'; import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState'; describe('getTimelineActivityAuthorFullName', () => { diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/utils/__tests__/groupEventsByMonth.test.ts b/packages/twenty-front/src/modules/activities/timeline-activities/utils/__tests__/groupEventsByMonth.test.ts similarity index 100% rename from packages/twenty-front/src/modules/activities/timelineActivities/utils/__tests__/groupEventsByMonth.test.ts rename to packages/twenty-front/src/modules/activities/timeline-activities/utils/__tests__/groupEventsByMonth.test.ts diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/utils/filterOutInvalidTimelineActivities.ts b/packages/twenty-front/src/modules/activities/timeline-activities/utils/filterOutInvalidTimelineActivities.ts similarity index 95% rename from packages/twenty-front/src/modules/activities/timelineActivities/utils/filterOutInvalidTimelineActivities.ts rename to packages/twenty-front/src/modules/activities/timeline-activities/utils/filterOutInvalidTimelineActivities.ts index 5613db9d48b0..96413c89cd4e 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/utils/filterOutInvalidTimelineActivities.ts +++ b/packages/twenty-front/src/modules/activities/timeline-activities/utils/filterOutInvalidTimelineActivities.ts @@ -1,4 +1,4 @@ -import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; +import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/utils/filterTimelineActivityByLinkedObjectTypes.ts b/packages/twenty-front/src/modules/activities/timeline-activities/utils/filterTimelineActivityByLinkedObjectTypes.ts similarity index 75% rename from packages/twenty-front/src/modules/activities/timelineActivities/utils/filterTimelineActivityByLinkedObjectTypes.ts rename to packages/twenty-front/src/modules/activities/timeline-activities/utils/filterTimelineActivityByLinkedObjectTypes.ts index 455ceca01c0a..781e85d8b67e 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/utils/filterTimelineActivityByLinkedObjectTypes.ts +++ b/packages/twenty-front/src/modules/activities/timeline-activities/utils/filterTimelineActivityByLinkedObjectTypes.ts @@ -1,5 +1,5 @@ -import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; -import { TimelineActivityLinkedObject } from '@/activities/timelineActivities/types/TimelineActivityLinkedObject'; +import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity'; +import { TimelineActivityLinkedObject } from '@/activities/timeline-activities/types/TimelineActivityLinkedObject'; export const filterTimelineActivityByLinkedObjectTypes = (linkedObjectTypes: TimelineActivityLinkedObject[]) => diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/utils/getTimelineActivityAuthorFullName.ts b/packages/twenty-front/src/modules/activities/timeline-activities/utils/getTimelineActivityAuthorFullName.ts similarity index 84% rename from packages/twenty-front/src/modules/activities/timelineActivities/utils/getTimelineActivityAuthorFullName.ts rename to packages/twenty-front/src/modules/activities/timeline-activities/utils/getTimelineActivityAuthorFullName.ts index e97b27fa9450..4e141de6b3bd 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/utils/getTimelineActivityAuthorFullName.ts +++ b/packages/twenty-front/src/modules/activities/timeline-activities/utils/getTimelineActivityAuthorFullName.ts @@ -1,4 +1,4 @@ -import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; +import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity'; import { CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState'; import { isDefined } from '~/utils/isDefined'; diff --git a/packages/twenty-front/src/modules/activities/timelineActivities/utils/groupEventsByMonth.ts b/packages/twenty-front/src/modules/activities/timeline-activities/utils/groupEventsByMonth.ts similarity index 89% rename from packages/twenty-front/src/modules/activities/timelineActivities/utils/groupEventsByMonth.ts rename to packages/twenty-front/src/modules/activities/timeline-activities/utils/groupEventsByMonth.ts index fa0779f538c3..cd5ce8a73364 100644 --- a/packages/twenty-front/src/modules/activities/timelineActivities/utils/groupEventsByMonth.ts +++ b/packages/twenty-front/src/modules/activities/timeline-activities/utils/groupEventsByMonth.ts @@ -1,4 +1,4 @@ -import { TimelineActivity } from '@/activities/timelineActivities/types/TimelineActivity'; +import { TimelineActivity } from '@/activities/timeline-activities/types/TimelineActivity'; import { isDefined } from '~/utils/isDefined'; export type EventGroup = { diff --git a/packages/twenty-front/src/modules/analytics/components/AnalyticsActivityGraph.tsx b/packages/twenty-front/src/modules/analytics/components/AnalyticsActivityGraph.tsx new file mode 100644 index 000000000000..1a124ad90c52 --- /dev/null +++ b/packages/twenty-front/src/modules/analytics/components/AnalyticsActivityGraph.tsx @@ -0,0 +1,181 @@ +import { WebhookAnalyticsTooltip } from '@/analytics/components/WebhookAnalyticsTooltip'; +import { ANALYTICS_GRAPH_DESCRIPTION_MAP } from '@/analytics/constants/AnalyticsGraphDescriptionMap'; +import { ANALYTICS_GRAPH_TITLE_MAP } from '@/analytics/constants/AnalyticsGraphTitleMap'; +import { useGraphData } from '@/analytics/hooks/useGraphData'; +import { analyticsGraphDataComponentState } from '@/analytics/states/analyticsGraphDataComponentState'; +import { AnalyticsComponentProps as AnalyticsActivityGraphProps } from '@/analytics/types/AnalyticsComponentProps'; +import { computeAnalyticsGraphDataFunction } from '@/analytics/utils/computeAnalyticsGraphDataFunction'; +import { Select } from '@/ui/input/components/Select'; +import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { ResponsiveLine } from '@nivo/line'; +import { Section } from '@react-email/components'; +import { useId, useState } from 'react'; +import { H2Title } from 'twenty-ui'; + +const StyledGraphContainer = styled.div` + background-color: ${({ theme }) => theme.background.secondary}; + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-radius: ${({ theme }) => theme.border.radius.md}; + height: 199px; + + padding: ${({ theme }) => theme.spacing(4, 2, 2, 2)}; + width: 496px; +`; +const StyledTitleContainer = styled.div` + align-items: flex-start; + display: flex; + justify-content: space-between; +`; + +export const AnalyticsActivityGraph = ({ + recordId, + endpointName, +}: AnalyticsActivityGraphProps) => { + const [analyticsGraphData, setAnalyticsGraphData] = useRecoilComponentStateV2( + analyticsGraphDataComponentState, + ); + const theme = useTheme(); + + const [windowLengthGraphOption, setWindowLengthGraphOption] = useState< + '7D' | '1D' | '12H' | '4H' + >('7D'); + + const { fetchGraphData } = useGraphData({ + recordId, + endpointName, + }); + + const transformDataFunction = computeAnalyticsGraphDataFunction(endpointName); + + const dropdownId = useId(); + return ( + <> + {analyticsGraphData.length ? ( +
+ + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRootLevelViewFilterGroup.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRootLevelViewFilterGroup.tsx new file mode 100644 index 000000000000..9518cef60a96 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRootLevelViewFilterGroup.tsx @@ -0,0 +1,78 @@ +import { AdvancedFilterAddFilterRuleSelect } from '@/object-record/advanced-filter/components/AdvancedFilterAddFilterRuleSelect'; +import { AdvancedFilterLogicalOperatorCell } from '@/object-record/advanced-filter/components/AdvancedFilterLogicalOperatorCell'; +import { AdvancedFilterRuleOptionsDropdown } from '@/object-record/advanced-filter/components/AdvancedFilterRuleOptionsDropdown'; +import { AdvancedFilterViewFilter } from '@/object-record/advanced-filter/components/AdvancedFilterViewFilter'; +import { AdvancedFilterViewFilterGroup } from '@/object-record/advanced-filter/components/AdvancedFilterViewFilterGroup'; +import { useCurrentViewViewFilterGroup } from '@/object-record/advanced-filter/hooks/useCurrentViewViewFilterGroup'; +import styled from '@emotion/styled'; +import { isDefined } from 'twenty-ui'; + +const StyledRow = styled.div` + display: flex; + width: 100%; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledContainer = styled.div<{ isGrayBackground?: boolean }>` + align-items: start; + background-color: ${({ theme, isGrayBackground }) => + isGrayBackground ? theme.background.transparent.lighter : 'transparent'}; + border: ${({ theme }) => `1px solid ${theme.border.color.medium}`}; + border-radius: ${({ theme }) => theme.border.radius.md}; + display: flex; + flex: 1; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(2)}; + overflow: hidden; +`; + +type AdvancedFilterRootLevelViewFilterGroupProps = { + rootLevelViewFilterGroupId: string; +}; + +export const AdvancedFilterRootLevelViewFilterGroup = ({ + rootLevelViewFilterGroupId, +}: AdvancedFilterRootLevelViewFilterGroupProps) => { + const { + currentViewFilterGroup: rootLevelViewFilterGroup, + childViewFiltersAndViewFilterGroups, + lastChildPosition, + } = useCurrentViewViewFilterGroup({ + viewFilterGroupId: rootLevelViewFilterGroupId, + }); + + if (!isDefined(rootLevelViewFilterGroup)) { + return null; + } + + return ( + + {childViewFiltersAndViewFilterGroups.map((child, i) => + child.__typename === 'ViewFilterGroup' ? ( + + + + + + ) : ( + + + + + + ), + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRuleOptionsDropdown.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRuleOptionsDropdown.tsx new file mode 100644 index 000000000000..f4b8af642aed --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRuleOptionsDropdown.tsx @@ -0,0 +1,86 @@ +import { AdvancedFilterRuleOptionsDropdownButton } from '@/object-record/advanced-filter/components/AdvancedFilterRuleOptionsDropdownButton'; +import { useCurrentViewFilter } from '@/object-record/advanced-filter/hooks/useCurrentViewFilter'; +import { useCurrentViewViewFilterGroup } from '@/object-record/advanced-filter/hooks/useCurrentViewViewFilterGroup'; +import { useDeleteCombinedViewFilterGroup } from '@/object-record/advanced-filter/hooks/useDeleteCombinedViewFilterGroup'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { ADVANCED_FILTER_DROPDOWN_ID } from '@/views/constants/AdvancedFilterDropdownId'; +import { useDeleteCombinedViewFilters } from '@/views/hooks/useDeleteCombinedViewFilters'; +import { isDefined, MenuItem } from 'twenty-ui'; + +type AdvancedFilterRuleOptionsDropdownProps = + | { + viewFilterId: string; + viewFilterGroupId?: never; + } + | { + viewFilterId?: never; + viewFilterGroupId: string; + }; + +export const AdvancedFilterRuleOptionsDropdown = ({ + viewFilterId, + viewFilterGroupId, +}: AdvancedFilterRuleOptionsDropdownProps) => { + const dropdownId = `advanced-filter-rule-options-${viewFilterId ?? viewFilterGroupId}`; + + const { deleteCombinedViewFilter } = useDeleteCombinedViewFilters(); + const { deleteCombinedViewFilterGroup } = useDeleteCombinedViewFilterGroup(); + + const { currentViewFilterGroup, childViewFiltersAndViewFilterGroups } = + useCurrentViewViewFilterGroup({ + viewFilterGroupId, + }); + + const currentViewFilter = useCurrentViewFilter({ + viewFilterId, + }); + + const handleRemove = async () => { + if (isDefined(viewFilterId)) { + deleteCombinedViewFilter(viewFilterId); + + const isOnlyViewFilterInGroup = + childViewFiltersAndViewFilterGroups.length === 1; + + if ( + isOnlyViewFilterInGroup && + isDefined(currentViewFilter?.viewFilterGroupId) + ) { + deleteCombinedViewFilterGroup(currentViewFilter.viewFilterGroupId); + } + } else if (isDefined(currentViewFilterGroup)) { + deleteCombinedViewFilterGroup(currentViewFilterGroup.id); + + const childViewFilters = childViewFiltersAndViewFilterGroups.filter( + (child) => child.__typename === 'ViewFilter', + ); + + for (const childViewFilter of childViewFilters) { + await deleteCombinedViewFilter(childViewFilter.id); + } + } else { + throw new Error('No view filter or view filter group to remove'); + } + }; + + const removeButtonLabel = viewFilterId ? 'Remove rule' : 'Remove rule group'; + + return ( + + } + dropdownComponents={ + + + + } + dropdownHotkeyScope={{ scope: ADVANCED_FILTER_DROPDOWN_ID }} + dropdownOffset={{ y: 8, x: 0 }} + dropdownPlacement="bottom-start" + /> + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRuleOptionsDropdownButton.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRuleOptionsDropdownButton.tsx new file mode 100644 index 000000000000..40c21f09e660 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterRuleOptionsDropdownButton.tsx @@ -0,0 +1,25 @@ +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; +import { IconButton, IconDotsVertical } from 'twenty-ui'; + +type AdvancedFilterRuleOptionsDropdownButtonProps = { + dropdownId: string; +}; + +export const AdvancedFilterRuleOptionsDropdownButton = ({ + dropdownId, +}: AdvancedFilterRuleOptionsDropdownButtonProps) => { + const { toggleDropdown } = useDropdown(dropdownId); + + const handleClick = () => { + toggleDropdown(); + }; + + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterViewFilter.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterViewFilter.tsx new file mode 100644 index 000000000000..481bbc8d4fcb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterViewFilter.tsx @@ -0,0 +1,47 @@ +import { AdvancedFilterViewFilterFieldSelect } from '@/object-record/advanced-filter/components/AdvancedFilterViewFilterFieldSelect'; +import { AdvancedFilterViewFilterOperandSelect } from '@/object-record/advanced-filter/components/AdvancedFilterViewFilterOperandSelect'; +import { AdvancedFilterViewFilterValueInput } from '@/object-record/advanced-filter/components/AdvancedFilterViewFilterValueInput'; +import { useCurrentViewFilter } from '@/object-record/advanced-filter/hooks/useCurrentViewFilter'; +import { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope'; +import { configurableViewFilterOperands } from '@/object-record/object-filter-dropdown/utils/configurableViewFilterOperands'; +import styled from '@emotion/styled'; + +const StyledValueDropdownContainer = styled.div` + flex: 3; +`; + +const StyledRow = styled.div` + flex: 1; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; + white-space: nowrap; + overflow: hidden; +`; + +type AdvancedFilterViewFilterProps = { + viewFilterId: string; +}; + +export const AdvancedFilterViewFilter = ({ + viewFilterId, +}: AdvancedFilterViewFilterProps) => { + const filter = useCurrentViewFilter({ viewFilterId }); + + if (!filter) { + return null; + } + + return ( + + + + + + {configurableViewFilterOperands.has(filter.operand) && ( + + )} + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterViewFilterFieldSelect.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterViewFilterFieldSelect.tsx new file mode 100644 index 000000000000..59ab1505800a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterViewFilterFieldSelect.tsx @@ -0,0 +1,71 @@ +import { useAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterDropdown'; +import { useCurrentViewFilter } from '@/object-record/advanced-filter/hooks/useCurrentViewFilter'; +import { ObjectFilterDropdownFilterSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelect'; +import { ObjectFilterDropdownFilterSelectCompositeFieldSubMenu } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectCompositeFieldSubMenu'; +import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; +import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState'; +import { SelectControl } from '@/ui/input/components/SelectControl'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; +import { ADVANCED_FILTER_DROPDOWN_ID } from '@/views/constants/AdvancedFilterDropdownId'; +import styled from '@emotion/styled'; + +const StyledContainer = styled.div` + flex: 2; +`; + +type AdvancedFilterViewFilterFieldSelectProps = { + viewFilterId: string; +}; + +export const AdvancedFilterViewFilterFieldSelect = ({ + viewFilterId, +}: AdvancedFilterViewFilterFieldSelectProps) => { + const { advancedFilterDropdownId } = useAdvancedFilterDropdown(viewFilterId); + + const filter = useCurrentViewFilter({ viewFilterId }); + + const selectedFieldLabel = filter?.definition.label ?? ''; + + const { setAdvancedFilterViewFilterGroupId, setAdvancedFilterViewFilterId } = + useFilterDropdown(); + + const [objectFilterDropdownIsSelectingCompositeField] = + useRecoilComponentStateV2( + objectFilterDropdownIsSelectingCompositeFieldComponentState, + ); + + const shouldShowCompositeSelectionSubMenu = + objectFilterDropdownIsSelectingCompositeField; + + return ( + + + } + onOpen={() => { + setAdvancedFilterViewFilterId(filter?.id); + setAdvancedFilterViewFilterGroupId(filter?.viewFilterGroupId); + }} + dropdownComponents={ + shouldShowCompositeSelectionSubMenu ? ( + + ) : ( + + ) + } + dropdownHotkeyScope={{ scope: ADVANCED_FILTER_DROPDOWN_ID }} + dropdownOffset={{ y: 8, x: 0 }} + dropdownPlacement="bottom-start" + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterViewFilterGroup.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterViewFilterGroup.tsx new file mode 100644 index 000000000000..40f1f1cca84c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterViewFilterGroup.tsx @@ -0,0 +1,67 @@ +import { AdvancedFilterAddFilterRuleSelect } from '@/object-record/advanced-filter/components/AdvancedFilterAddFilterRuleSelect'; +import { AdvancedFilterLogicalOperatorCell } from '@/object-record/advanced-filter/components/AdvancedFilterLogicalOperatorCell'; +import { AdvancedFilterRuleOptionsDropdown } from '@/object-record/advanced-filter/components/AdvancedFilterRuleOptionsDropdown'; +import { AdvancedFilterViewFilter } from '@/object-record/advanced-filter/components/AdvancedFilterViewFilter'; +import { useCurrentViewViewFilterGroup } from '@/object-record/advanced-filter/hooks/useCurrentViewViewFilterGroup'; +import styled from '@emotion/styled'; + +const StyledRow = styled.div` + display: flex; + width: 100%; + gap: ${({ theme }) => theme.spacing(2)}; +`; + +const StyledContainer = styled.div<{ isGrayBackground?: boolean }>` + align-items: start; + background-color: ${({ theme, isGrayBackground }) => + isGrayBackground ? theme.background.transparent.lighter : 'transparent'}; + border: ${({ theme }) => `1px solid ${theme.border.color.medium}`}; + border-radius: ${({ theme }) => theme.border.radius.md}; + display: flex; + flex: 1; + flex-direction: column; + gap: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(2)}; + overflow: hidden; +`; + +type AdvancedFilterViewFilterGroupProps = { + viewFilterGroupId: string; +}; + +export const AdvancedFilterViewFilterGroup = ({ + viewFilterGroupId, +}: AdvancedFilterViewFilterGroupProps) => { + const { + currentViewFilterGroup, + childViewFiltersAndViewFilterGroups, + lastChildPosition, + } = useCurrentViewViewFilterGroup({ + viewFilterGroupId, + }); + + if (!currentViewFilterGroup) { + return null; + } + + return ( + + {childViewFiltersAndViewFilterGroups.map((child, i) => ( + + + + + + ))} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterViewFilterOperandSelect.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterViewFilterOperandSelect.tsx new file mode 100644 index 000000000000..17ddc5c644f5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterViewFilterOperandSelect.tsx @@ -0,0 +1,110 @@ +import { useCurrentViewFilter } from '@/object-record/advanced-filter/hooks/useCurrentViewFilter'; +import { getInitialFilterValue } from '@/object-record/object-filter-dropdown/utils/getInitialFilterValue'; +import { getOperandLabel } from '@/object-record/object-filter-dropdown/utils/getOperandLabel'; +import { getOperandsForFilterDefinition } from '@/object-record/object-filter-dropdown/utils/getOperandsForFilterType'; +import { SelectControl } from '@/ui/input/components/SelectControl'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; +import { ADVANCED_FILTER_DROPDOWN_ID } from '@/views/constants/AdvancedFilterDropdownId'; +import { useUpsertCombinedViewFilters } from '@/views/hooks/useUpsertCombinedViewFilters'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; +import styled from '@emotion/styled'; +import { isDefined, MenuItem } from 'twenty-ui'; + +const StyledContainer = styled.div` + flex: 1; +`; + +type AdvancedFilterViewFilterOperandSelectProps = { + viewFilterId: string; +}; + +export const AdvancedFilterViewFilterOperandSelect = ({ + viewFilterId, +}: AdvancedFilterViewFilterOperandSelectProps) => { + const dropdownId = `advanced-filter-view-filter-operand-${viewFilterId}`; + + const filter = useCurrentViewFilter({ viewFilterId }); + + const isDisabled = !filter?.fieldMetadataId; + + const { closeDropdown } = useDropdown(dropdownId); + + const { upsertCombinedViewFilter } = useUpsertCombinedViewFilters(); + + const handleOperandChange = (operand: ViewFilterOperand) => { + closeDropdown(); + + if (!filter) { + throw new Error('Filter is not defined'); + } + + const { value, displayValue } = getInitialFilterValue( + filter.definition.type, + operand, + filter.value, + filter.displayValue, + ); + + upsertCombinedViewFilter({ + ...filter, + operand, + value, + displayValue, + }); + }; + + const operandsForFilterType = isDefined(filter?.definition) + ? getOperandsForFilterDefinition(filter.definition) + : []; + + if (isDisabled === true) { + return ( + + ); + } + + return ( + + + } + dropdownComponents={ + + {operandsForFilterType.map((filterOperand, index) => ( + { + handleOperandChange(filterOperand); + }} + text={getOperandLabel(filterOperand)} + /> + ))} + + } + dropdownHotkeyScope={{ scope: ADVANCED_FILTER_DROPDOWN_ID }} + dropdownOffset={{ y: 8, x: 0 }} + dropdownPlacement="bottom-start" + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterViewFilterValueInput.tsx b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterViewFilterValueInput.tsx new file mode 100644 index 000000000000..fdfb17f59f96 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/components/AdvancedFilterViewFilterValueInput.tsx @@ -0,0 +1,70 @@ +import { useCurrentViewFilter } from '@/object-record/advanced-filter/hooks/useCurrentViewFilter'; +import { ObjectFilterDropdownFilterInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput'; +import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; +import { SelectControl } from '@/ui/input/components/SelectControl'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { ADVANCED_FILTER_DROPDOWN_ID } from '@/views/constants/AdvancedFilterDropdownId'; + +type AdvancedFilterViewFilterValueInputProps = { + viewFilterId: string; +}; + +export const AdvancedFilterViewFilterValueInput = ({ + viewFilterId, +}: AdvancedFilterViewFilterValueInputProps) => { + const dropdownId = `advanced-filter-view-filter-value-input-${viewFilterId}`; + + const filter = useCurrentViewFilter({ viewFilterId }); + + const isDisabled = !filter?.fieldMetadataId || !filter.operand; + + const { + setFilterDefinitionUsedInDropdown, + setSelectedOperandInDropdown, + setIsObjectFilterDropdownOperandSelectUnfolded, + setSelectedFilter, + } = useFilterDropdown(); + + if (isDisabled) { + return ( + + ); + } + + return ( + + } + onOpen={() => { + setFilterDefinitionUsedInDropdown(filter.definition); + setSelectedOperandInDropdown(filter.operand); + setIsObjectFilterDropdownOperandSelectUnfolded(true); + setSelectedFilter(filter); + }} + dropdownComponents={ + + + + } + dropdownHotkeyScope={{ scope: ADVANCED_FILTER_DROPDOWN_ID }} + dropdownOffset={{ y: 8, x: 0 }} + dropdownPlacement="bottom-start" + dropdownMenuWidth={280} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/constants/AdvancedFilterLogicalOperatorOptions.ts b/packages/twenty-front/src/modules/object-record/advanced-filter/constants/AdvancedFilterLogicalOperatorOptions.ts new file mode 100644 index 000000000000..a0bd91536066 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/constants/AdvancedFilterLogicalOperatorOptions.ts @@ -0,0 +1,12 @@ +import { ViewFilterGroupLogicalOperator } from '@/views/types/ViewFilterGroupLogicalOperator'; + +export const ADVANCED_FILTER_LOGICAL_OPERATOR_OPTIONS = [ + { + value: ViewFilterGroupLogicalOperator.AND, + label: 'And', + }, + { + value: ViewFilterGroupLogicalOperator.OR, + label: 'Or', + }, +]; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useAdvancedFilterDropdown.ts b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useAdvancedFilterDropdown.ts new file mode 100644 index 000000000000..77cc3f1c0ecd --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useAdvancedFilterDropdown.ts @@ -0,0 +1,14 @@ +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; + +export const useAdvancedFilterDropdown = (viewFilterId?: string) => { + const advancedFilterDropdownId = `advanced-filter-view-filter-field-${viewFilterId}`; + + const { closeDropdown: closeAdvancedFilterDropdown } = useDropdown( + advancedFilterDropdownId, + ); + + return { + closeAdvancedFilterDropdown, + advancedFilterDropdownId, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useCurrentViewFilter.ts b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useCurrentViewFilter.ts new file mode 100644 index 000000000000..40bd802ba286 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useCurrentViewFilter.ts @@ -0,0 +1,31 @@ +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; +import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; +import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; + +export const useCurrentViewFilter = ({ + viewFilterId, +}: { + viewFilterId?: string; +}) => { + const availableFilterDefinitions = useRecoilComponentValueV2( + availableFilterDefinitionsComponentState, + ); + + const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView(); + + const viewFilter = currentViewWithCombinedFiltersAndSorts?.viewFilters.find( + (viewFilter) => viewFilter.id === viewFilterId, + ); + + if (!viewFilter) { + return undefined; + } + + const [filter] = mapViewFiltersToFilters( + [viewFilter], + availableFilterDefinitions, + ); + + return filter; +}; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useCurrentViewViewFilterGroup.ts b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useCurrentViewViewFilterGroup.ts new file mode 100644 index 000000000000..6b38e861a062 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useCurrentViewViewFilterGroup.ts @@ -0,0 +1,59 @@ +import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; +import { ViewFilter } from '@/views/types/ViewFilter'; +import { ViewFilterGroup } from '@/views/types/ViewFilterGroup'; +import { isDefined } from 'twenty-ui'; + +export const useCurrentViewViewFilterGroup = ({ + viewFilterGroupId, +}: { + viewFilterGroupId?: string; +}) => { + const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView(); + + const viewFilterGroup = + currentViewWithCombinedFiltersAndSorts?.viewFilterGroups.find( + (viewFilterGroup) => viewFilterGroup.id === viewFilterGroupId, + ); + + if (!isDefined(viewFilterGroup)) { + return { + currentViewFilterGroup: undefined, + childViewFiltersAndViewFilterGroups: [] as ( + | ViewFilter + | ViewFilterGroup + )[], + }; + } + + const childViewFilters = + currentViewWithCombinedFiltersAndSorts?.viewFilters.filter( + (viewFilterToFilter) => + viewFilterToFilter.viewFilterGroupId === viewFilterGroup.id, + ); + + const childViewFilterGroups = + currentViewWithCombinedFiltersAndSorts?.viewFilterGroups.filter( + (viewFilterGroupToFilter) => + viewFilterGroupToFilter.parentViewFilterGroupId === viewFilterGroup.id, + ); + + const childViewFiltersAndViewFilterGroups = [ + ...(childViewFilterGroups ?? []), + ...(childViewFilters ?? []), + ].sort((a, b) => { + const positionA = a.positionInViewFilterGroup ?? 0; + const positionB = b.positionInViewFilterGroup ?? 0; + return positionA - positionB; + }); + + const lastChildPosition = + childViewFiltersAndViewFilterGroups[ + childViewFiltersAndViewFilterGroups.length - 1 + ]?.positionInViewFilterGroup ?? 0; + + return { + currentViewFilterGroup: viewFilterGroup, + childViewFiltersAndViewFilterGroups, + lastChildPosition, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useDeleteCombinedViewFilterGroup.ts b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useDeleteCombinedViewFilterGroup.ts new file mode 100644 index 000000000000..b6149454c89e --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useDeleteCombinedViewFilterGroup.ts @@ -0,0 +1,111 @@ +import { useRecoilCallback } from 'recoil'; + +import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { useGetViewFromCache } from '@/views/hooks/useGetViewFromCache'; +import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState'; +import { unsavedToDeleteViewFilterGroupIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterGroupIdsComponentFamilyState'; +import { unsavedToUpsertViewFilterGroupsComponentFamilyState } from '@/views/states/unsavedToUpsertViewFilterGroupsComponentFamilyState'; +import { isDefined } from '~/utils/isDefined'; + +export const useDeleteCombinedViewFilterGroup = ( + viewBarComponentId?: string, +) => { + const unsavedToUpsertViewFilterGroupsCallbackState = + useRecoilComponentCallbackStateV2( + unsavedToUpsertViewFilterGroupsComponentFamilyState, + viewBarComponentId, + ); + + const unsavedToDeleteViewFilterGroupIdsCallbackState = + useRecoilComponentCallbackStateV2( + unsavedToDeleteViewFilterGroupIdsComponentFamilyState, + viewBarComponentId, + ); + + const currentViewIdCallbackState = useRecoilComponentCallbackStateV2( + currentViewIdComponentState, + viewBarComponentId, + ); + + const { getViewFromCache } = useGetViewFromCache(); + + const deleteCombinedViewFilterGroup = useRecoilCallback( + ({ snapshot, set }) => + async (filterGroupId: string) => { + const currentViewId = getSnapshotValue( + snapshot, + currentViewIdCallbackState, + ); + + const unsavedToUpsertViewFilterGroups = getSnapshotValue( + snapshot, + unsavedToUpsertViewFilterGroupsCallbackState({ + viewId: currentViewId, + }), + ); + + const unsavedToDeleteViewFilterGroupIds = getSnapshotValue( + snapshot, + unsavedToDeleteViewFilterGroupIdsCallbackState({ + viewId: currentViewId, + }), + ); + + if (!currentViewId) { + return; + } + + const currentView = await getViewFromCache(currentViewId); + + if (!currentView) { + return; + } + + const matchingFilterGroupInCurrentView = + currentView.viewFilterGroups?.find( + (viewFilterGroup) => viewFilterGroup.id === filterGroupId, + ); + + const matchingFilterGroupInUnsavedFilterGroups = + unsavedToUpsertViewFilterGroups.find( + (viewFilterGroup) => viewFilterGroup.id === filterGroupId, + ); + + if (isDefined(matchingFilterGroupInUnsavedFilterGroups)) { + set( + unsavedToUpsertViewFilterGroupsCallbackState({ + viewId: currentViewId, + }), + unsavedToUpsertViewFilterGroups.filter( + (viewFilterGroup) => viewFilterGroup.id !== filterGroupId, + ), + ); + } + + if (isDefined(matchingFilterGroupInCurrentView)) { + set( + unsavedToDeleteViewFilterGroupIdsCallbackState({ + viewId: currentViewId, + }), + [ + ...new Set([ + ...unsavedToDeleteViewFilterGroupIds, + matchingFilterGroupInCurrentView.id, + ]), + ], + ); + } + }, + [ + currentViewIdCallbackState, + getViewFromCache, + unsavedToDeleteViewFilterGroupIdsCallbackState, + unsavedToUpsertViewFilterGroupsCallbackState, + ], + ); + + return { + deleteCombinedViewFilterGroup, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useUpsertCombinedViewFilterGroup.ts b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useUpsertCombinedViewFilterGroup.ts new file mode 100644 index 000000000000..d4d8c199200d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/advanced-filter/hooks/useUpsertCombinedViewFilterGroup.ts @@ -0,0 +1,54 @@ +import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; +import { useAvailableComponentInstanceIdOrThrow } from '@/ui/utilities/state/component-state/hooks/useAvailableComponentInstanceIdOrThrow'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; +import { unsavedToUpsertViewFilterGroupsComponentFamilyState } from '@/views/states/unsavedToUpsertViewFilterGroupsComponentFamilyState'; +import { ViewFilterGroup } from '@/views/types/ViewFilterGroup'; +import { useRecoilCallback } from 'recoil'; + +export const useUpsertCombinedViewFilterGroup = () => { + const instanceId = useAvailableComponentInstanceIdOrThrow( + ViewComponentInstanceContext, + ); + + const unsavedToUpsertViewFilterGroupsCallbackState = + useRecoilComponentCallbackStateV2( + unsavedToUpsertViewFilterGroupsComponentFamilyState, + instanceId, + ); + + const upsertCombinedViewFilterGroup = useRecoilCallback( + ({ snapshot, set }) => + (newViewFilterGroup: Omit) => { + const currentViewUnsavedToUpsertViewFilterGroups = + unsavedToUpsertViewFilterGroupsCallbackState({ + viewId: newViewFilterGroup.viewId, + }); + + const unsavedToUpsertViewFilterGroups = getSnapshotValue( + snapshot, + currentViewUnsavedToUpsertViewFilterGroups, + ); + + const newViewFilterWithTypename: ViewFilterGroup = { + ...newViewFilterGroup, + __typename: 'ViewFilterGroup', + }; + + set( + unsavedToUpsertViewFilterGroupsCallbackState({ + viewId: newViewFilterGroup.viewId, + }), + [ + ...unsavedToUpsertViewFilterGroups.filter( + (viewFilterGroup) => viewFilterGroup.id !== newViewFilterGroup.id, + ), + newViewFilterWithTypename, + ], + ); + }, + [unsavedToUpsertViewFilterGroupsCallbackState], + ); + + return { upsertCombinedViewFilterGroup }; +}; diff --git a/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx b/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx index 16b207dbe12f..6a7a51e4b563 100644 --- a/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx +++ b/packages/twenty-front/src/modules/object-record/components/RecordChip.tsx @@ -3,7 +3,6 @@ import { AvatarChip, AvatarChipVariant } from 'twenty-ui'; import { getLinkToShowPage } from '@/object-metadata/utils/getLinkToShowPage'; import { useRecordChipData } from '@/object-record/hooks/useRecordChipData'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink'; import { MouseEvent } from 'react'; export type RecordChipProps = { @@ -24,24 +23,20 @@ export const RecordChip = ({ record, }); - const handleClick = (e: MouseEvent) => { + const handleClick = (e: MouseEvent) => { e.stopPropagation(); }; return ( - - {}} - /> - + /> ); }; diff --git a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts index 6c1615b17afa..9393d98b841a 100644 --- a/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts +++ b/packages/twenty-front/src/modules/object-record/graphql/types/RecordGqlOperationFilter.ts @@ -104,6 +104,17 @@ export type PhonesFilter = { primaryPhoneCountryCode?: StringFilter; }; +export type ArrayFilter = { + contains?: string[]; + not_contains?: string[]; + is?: IsFilter; +}; + +export type RawJsonFilter = { + like?: string; + is?: IsFilter; +}; + export type LeafFilter = | UUIDFilter | StringFilter @@ -117,6 +128,8 @@ export type LeafFilter = | LinksFilter | ActorFilter | PhonesFilter + | ArrayFilter + | RawJsonFilter | undefined; export type AndObjectRecordFilter = { diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/personFragments.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/personFragments.ts index 21cf8b2848b2..e9193143e11d 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/personFragments.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/personFragments.ts @@ -17,7 +17,7 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS = ` id intro jobTitle - linkedinLink{ + linkedinLink { primaryLinkUrl primaryLinkLabel secondaryLinks @@ -45,31 +45,14 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ZERO_RELATIONS = ` primaryLinkLabel secondaryLinks } -` +`; export const PERSON_FRAGMENT_WITH_DEPTH_ONE_RELATIONS = ` __typename - activityTargets { - edges { - node { - __typename - activityId - companyId - createdAt - deletedAt - id - opportunityId - personId - rocketId - updatedAt - } - } - } attachments { edges { node { __typename - activityId authorId companyId createdAt @@ -190,6 +173,8 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ONE_RELATIONS = ` updatedAt viewId workflowId + workflowRunId + workflowVersionId workspaceMemberId } } @@ -308,6 +293,9 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ONE_RELATIONS = ` rocketId taskId updatedAt + workflowId + workflowRunId + workflowVersionId workspaceMemberId } } @@ -324,4 +312,4 @@ export const PERSON_FRAGMENT_WITH_DEPTH_ONE_RELATIONS = ` primaryLinkLabel secondaryLinks } -` +`; diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useDeleteOneRecord.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useDeleteOneRecord.ts index 2e7ce9bc5320..b04513123608 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useDeleteOneRecord.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useDeleteOneRecord.ts @@ -15,5 +15,7 @@ export const variables = { }; export const responseData = { + __typename: 'Person', + deletedAt: '2024-02-14T09:45:00Z', id: 'a7286b9a-c039-4a89-9567-2dfa7953cda9', }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFetchAllRecordIds.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFetchAllRecordIds.ts index 3a15abc83441..d63ae538c2e8 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFetchAllRecordIds.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFetchAllRecordIds.ts @@ -3,10 +3,19 @@ import { gql } from '@apollo/client'; import { peopleQueryResult } from '~/testing/mock-data/people'; - export const query = gql` - query FindManyPeople($filter: PersonFilterInput, $orderBy: [PersonOrderByInput], $lastCursor: String, $limit: Int) { - people(filter: $filter, orderBy: $orderBy, first: $limit, after: $lastCursor){ + query FindManyPeople( + $filter: PersonFilterInput + $orderBy: [PersonOrderByInput] + $lastCursor: String + $limit: Int + ) { + people( + filter: $filter + orderBy: $orderBy + first: $limit + after: $lastCursor + ) { edges { node { __typename @@ -27,38 +36,51 @@ export const query = gql` export const mockPageSize = 2; -export const peopleMockWithIdsOnly: RecordGqlConnection = { ...peopleQueryResult.people,edges: peopleQueryResult.people.edges.map((edge) => ({ ...edge, node: { __typename: 'Person', id: edge.node.id } })) }; +export const peopleMockWithIdsOnly: RecordGqlConnection = { + ...peopleQueryResult.people, + edges: peopleQueryResult.people.edges.map((edge) => ({ + ...edge, + node: { __typename: 'Person', id: edge.node.id }, + })), +}; -export const firstRequestLastCursor = peopleMockWithIdsOnly.edges[mockPageSize].cursor; -export const secondRequestLastCursor = peopleMockWithIdsOnly.edges[mockPageSize * 2].cursor; -export const thirdRequestLastCursor = peopleMockWithIdsOnly.edges[mockPageSize * 3].cursor; +export const firstRequestLastCursor = + peopleMockWithIdsOnly.edges[mockPageSize].cursor; +export const secondRequestLastCursor = + peopleMockWithIdsOnly.edges[mockPageSize * 2].cursor; +export const thirdRequestLastCursor = + peopleMockWithIdsOnly.edges[mockPageSize * 3].cursor; export const variablesFirstRequest = { filter: undefined, limit: mockPageSize, - orderBy: undefined + orderBy: undefined, }; export const variablesSecondRequest = { filter: undefined, limit: mockPageSize, orderBy: undefined, - lastCursor: firstRequestLastCursor + lastCursor: firstRequestLastCursor, }; export const variablesThirdRequest = { filter: undefined, limit: mockPageSize, orderBy: undefined, - lastCursor: secondRequestLastCursor -} + lastCursor: secondRequestLastCursor, +}; -const paginateRequestResponse = (response: RecordGqlConnection, start: number, end: number, hasNextPage: boolean, totalCount: number) => { +const paginateRequestResponse = ( + response: RecordGqlConnection, + start: number, + end: number, + hasNextPage: boolean, + totalCount: number, +) => { return { ...response, - edges: [ - ...response.edges.slice(start, end) - ], + edges: [...response.edges.slice(start, end)], pageInfo: { ...response.pageInfo, startCursor: response.edges[start].cursor, @@ -66,17 +88,35 @@ const paginateRequestResponse = (response: RecordGqlConnection, start: number, e hasNextPage, } satisfies RecordGqlConnection['pageInfo'], totalCount, - } -} + }; +}; export const responseFirstRequest = { - people: paginateRequestResponse(peopleMockWithIdsOnly, 0, mockPageSize, true, 6), + people: paginateRequestResponse( + peopleMockWithIdsOnly, + 0, + mockPageSize, + true, + 6, + ), }; export const responseSecondRequest = { - people: paginateRequestResponse(peopleMockWithIdsOnly, mockPageSize, mockPageSize * 2, true, 6), + people: paginateRequestResponse( + peopleMockWithIdsOnly, + mockPageSize, + mockPageSize * 2, + true, + 6, + ), }; export const responseThirdRequest = { - people: paginateRequestResponse(peopleMockWithIdsOnly, mockPageSize * 2, mockPageSize * 3, false, 6), + people: paginateRequestResponse( + peopleMockWithIdsOnly, + mockPageSize * 2, + mockPageSize * 3, + false, + 6, + ), }; diff --git a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindManyRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindManyRecords.ts index 1ed7b456fca0..d250110737bd 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindManyRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/__mocks__/useFindManyRecords.ts @@ -99,20 +99,6 @@ export const query = gql` } city email - activityTargets { - edges { - node { - __typename - id - updatedAt - createdAt - personId - activityId - companyId - id - } - } - } jobTitle favorites { edges { @@ -137,7 +123,6 @@ export const query = gql` createdAt name personId - activityId companyId id authorId diff --git a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useObjectRecordTable.test.tsx b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useObjectRecordTable.test.tsx index 4cd4cdbffc11..58828525ea7b 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/__tests__/useObjectRecordTable.test.tsx +++ b/packages/twenty-front/src/modules/object-record/hooks/__tests__/useObjectRecordTable.test.tsx @@ -4,8 +4,7 @@ import { ReactNode } from 'react'; import { mocks } from '@/auth/hooks/__mocks__/useAuth'; import { useLoadRecordIndexTable } from '@/object-record/record-index/hooks/useLoadRecordIndexTable'; -import { RecordTableScope } from '@/object-record/record-table/scopes/RecordTableScope'; -import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; +import { RecordTableComponentInstance } from '@/object-record/record-table/components/RecordTableComponentInstance'; import { getJestMetadataAndApolloMocksWrapper } from '~/testing/jest/getJestMetadataAndApolloMocksWrapper'; const recordTableId = 'people'; @@ -24,12 +23,12 @@ const Wrapper = ({ children }: { children: ReactNode }) => { return ( - {children} - + ); diff --git a/packages/twenty-front/src/modules/object-record/hooks/useRecordChipData.ts b/packages/twenty-front/src/modules/object-record/hooks/useRecordChipData.ts index 1958a09eb535..7ade7cb90699 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useRecordChipData.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useRecordChipData.ts @@ -1,4 +1,4 @@ -import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext'; +import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext'; import { generateDefaultRecordChipData } from '@/object-metadata/utils/generateDefaultRecordChipData'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; import { useContext } from 'react'; diff --git a/packages/twenty-front/src/modules/object-record/hooks/useSearchRecords.ts b/packages/twenty-front/src/modules/object-record/hooks/useSearchRecords.ts index 175f84554f19..7c1f90162597 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useSearchRecords.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useSearchRecords.ts @@ -13,10 +13,11 @@ import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { useQuery, WatchQueryFetchPolicy } from '@apollo/client'; import { useMemo } from 'react'; import { useRecoilValue } from 'recoil'; +import { isDefined } from '~/utils/isDefined'; import { logError } from '~/utils/logError'; export type UseSearchRecordsParams = ObjectMetadataItemIdentifier & - RecordGqlOperationVariables & { + Pick & { onError?: (error?: Error) => void; skip?: boolean; recordGqlFields?: RecordGqlOperationGqlRecordFields; @@ -29,6 +30,7 @@ export const useSearchRecords = ({ searchInput, limit, skip, + filter, recordGqlFields, fetchPolicy, }: UseSearchRecordsParams) => { @@ -45,10 +47,14 @@ export const useSearchRecords = ({ const { data, loading, error, previousData } = useQuery(searchRecordsQuery, { skip: - skip || !objectMetadataItem || !currentWorkspaceMember || !searchInput, + skip || + !objectMetadataItem || + !currentWorkspaceMember || + !isDefined(searchInput), variables: { search: searchInput, limit: limit, + filter: filter, }, fetchPolicy: fetchPolicy, onError: (error) => { diff --git a/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useGenerateCombinedSearchRecordsQuery.ts b/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useGenerateCombinedSearchRecordsQuery.ts new file mode 100644 index 000000000000..fc7725c3ae90 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/multiple-objects/hooks/useGenerateCombinedSearchRecordsQuery.ts @@ -0,0 +1,96 @@ +import { gql } from '@apollo/client'; +import { isUndefined } from '@sniptt/guards'; +import { useRecoilValue } from 'recoil'; + +import { objectMetadataItemsState } from '@/object-metadata/states/objectMetadataItemsState'; +import { mapObjectMetadataToGraphQLQuery } from '@/object-metadata/utils/mapObjectMetadataToGraphQLQuery'; +import { RecordGqlOperationSignature } from '@/object-record/graphql/types/RecordGqlOperationSignature'; +import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields'; +import { getSearchRecordsQueryResponseField } from '@/object-record/utils/getSearchRecordsQueryResponseField'; +import { isObjectMetadataItemSearchable } from '@/object-record/utils/isObjectMetadataItemSearchable'; +import { isNonEmptyArray } from '~/utils/isNonEmptyArray'; +import { capitalize } from '~/utils/string/capitalize'; + +export const useGenerateCombinedSearchRecordsQuery = ({ + operationSignatures, +}: { + operationSignatures: RecordGqlOperationSignature[]; +}) => { + const objectMetadataItems = useRecoilValue(objectMetadataItemsState); + + if (!isNonEmptyArray(operationSignatures)) { + return null; + } + + const filterPerMetadataItemArray = operationSignatures + .map( + ({ objectNameSingular }) => + `$filter${capitalize(objectNameSingular)}: ${capitalize( + objectNameSingular, + )}FilterInput`, + ) + .join(', '); + + const limitPerMetadataItemArray = operationSignatures + .map( + ({ objectNameSingular }) => + `$limit${capitalize(objectNameSingular)}: Int`, + ) + .join(', '); + + const queryKeyWithObjectMetadataItemArray = operationSignatures.map( + (queryKey) => { + const objectMetadataItem = objectMetadataItems.find( + (objectMetadataItem) => + objectMetadataItem.nameSingular === queryKey.objectNameSingular, + ); + + if (isUndefined(objectMetadataItem)) { + throw new Error( + `Object metadata item not found for object name singular: ${queryKey.objectNameSingular}`, + ); + } + + return { ...queryKey, objectMetadataItem }; + }, + ); + + const filteredQueryKeyWithObjectMetadataItemArray = + queryKeyWithObjectMetadataItemArray.filter(({ objectMetadataItem }) => + isObjectMetadataItemSearchable(objectMetadataItem), + ); + + return gql` + query CombinedSearchRecords( + ${filterPerMetadataItemArray}, + ${limitPerMetadataItemArray}, + $search: String, + ) { + ${filteredQueryKeyWithObjectMetadataItemArray + .map( + ({ objectMetadataItem, fields }) => + `${getSearchRecordsQueryResponseField(objectMetadataItem.namePlural)}(filter: $filter${capitalize( + objectMetadataItem.nameSingular, + )}, + limit: $limit${capitalize(objectMetadataItem.nameSingular)}, + searchInput: $search + ){ + edges { + node ${mapObjectMetadataToGraphQLQuery({ + objectMetadataItems: objectMetadataItems, + objectMetadataItem, + recordGqlFields: + fields ?? + generateDepthOneRecordGqlFields({ + objectMetadataItem, + }), + })} + cursor + } + totalCount + }`, + ) + .join('\n')} + } + `; +}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/AddObjectFilterFromDetailsButton.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/AddObjectFilterFromDetailsButton.tsx index 1fb257f79baf..21d407471462 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/AddObjectFilterFromDetailsButton.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/AddObjectFilterFromDetailsButton.tsx @@ -1,8 +1,7 @@ -import { IconPlus } from 'twenty-ui'; +import { IconPlus, LightButton } from 'twenty-ui'; import { OBJECT_FILTER_DROPDOWN_ID } from '@/object-record/object-filter-dropdown/constants/ObjectFilterDropdownId'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; -import { LightButton } from '@/ui/input/button/components/LightButton'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; type AddObjectFilterFromDetailsButtonProps = { diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/AdvancedFilterButton.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/AdvancedFilterButton.tsx new file mode 100644 index 000000000000..542aed2e14db --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/AdvancedFilterButton.tsx @@ -0,0 +1,127 @@ +import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; +import { useUpsertCombinedViewFilterGroup } from '@/object-record/advanced-filter/hooks/useUpsertCombinedViewFilterGroup'; +import { OBJECT_FILTER_DROPDOWN_ID } from '@/object-record/object-filter-dropdown/constants/ObjectFilterDropdownId'; +import { getOperandsForFilterDefinition } from '@/object-record/object-filter-dropdown/utils/getOperandsForFilterType'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { ADVANCED_FILTER_DROPDOWN_ID } from '@/views/constants/AdvancedFilterDropdownId'; +import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; +import { useUpsertCombinedViewFilters } from '@/views/hooks/useUpsertCombinedViewFilters'; +import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; +import { ViewFilterGroupLogicalOperator } from '@/views/types/ViewFilterGroupLogicalOperator'; +import styled from '@emotion/styled'; +import { + IconFilter, + MenuItemLeftContent, + Pill, + StyledMenuItemBase, +} from 'twenty-ui'; +import { v4 } from 'uuid'; + +export const StyledContainer = styled.div` + align-items: center; + display: flex; + justify-content: space-between; + padding: ${({ theme }) => theme.spacing(1)}; + border-top: 1px solid ${({ theme }) => theme.border.color.light}; +`; + +export const StyledMenuItemSelect = styled(StyledMenuItemBase)` + &:hover { + background: ${({ theme }) => theme.background.transparent.light}; + } +`; + +export const StyledPill = styled(Pill)` + background: ${({ theme }) => theme.color.blueAccent10}; + color: ${({ theme }) => theme.color.blue}; +`; + +export const AdvancedFilterButton = () => { + const advancedFilterQuerySubFilterCount = 0; // TODO + + const { openDropdown: openAdvancedFilterDropdown } = useDropdown( + ADVANCED_FILTER_DROPDOWN_ID, + ); + + const { closeDropdown: closeObjectFilterDropdown } = useDropdown( + OBJECT_FILTER_DROPDOWN_ID, + ); + + const { currentViewId, currentViewWithCombinedFiltersAndSorts } = + useGetCurrentView(); + + const { upsertCombinedViewFilterGroup } = useUpsertCombinedViewFilterGroup(); + + const { upsertCombinedViewFilter } = useUpsertCombinedViewFilters(); + + const objectMetadataId = + currentViewWithCombinedFiltersAndSorts?.objectMetadataId; + + if (!objectMetadataId) { + throw new Error('Object metadata id is missing from current view'); + } + + const { objectMetadataItem } = useObjectMetadataItemById({ + objectId: objectMetadataId ?? null, + }); + + const availableFilterDefinitions = useRecoilComponentValueV2( + availableFilterDefinitionsComponentState, + ); + + const handleClick = () => { + if (!currentViewId) { + throw new Error('Missing current view id'); + } + + const alreadyHasAdvancedFilterGroup = + (currentViewWithCombinedFiltersAndSorts?.viewFilterGroups?.length ?? 0) > + 0; + + if (!alreadyHasAdvancedFilterGroup) { + const newViewFilterGroup = { + id: v4(), + viewId: currentViewId, + logicalOperator: ViewFilterGroupLogicalOperator.AND, + }; + + upsertCombinedViewFilterGroup(newViewFilterGroup); + + const defaultFilterDefinition = + availableFilterDefinitions.find( + (filterDefinition) => + filterDefinition.fieldMetadataId === + objectMetadataItem?.labelIdentifierFieldMetadataId, + ) ?? availableFilterDefinitions?.[0]; + + if (!defaultFilterDefinition) { + throw new Error('Missing default filter definition'); + } + + upsertCombinedViewFilter({ + id: v4(), + fieldMetadataId: defaultFilterDefinition.fieldMetadataId, + operand: getOperandsForFilterDefinition(defaultFilterDefinition)[0], + definition: defaultFilterDefinition, + value: '', + displayValue: '', + viewFilterGroupId: newViewFilterGroup.id, + }); + } + + openAdvancedFilterDropdown(); + closeObjectFilterDropdown(); + }; + + return ( + + + + {advancedFilterQuerySubFilterCount > 0 && ( + + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx index 1e019b6c77e5..43f9e4faf4f3 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent.tsx @@ -1,19 +1,14 @@ import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; -import { ObjectFilterDropdownFilterInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput'; import { ObjectFilterDropdownFilterSelectCompositeFieldSubMenu } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectCompositeFieldSubMenu'; +import { ObjectFilterOperandSelectAndInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterOperandSelectAndInput'; import { objectFilterDropdownFilterIsSelectedComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownFilterIsSelectedComponentState'; import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState'; import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; -import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; import { MultipleFiltersDropdownFilterOnFilterChangedEffect } from './MultipleFiltersDropdownFilterOnFilterChangedEffect'; import { ObjectFilterDropdownFilterSelect } from './ObjectFilterDropdownFilterSelect'; -const StyledContainer = styled.div` - position: relative; -`; - type MultipleFiltersDropdownContentProps = { filterDropdownId?: string; }; @@ -46,19 +41,21 @@ export const MultipleFiltersDropdownContent = ({ const shoudShowFilterInput = objectFilterDropdownFilterIsSelected; return ( - + <> {shoudShowFilterInput ? ( - + ) : shouldShowCompositeSelectionSubMenu ? ( ) : ( - + )} - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownFilterOnFilterChangedEffect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownFilterOnFilterChangedEffect.tsx index c70ff89521c5..5b8d12162be4 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownFilterOnFilterChangedEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/MultipleFiltersDropdownFilterOnFilterChangedEffect.tsx @@ -16,7 +16,7 @@ export const MultipleFiltersDropdownFilterOnFilterChangedEffect = ({ setDropdownWidth(280); break; default: - setDropdownWidth(160); + setDropdownWidth(200); } }, [filterDefinitionUsedInDropdownType, setDropdownWidth]); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx index 3961f28c836b..ead532780b98 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput.tsx @@ -6,12 +6,12 @@ import { Filter } from '@/object-record/object-filter-dropdown/types/Filter'; import { getRelativeDateDisplayValue } from '@/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue'; import { InternalDatePicker } from '@/ui/input/components/internal/date/components/InternalDatePicker'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; -import { computeVariableDateViewFilterValue } from '@/views/utils/view-filter-value/computeVariableDateViewFilterValue'; +import { computeVariableDateViewFilterValue } from '@/views/view-filter-value/utils/computeVariableDateViewFilterValue'; import { VariableDateViewFilterValueDirection, VariableDateViewFilterValueUnit, -} from '@/views/utils/view-filter-value/resolveDateViewFilterValue'; -import { resolveFilterValue } from '@/views/utils/view-filter-value/resolveFilterValue'; +} from '@/views/view-filter-value/utils/resolveDateViewFilterValue'; +import { resolveFilterValue } from '@/views/view-filter-value/utils/resolveFilterValue'; import { useState } from 'react'; import { isDefined } from 'twenty-ui'; import { FieldMetadataType } from '~/generated-metadata/graphql'; @@ -63,6 +63,7 @@ export const ObjectFilterDropdownDateInput = () => { : newDate.toLocaleDateString() : '', definition: filterDefinitionUsedInDropdown, + viewFilterGroupId: selectedFilter?.viewFilterGroupId, }); setIsObjectFilterDropdownUnfolded(false); @@ -92,6 +93,7 @@ export const ObjectFilterDropdownDateInput = () => { operand: selectedOperandInDropdown, displayValue: getRelativeDateDisplayValue(relativeDate), definition: filterDefinitionUsedInDropdown, + viewFilterGroupId: selectedFilter?.viewFilterGroupId, }); setIsObjectFilterDropdownUnfolded(false); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx index a630286ffadb..050fb90218bc 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput.tsx @@ -2,8 +2,6 @@ import { useRecoilValue } from 'recoil'; import { ObjectFilterDropdownDateInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownDateInput'; import { ObjectFilterDropdownNumberInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownNumberInput'; -import { ObjectFilterDropdownOperandButton } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandButton'; -import { ObjectFilterDropdownOperandSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandSelect'; import { ObjectFilterDropdownOptionSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect'; import { ObjectFilterDropdownRatingInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRatingInput'; import { ObjectFilterDropdownRecordSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect'; @@ -14,19 +12,11 @@ import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/ import { isActorSourceCompositeFilter } from '@/object-record/object-filter-dropdown/utils/isActorSourceCompositeFilter'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; -import styled from '@emotion/styled'; import { isDefined } from 'twenty-ui'; -const StyledOperandSelectContainer = styled.div` - background: ${({ theme }) => theme.background.secondary}; - box-shadow: ${({ theme }) => theme.boxShadow.light}; - border-radius: ${({ theme }) => theme.border.radius.md}; - left: 10px; - position: absolute; - top: 10px; - width: 100%; - z-index: 1000; -`; +import { DATE_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/DateFilterTypes'; +import { NUMBER_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/NumberFilterTypes'; +import { TEXT_FILTER_TYPES } from '@/object-record/object-filter-dropdown/constants/TextFilterTypes'; type ObjectFilterDropdownFilterInputProps = { filterDropdownId?: string; @@ -68,45 +58,29 @@ export const ObjectFilterDropdownFilterInput = ({ ViewFilterOperand.IsRelative, ].includes(selectedOperandInDropdown); - if (!isDefined(filterDefinitionUsedInDropdown)) { + const shouldHide = isObjectFilterDropdownOperandSelectUnfolded; + + if (shouldHide || !isDefined(filterDefinitionUsedInDropdown)) { return null; } return ( <> - - {isObjectFilterDropdownOperandSelectUnfolded && ( - - - - )} {isConfigurable && selectedOperandInDropdown && ( <> - {[ - 'TEXT', - 'EMAIL', - 'EMAILS', - 'PHONE', - 'FULL_NAME', - 'LINK', - 'LINKS', - 'ADDRESS', - 'ACTOR', - 'ARRAY', - 'PHONES', - ].includes(filterDefinitionUsedInDropdown.type) && + {TEXT_FILTER_TYPES.includes(filterDefinitionUsedInDropdown.type) && !isActorSourceCompositeFilter(filterDefinitionUsedInDropdown) && ( )} - {['NUMBER', 'CURRENCY'].includes( + {NUMBER_FILTER_TYPES.includes( filterDefinitionUsedInDropdown.type, ) && } {filterDefinitionUsedInDropdown.type === 'RATING' && ( )} - {['DATE_TIME', 'DATE'].includes( - filterDefinitionUsedInDropdown.type, - ) && } + {DATE_FILTER_TYPES.includes(filterDefinitionUsedInDropdown.type) && ( + + )} {filterDefinitionUsedInDropdown.type === 'RELATION' && ( <> diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterOperandSelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterOperandSelect.tsx new file mode 100644 index 000000000000..4aad6ec39d47 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterOperandSelect.tsx @@ -0,0 +1,38 @@ +import { ObjectFilterDropdownOperandButton } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandButton'; +import { ObjectFilterDropdownOperandSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandSelect'; +import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; +import styled from '@emotion/styled'; +import { useRecoilValue } from 'recoil'; + +const StyledOperandSelectContainer = styled.div` + background: ${({ theme }) => theme.background.secondary}; + box-shadow: ${({ theme }) => theme.boxShadow.light}; + border-radius: ${({ theme }) => theme.border.radius.md}; + + width: 100%; + z-index: 1000; +`; + +export const ObjectFilterDropdownFilterOperandSelect = ({ + filterDropdownId, +}: { + filterDropdownId?: string; +}) => { + const { isObjectFilterDropdownOperandSelectUnfoldedState } = + useFilterDropdown({ filterDropdownId }); + + const isObjectFilterDropdownOperandSelectUnfolded = useRecoilValue( + isObjectFilterDropdownOperandSelectUnfoldedState, + ); + + return ( + <> + + {isObjectFilterDropdownOperandSelectUnfolded && ( + + + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelect.tsx index 9c815948dc28..e97dd2c59028 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelect.tsx @@ -1,21 +1,27 @@ import styled from '@emotion/styled'; -import { useContext } from 'react'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterDropdown'; +import { AdvancedFilterButton } from '@/object-record/object-filter-dropdown/components/AdvancedFilterButton'; import { ObjectFilterDropdownFilterSelectMenuItem } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectMenuItem'; import { OBJECT_FILTER_DROPDOWN_ID } from '@/object-record/object-filter-dropdown/constants/ObjectFilterDropdownId'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; import { useSelectFilter } from '@/object-record/object-filter-dropdown/hooks/useSelectFilter'; import { FiltersHotkeyScope } from '@/object-record/object-filter-dropdown/types/FiltersHotkeyScope'; import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; -import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; +import { hiddenTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/hiddenTableColumnsComponentSelector'; +import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { SelectableItem } from '@/ui/layout/selectable-list/components/SelectableItem'; import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; +import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; +import { useContext } from 'react'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-ui'; @@ -31,7 +37,7 @@ export const StyledInput = styled.input` margin: 0; outline: none; padding: ${({ theme }) => theme.spacing(2)}; - height: 19px; + min-height: 19px; font-family: inherit; font-size: ${({ theme }) => theme.font.size.sm}; @@ -45,12 +51,29 @@ export const StyledInput = styled.input` } `; -export const ObjectFilterDropdownFilterSelect = () => { +type ObjectFilterDropdownFilterSelectProps = { + isAdvancedFilterButtonVisible?: boolean; +}; + +export const ObjectFilterDropdownFilterSelect = ({ + isAdvancedFilterButtonVisible, +}: ObjectFilterDropdownFilterSelectProps) => { + const { recordIndexId } = useContext(RecordIndexRootPropsContext); + const { setObjectFilterDropdownSearchInput, objectFilterDropdownSearchInputState, + advancedFilterViewFilterIdState, } = useFilterDropdown(); + const advancedFilterViewFilterId = useRecoilValue( + advancedFilterViewFilterIdState, + ); + + const { closeAdvancedFilterDropdown } = useAdvancedFilterDropdown( + advancedFilterViewFilterId, + ); + const objectFilterDropdownSearchInput = useRecoilValue( objectFilterDropdownSearchInputState, ); @@ -58,15 +81,18 @@ export const ObjectFilterDropdownFilterSelect = () => { const availableFilterDefinitions = useRecoilComponentValueV2( availableFilterDefinitionsComponentState, ); - const { recordIndexId } = useContext(RecordIndexRootPropsContext); - const { hiddenTableColumnsSelector, visibleTableColumnsSelector } = - useRecordTableStates(recordIndexId); - const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector()); + const visibleTableColumns = useRecoilComponentValueV2( + visibleTableColumnsComponentSelector, + recordIndexId, + ); const visibleColumnsIds = visibleTableColumns.map( (column) => column.fieldMetadataId, ); - const hiddenTableColumns = useRecoilValue(hiddenTableColumnsSelector()); + const hiddenTableColumns = useRecoilComponentValueV2( + hiddenTableColumnsComponentSelector, + recordIndexId, + ); const hiddenColumnIds = hiddenTableColumns.map( (column) => column.fieldMetadataId, ); @@ -110,14 +136,27 @@ export const ObjectFilterDropdownFilterSelect = () => { } resetSelectedItem(); - selectFilter({ filterDefinition: selectedFilterDefinition }); + closeAdvancedFilterDropdown(); }; const shoudShowSeparator = visibleColumnsFilterDefinitions.length > 0 && hiddenColumnsFilterDefinitions.length > 0; + const { currentViewId, currentViewWithCombinedFiltersAndSorts } = + useGetCurrentView(); + + const isAdvancedFiltersEnabled = useIsFeatureEnabled( + 'IS_ADVANCED_FILTERS_ENABLED', + ); + + const shouldShowAdvancedFilterButton = + isDefined(currentViewId) && + isDefined(currentViewWithCombinedFiltersAndSorts?.objectMetadataId) && + isAdvancedFilterButtonVisible && + isAdvancedFiltersEnabled; + return ( <> { setObjectFilterDropdownSearchInput(event.target.value) } /> - - - {visibleColumnsFilterDefinitions.map( - (visibleFilterDefinition, index) => ( - - - - ), - )} - - {shoudShowSeparator && } - - {hiddenColumnsFilterDefinitions.map( - (hiddenFilterDefinition, index) => ( - - - - ), - )} - - + + + + {visibleColumnsFilterDefinitions.map( + (visibleFilterDefinition, index) => ( + + + + ), + )} + + {shoudShowSeparator && } + + {hiddenColumnsFilterDefinitions.map( + (hiddenFilterDefinition, index) => ( + + + + ), + )} + + + {shouldShowAdvancedFilterButton && } + ); }; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectCompositeFieldSubMenu.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectCompositeFieldSubMenu.tsx index 6fd8b778dc14..c364bb90eb6a 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectCompositeFieldSubMenu.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectCompositeFieldSubMenu.tsx @@ -1,3 +1,4 @@ +import { useAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterDropdown'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; import { objectFilterDropdownFilterIsSelectedComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownFilterIsSelectedComponentState'; import { objectFilterDropdownFirstLevelFilterDefinitionComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownFirstLevelFilterDefinitionComponentState'; @@ -6,14 +7,21 @@ import { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-rec import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition'; import { getCompositeSubFieldLabel } from '@/object-record/object-filter-dropdown/utils/getCompositeSubFieldLabel'; import { getFilterableFieldTypeLabel } from '@/object-record/object-filter-dropdown/utils/getFilterableFieldTypeLabel'; +import { getInitialFilterValue } from '@/object-record/object-filter-dropdown/utils/getInitialFilterValue'; import { getOperandsForFilterDefinition } from '@/object-record/object-filter-dropdown/utils/getOperandsForFilterType'; import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; -import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; import { useState } from 'react'; -import { IconApps, IconChevronLeft, isDefined, useIcons } from 'twenty-ui'; +import { useRecoilValue } from 'recoil'; +import { + IconApps, + IconChevronLeft, + isDefined, + MenuItem, + useIcons, +} from 'twenty-ui'; export const ObjectFilterDropdownFilterSelectCompositeFieldSubMenu = () => { const [searchText] = useState(''); @@ -47,10 +55,46 @@ export const ObjectFilterDropdownFilterSelectCompositeFieldSubMenu = () => { setFilterDefinitionUsedInDropdown, setSelectedOperandInDropdown, setObjectFilterDropdownSearchInput, + selectFilter, + advancedFilterViewFilterIdState, + advancedFilterViewFilterGroupIdState, } = useFilterDropdown(); + const advancedFilterViewFilterId = useRecoilValue( + advancedFilterViewFilterIdState, + ); + const advancedFilterViewFilterGroupId = useRecoilValue( + advancedFilterViewFilterGroupIdState, + ); + + const { closeAdvancedFilterDropdown } = useAdvancedFilterDropdown( + advancedFilterViewFilterId, + ); + const handleSelectFilter = (definition: FilterDefinition | null) => { if (definition !== null) { + if ( + isDefined(advancedFilterViewFilterId) && + isDefined(advancedFilterViewFilterGroupId) + ) { + closeAdvancedFilterDropdown(); + + const operand = getOperandsForFilterDefinition(definition)[0]; + const { value, displayValue } = getInitialFilterValue( + definition.type, + operand, + ); + selectFilter({ + id: advancedFilterViewFilterId, + fieldMetadataId: definition.fieldMetadataId, + value, + operand, + displayValue, + definition, + viewFilterGroupId: advancedFilterViewFilterGroupId, + }); + } + setFilterDefinitionUsedInDropdown(definition); setSelectedOperandInDropdown( diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectMenuItem.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectMenuItem.tsx index 7d94eaaf0e35..84f9addb447f 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectMenuItem.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterSelectMenuItem.tsx @@ -1,3 +1,4 @@ +import { useAdvancedFilterDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterDropdown'; import { OBJECT_FILTER_DROPDOWN_ID } from '@/object-record/object-filter-dropdown/constants/ObjectFilterDropdownId'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; import { useSelectFilter } from '@/object-record/object-filter-dropdown/hooks/useSelectFilter'; @@ -12,11 +13,10 @@ import { getOperandsForFilterDefinition } from '@/object-record/object-filter-dr import { isCompositeField } from '@/object-record/object-filter-dropdown/utils/isCompositeField'; import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; -import { MenuItemSelect } from '@/ui/navigation/menu-item/components/MenuItemSelect'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; import { useRecoilValue } from 'recoil'; -import { useIcons } from 'twenty-ui'; +import { MenuItemSelect, useIcons } from 'twenty-ui'; export type ObjectFilterDropdownFilterSelectMenuItemProps = { filterDefinition: FilterDefinition; @@ -59,11 +59,23 @@ export const ObjectFilterDropdownFilterSelectMenuItem = ({ setFilterDefinitionUsedInDropdown, setSelectedOperandInDropdown, setObjectFilterDropdownSearchInput, + advancedFilterViewFilterIdState, } = useFilterDropdown(); const setHotkeyScope = useSetHotkeyScope(); + const advancedFilterViewFilterId = useRecoilValue( + advancedFilterViewFilterIdState, + ); + + const { closeAdvancedFilterDropdown } = useAdvancedFilterDropdown( + advancedFilterViewFilterId, + ); + const handleSelectFilter = (availableFilterDefinition: FilterDefinition) => { + closeAdvancedFilterDropdown(); + selectFilter({ filterDefinition: availableFilterDefinition }); + setFilterDefinitionUsedInDropdown(availableFilterDefinition); if ( @@ -87,8 +99,6 @@ export const ObjectFilterDropdownFilterSelectMenuItem = ({ const handleClick = () => { resetSelectedItem(); - selectFilter({ filterDefinition }); - if (isACompositeField) { // TODO: create isCompositeFilterableFieldType type guard setObjectFilterDropdownSubMenuFieldType( diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownNumberInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownNumberInput.tsx index 98f27f10932d..38246cf53df9 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownNumberInput.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownNumberInput.tsx @@ -56,6 +56,7 @@ export const ObjectFilterDropdownNumberInput = () => { operand: selectedOperandInDropdown, displayValue: newValue, definition: filterDefinitionUsedInDropdown, + viewFilterGroupId: selectedFilter?.viewFilterGroupId, }); }} /> diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandButton.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandButton.tsx index 3931c76547e1..1e21d36b1ac9 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandButton.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandButton.tsx @@ -10,17 +10,28 @@ export const ObjectFilterDropdownOperandButton = () => { const { selectedOperandInDropdownState, setIsObjectFilterDropdownOperandSelectUnfolded, + isObjectFilterDropdownOperandSelectUnfoldedState, } = useFilterDropdown(); const selectedOperandInDropdown = useRecoilValue( selectedOperandInDropdownState, ); + const isObjectFilterDropdownOperandSelectUnfolded = useRecoilValue( + isObjectFilterDropdownOperandSelectUnfoldedState, + ); + + const handleButtonClick = () => { + setIsObjectFilterDropdownOperandSelectUnfolded( + !isObjectFilterDropdownOperandSelectUnfolded, + ); + }; + return ( setIsObjectFilterDropdownOperandSelectUnfolded(true)} + onClick={handleButtonClick} > {getOperandLabel(selectedOperandInDropdown)} diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandSelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandSelect.tsx index b33fcbc162e3..ec90ebe931a1 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOperandSelect.tsx @@ -3,8 +3,8 @@ import { v4 } from 'uuid'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; -import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; +import { MenuItem } from 'twenty-ui'; import { isDefined } from '~/utils/isDefined'; import { getInitialFilterValue } from '@/object-record/object-filter-dropdown/utils/getInitialFilterValue'; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect.tsx index 213f828cfa0b..13a32ca4b2fb 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownOptionSelect.tsx @@ -13,9 +13,10 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; import { useSelectableListStates } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListStates'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; -import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; -import { MenuItemMultiSelect } from '@/ui/navigation/menu-item/components/MenuItemMultiSelect'; + import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; +import { MenuItem, MenuItemMultiSelect } from 'twenty-ui'; import { isDefined } from '~/utils/isDefined'; export const EMPTY_FILTER_VALUE = ''; @@ -135,6 +136,7 @@ export const ObjectFilterDropdownOptionSelect = () => { displayValue: filterDisplayValue, fieldMetadataId: filterDefinitionUsedInDropdown.fieldMetadataId, value: newFilterValue, + viewFilterGroupId: selectedFilter?.viewFilterGroupId, }); } resetSelectedItem(); @@ -161,21 +163,23 @@ export const ObjectFilterDropdownOptionSelect = () => { } }} > - - {optionsInDropdown?.map((option) => ( - - handleMultipleOptionSelectChange(option, selected) - } - text={option.label} - color={option.color} - className="" - /> - ))} - + + + {optionsInDropdown?.map((option) => ( + + handleMultipleOptionSelectChange(option, selected) + } + text={option.label} + color={option.color} + className="" + /> + ))} + + {showNoResult && } ); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRatingInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRatingInput.tsx index 2ed0cd0f3090..95af2de52e4b 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRatingInput.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRatingInput.tsx @@ -64,6 +64,7 @@ export const ObjectFilterDropdownRatingInput = () => { operand: selectedOperandInDropdown, displayValue: convertFieldRatingValueToNumber(newValue), definition: filterDefinitionUsedInDropdown, + viewFilterGroupId: selectedFilter?.viewFilterGroupId, }); }} /> diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordRemoveFilterMenuItem.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordRemoveFilterMenuItem.tsx index 74f3ed364fa3..7251fab80788 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordRemoveFilterMenuItem.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordRemoveFilterMenuItem.tsx @@ -1,9 +1,8 @@ -import { IconFilterOff } from 'twenty-ui'; +import { IconFilterOff, MenuItem } from 'twenty-ui'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; -import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; export const ObjectFilterDropdownRecordRemoveFilterMenuItem = () => { const { emptyFilterButKeepDefinition } = useFilterDropdown(); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect.tsx index c55496ee61d3..0323a75d6d4e 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownRecordSelect.tsx @@ -129,6 +129,7 @@ export const ObjectFilterDropdownRecordSelect = ({ displayValue: filterDisplayValue, fieldMetadataId: filterDefinitionUsedInDropdown.fieldMetadataId, value: newFilterValue, + viewFilterGroupId: selectedFilter?.viewFilterGroupId, }); } }; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownSourceSelect.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownSourceSelect.tsx index 153abd72b506..cb46def5c76e 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownSourceSelect.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownSourceSelect.tsx @@ -115,6 +115,7 @@ export const ObjectFilterDropdownSourceSelect = ({ displayValue: filterDisplayValue, fieldMetadataId: filterDefinitionUsedInDropdown.fieldMetadataId, value: newFilterValue, + viewFilterGroupId: selectedFilter?.viewFilterGroupId, }); } }; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownTextSearchInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownTextSearchInput.tsx index 4c83c98c0fd2..b6508323bbe0 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownTextSearchInput.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterDropdownTextSearchInput.tsx @@ -52,12 +52,13 @@ export const ObjectFilterDropdownTextSearchInput = () => { setObjectFilterDropdownSearchInput(event.target.value); selectFilter?.({ - id: selectedFilter?.id ? selectedFilter.id : filterId, + id: selectedFilter?.id ?? filterId, fieldMetadataId: filterDefinitionUsedInDropdown.fieldMetadataId, value: event.target.value, operand: selectedOperandInDropdown, displayValue: event.target.value, definition: filterDefinitionUsedInDropdown, + viewFilterGroupId: selectedFilter?.viewFilterGroupId, }); }} /> diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterOperandSelectAndInput.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterOperandSelectAndInput.tsx new file mode 100644 index 000000000000..ec814a21a490 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/ObjectFilterOperandSelectAndInput.tsx @@ -0,0 +1,19 @@ +import { ObjectFilterDropdownFilterInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterInput'; +import { ObjectFilterDropdownFilterOperandSelect } from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownFilterOperandSelect'; + +type ObjectFilterOperandSelectAndInputProps = { + filterDropdownId?: string; +}; + +export const ObjectFilterOperandSelectAndInput = ({ + filterDropdownId, +}: ObjectFilterOperandSelectAndInputProps) => { + return ( + <> + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/__stories__/MultipleFiltersDropdownButton.stories.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/__stories__/MultipleFiltersDropdownButton.stories.tsx index df9044a82f21..16d060c53228 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/__stories__/MultipleFiltersDropdownButton.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/components/__stories__/MultipleFiltersDropdownButton.stories.tsx @@ -4,14 +4,13 @@ import { TaskGroups } from '@/activities/tasks/components/TaskGroups'; import { MultipleFiltersDropdownButton } from '@/object-record/object-filter-dropdown/components/MultipleFiltersDropdownButton'; import { ObjectFilterDropdownScope } from '@/object-record/object-filter-dropdown/scopes/ObjectFilterDropdownScope'; import { ObjectFilterDropdownComponentInstanceContext } from '@/object-record/object-filter-dropdown/states/contexts/ObjectFilterDropdownComponentInstanceContext'; -import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; -import { RecordTableScopeInternalContext } from '@/object-record/record-table/scopes/scope-internal-context/RecordTableScopeInternalContext'; +import { RecordTableComponentInstanceContext } from '@/object-record/record-table/states/context/RecordTableComponentInstanceContext'; +import { tableColumnsComponentState } from '@/object-record/record-table/states/tableColumnsComponentState'; import { ColumnDefinition } from '@/object-record/record-table/types/ColumnDefinition'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; import { within } from '@storybook/test'; -import { useSetRecoilState } from 'recoil'; import { ComponentDecorator } from 'twenty-ui'; import { FieldMetadataType } from '~/generated/graphql'; import { IconsProviderDecorator } from '~/testing/decorators/IconsProviderDecorator'; @@ -30,9 +29,10 @@ const meta: Meta = { instanceId, ); - const { tableColumnsState } = useRecordTableStates(instanceId); - - const setTableColumns = useSetRecoilState(tableColumnsState); + const setTableColumns = useSetRecoilComponentStateV2( + tableColumnsComponentState, + instanceId, + ); setTableColumns([ { @@ -91,15 +91,15 @@ const meta: Meta = { - {} }} + {} }} > - + ); }, diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/DateFilterTypes.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/DateFilterTypes.ts new file mode 100644 index 000000000000..3a3f9d1f7f54 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/DateFilterTypes.ts @@ -0,0 +1 @@ +export const DATE_FILTER_TYPES = ['DATE_TIME', 'DATE']; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/NumberFilterTypes.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/NumberFilterTypes.ts new file mode 100644 index 000000000000..5f36ae347fd8 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/NumberFilterTypes.ts @@ -0,0 +1 @@ +export const NUMBER_FILTER_TYPES = ['NUMBER', 'CURRENCY']; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/TextFilterTypes.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/TextFilterTypes.ts new file mode 100644 index 000000000000..9ff7456c42f4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/constants/TextFilterTypes.ts @@ -0,0 +1,14 @@ +export const TEXT_FILTER_TYPES = [ + 'TEXT', + 'EMAIL', + 'EMAILS', + 'PHONE', + 'FULL_NAME', + 'LINK', + 'LINKS', + 'ADDRESS', + 'ACTOR', + 'ARRAY', + 'RAW_JSON', + 'PHONES', +]; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/__tests__/useFilterDropdown.test.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/__tests__/useFilterDropdown.test.tsx index b69584cb39d7..1c15c498ed90 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/__tests__/useFilterDropdown.test.tsx +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/__tests__/useFilterDropdown.test.tsx @@ -8,11 +8,24 @@ import { Filter } from '@/object-record/object-filter-dropdown/types/Filter'; import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/FilterDefinition'; import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; +import { MockedProvider } from '@apollo/client/testing'; +import { JestObjectMetadataItemSetter } from '~/testing/jest/JestObjectMetadataItemSetter'; const filterDropdownId = 'filterDropdownId'; const renderHookConfig = { - wrapper: RecoilRoot, + wrapper: ({ children }: any) => ( + + + + + {children} + + + + + ), }; const filterDefinitions: FilterDefinition[] = [ @@ -306,9 +319,10 @@ describe('useFilterDropdown', () => { it('should reset filter', async () => { const { result } = renderHook(() => { - const { selectFilter, resetFilter } = useFilterDropdown({ + const { resetFilter, selectFilter } = useFilterDropdown({ filterDropdownId, }); + const { selectedFilterState } = useFilterDropdownStates(filterDropdownId); const [selectedFilter, setSelectedFilter] = diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useFilterDropdown.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useFilterDropdown.ts index c8a801336647..9efd3cfa40f1 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useFilterDropdown.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useFilterDropdown.ts @@ -7,11 +7,14 @@ import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotV import { objectFilterDropdownFilterIsSelectedComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownFilterIsSelectedComponentState'; import { objectFilterDropdownIsSelectingCompositeFieldComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownIsSelectingCompositeFieldComponentState'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { useUpsertCombinedViewFilters } from '@/views/hooks/useUpsertCombinedViewFilters'; +import { isDefined } from 'twenty-ui'; import { ObjectFilterDropdownScopeInternalContext } from '../scopes/scope-internal-context/ObjectFilterDropdownScopeInternalContext'; import { Filter } from '../types/Filter'; type UseFilterDropdownProps = { filterDropdownId?: string; + advancedFilterViewFilterId?: string; }; export const useFilterDropdown = (props?: UseFilterDropdownProps) => { @@ -30,17 +33,25 @@ export const useFilterDropdown = (props?: UseFilterDropdownProps) => { selectedFilterState, selectedOperandInDropdownState, onFilterSelectState, + advancedFilterViewFilterGroupIdState, + advancedFilterViewFilterIdState, } = useFilterDropdownStates(scopeId); + const { upsertCombinedViewFilter } = useUpsertCombinedViewFilters(); + const selectFilter = useRecoilCallback( ({ set, snapshot }) => (filter: Filter | null) => { set(selectedFilterState, filter); const onFilterSelect = getSnapshotValue(snapshot, onFilterSelectState); + if (isDefined(filter)) { + upsertCombinedViewFilter(filter); + } + onFilterSelect?.(filter); }, - [selectedFilterState, onFilterSelectState], + [selectedFilterState, onFilterSelectState, upsertCombinedViewFilter], ); const emptyFilterButKeepDefinition = useRecoilCallback( @@ -117,6 +128,12 @@ export const useFilterDropdown = (props?: UseFilterDropdownProps) => { isObjectFilterDropdownUnfoldedState, ); const setOnFilterSelect = useSetRecoilState(onFilterSelectState); + const setAdvancedFilterViewFilterGroupId = useSetRecoilState( + advancedFilterViewFilterGroupIdState, + ); + const setAdvancedFilterViewFilterId = useSetRecoilState( + advancedFilterViewFilterIdState, + ); return { scopeId, @@ -132,6 +149,8 @@ export const useFilterDropdown = (props?: UseFilterDropdownProps) => { setIsObjectFilterDropdownOperandSelectUnfolded, setIsObjectFilterDropdownUnfolded, setOnFilterSelect, + setAdvancedFilterViewFilterGroupId, + setAdvancedFilterViewFilterId, emptyFilterButKeepDefinition, filterDefinitionUsedInDropdownState, objectFilterDropdownSearchInputState, @@ -143,5 +162,7 @@ export const useFilterDropdown = (props?: UseFilterDropdownProps) => { selectedFilterState, selectedOperandInDropdownState, onFilterSelectState, + advancedFilterViewFilterGroupIdState, + advancedFilterViewFilterIdState, }; }; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useFilterDropdownStates.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useFilterDropdownStates.ts index 8d9e5f1d08bd..069e29a4cb1f 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useFilterDropdownStates.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useFilterDropdownStates.ts @@ -1,3 +1,5 @@ +import { advancedFilterViewFilterGroupIdComponentState } from '@/object-record/object-filter-dropdown/states/advancedFilterViewFilterGroupIdComponentState'; +import { advancedFilterViewFilterIdComponentState } from '@/object-record/object-filter-dropdown/states/advancedFilterViewFilterIdComponentState'; import { filterDefinitionUsedInDropdownComponentState } from '@/object-record/object-filter-dropdown/states/filterDefinitionUsedInDropdownComponentState'; import { isObjectFilterDropdownOperandSelectUnfoldedComponentState } from '@/object-record/object-filter-dropdown/states/isObjectFilterDropdownOperandSelectUnfoldedComponentState'; import { isObjectFilterDropdownUnfoldedComponentState } from '@/object-record/object-filter-dropdown/states/isObjectFilterDropdownUnfoldedComponentState'; @@ -56,6 +58,16 @@ export const useFilterDropdownStates = (scopeId: string) => { scopeId, ); + const advancedFilterViewFilterGroupIdState = extractComponentState( + advancedFilterViewFilterGroupIdComponentState, + scopeId, + ); + + const advancedFilterViewFilterIdState = extractComponentState( + advancedFilterViewFilterIdComponentState, + scopeId, + ); + return { filterDefinitionUsedInDropdownState, objectFilterDropdownSearchInputState, @@ -66,5 +78,7 @@ export const useFilterDropdownStates = (scopeId: string) => { selectedFilterState, selectedOperandInDropdownState, onFilterSelectState, + advancedFilterViewFilterGroupIdState, + advancedFilterViewFilterIdState, }; }; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useSelectFilter.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useSelectFilter.ts index c716971dc010..af287141ea32 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useSelectFilter.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/hooks/useSelectFilter.ts @@ -4,6 +4,8 @@ import { getInitialFilterValue } from '@/object-record/object-filter-dropdown/ut import { getOperandsForFilterDefinition } from '@/object-record/object-filter-dropdown/utils/getOperandsForFilterType'; import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types/RelationPickerHotkeyScope'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-ui'; import { v4 } from 'uuid'; type SelectFilterParams = { @@ -16,8 +18,17 @@ export const useSelectFilter = () => { setSelectedOperandInDropdown, setObjectFilterDropdownSearchInput, selectFilter: filterDropdownSelectFilter, + advancedFilterViewFilterGroupIdState, + advancedFilterViewFilterIdState, } = useFilterDropdown(); + const advancedFilterViewFilterId = useRecoilValue( + advancedFilterViewFilterIdState, + ); + const advancedFilterViewFilterGroupId = useRecoilValue( + advancedFilterViewFilterGroupIdState, + ); + const setHotkeyScope = useSetHotkeyScope(); const selectFilter = ({ filterDefinition }: SelectFilterParams) => { @@ -39,14 +50,17 @@ export const useSelectFilter = () => { getOperandsForFilterDefinition(filterDefinition)[0], ); - if (value !== '') { + const isAdvancedFilter = isDefined(advancedFilterViewFilterId); + + if (isAdvancedFilter || value !== '') { filterDropdownSelectFilter({ - id: v4(), + id: advancedFilterViewFilterId ?? v4(), fieldMetadataId: filterDefinition.fieldMetadataId, displayValue, operand: getOperandsForFilterDefinition(filterDefinition)[0], value, definition: filterDefinition, + viewFilterGroupId: advancedFilterViewFilterGroupId, }); } diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/advancedFilterViewFilterGroupIdComponentState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/advancedFilterViewFilterGroupIdComponentState.ts new file mode 100644 index 000000000000..b2c1dad91cf0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/advancedFilterViewFilterGroupIdComponentState.ts @@ -0,0 +1,7 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const advancedFilterViewFilterGroupIdComponentState = + createComponentState({ + key: 'advancedFilterViewFilterGroupIdComponentState', + defaultValue: undefined, + }); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/advancedFilterViewFilterIdComponentState.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/advancedFilterViewFilterIdComponentState.ts new file mode 100644 index 000000000000..59053b825af0 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/states/advancedFilterViewFilterIdComponentState.ts @@ -0,0 +1,8 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const advancedFilterViewFilterIdComponentState = createComponentState< + string | undefined +>({ + key: 'advancedFilterViewFilterIdComponentState', + defaultValue: undefined, +}); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/Filter.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/Filter.ts index 4d2eddb8756e..077fc1435974 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/Filter.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/Filter.ts @@ -7,7 +7,9 @@ export type Filter = { fieldMetadataId: string; value: string; displayValue: string; + viewFilterGroupId?: string; displayAvatarUrl?: string; operand: ViewFilterOperand; + positionInViewFilterGroup?: number | null; definition: FilterDefinition; }; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterDraft.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterDraft.ts new file mode 100644 index 000000000000..d107b21d7b72 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterDraft.ts @@ -0,0 +1,4 @@ +import { Filter } from '@/object-record/object-filter-dropdown/types/Filter'; + +export type FilterDraft = Partial & + Omit; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterableFieldType.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterableFieldType.ts index 0624fe937ef7..b2bf87102729 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterableFieldType.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/types/FilterableFieldType.ts @@ -19,4 +19,5 @@ export type FilterableFieldType = PickLiteral< | 'MULTI_SELECT' | 'ACTOR' | 'ARRAY' + | 'RAW_JSON' >; diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/__tests__/getOperandLabel.test.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/__tests__/getOperandLabel.test.ts similarity index 100% rename from packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/__tests__/getOperandLabel.test.tsx rename to packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/__tests__/getOperandLabel.test.ts diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/__tests__/getOperandsForFilterType.test.tsx b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/__tests__/getOperandsForFilterType.test.ts similarity index 100% rename from packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/__tests__/getOperandsForFilterType.test.tsx rename to packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/__tests__/getOperandsForFilterType.test.ts diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/configurableViewFilterOperands.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/configurableViewFilterOperands.ts new file mode 100644 index 000000000000..1b85debc7353 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/configurableViewFilterOperands.ts @@ -0,0 +1,14 @@ +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; + +export const configurableViewFilterOperands = new Set([ + ViewFilterOperand.Is, + ViewFilterOperand.IsNotNull, + ViewFilterOperand.IsNot, + ViewFilterOperand.LessThan, + ViewFilterOperand.GreaterThan, + ViewFilterOperand.IsBefore, + ViewFilterOperand.IsAfter, + ViewFilterOperand.Contains, + ViewFilterOperand.DoesNotContain, + ViewFilterOperand.IsRelative, +]); diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts index 688aa02b6c79..993c10916559 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getOperandsForFilterType.ts @@ -3,7 +3,7 @@ import { isActorSourceCompositeFilter } from '@/object-record/object-filter-drop import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; export const getOperandsForFilterDefinition = ( - filterDefinition: FilterDefinition, + filterDefinition: Pick, ): ViewFilterOperand[] => { const emptyOperands = [ ViewFilterOperand.IsEmpty, @@ -18,7 +18,6 @@ export const getOperandsForFilterDefinition = ( case 'FULL_NAME': case 'ADDRESS': case 'LINKS': - case 'ARRAY': case 'PHONES': return [ ViewFilterOperand.Contains, @@ -32,6 +31,12 @@ export const getOperandsForFilterDefinition = ( ViewFilterOperand.LessThan, ...emptyOperands, ]; + case 'RAW_JSON': + return [ + ViewFilterOperand.Contains, + ViewFilterOperand.DoesNotContain, + ...emptyOperands, + ]; case 'DATE_TIME': case 'DATE': return [ @@ -70,6 +75,12 @@ export const getOperandsForFilterDefinition = ( ...emptyOperands, ]; } + case 'ARRAY': + return [ + ViewFilterOperand.Contains, + ViewFilterOperand.DoesNotContain, + ...emptyOperands, + ]; default: return []; } diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts index fb59e540180b..9bbc69af3f52 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/getRelativeDateDisplayValue.ts @@ -1,7 +1,7 @@ import { VariableDateViewFilterValueDirection, VariableDateViewFilterValueUnit, -} from '@/views/utils/view-filter-value/resolveDateViewFilterValue'; +} from '@/views/view-filter-value/utils/resolveDateViewFilterValue'; import { plural } from 'pluralize'; import { capitalize } from '~/utils/string/capitalize'; export const getRelativeDateDisplayValue = ( diff --git a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/isActorSourceCompositeFilter.ts b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/isActorSourceCompositeFilter.ts index 0d1d5046ab41..e7296376e748 100644 --- a/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/isActorSourceCompositeFilter.ts +++ b/packages/twenty-front/src/modules/object-record/object-filter-dropdown/utils/isActorSourceCompositeFilter.ts @@ -2,7 +2,7 @@ import { FilterDefinition } from '@/object-record/object-filter-dropdown/types/F import { FieldActorValue } from '@/object-record/record-field/types/FieldMetadata'; export const isActorSourceCompositeFilter = ( - filterDefinition: FilterDefinition, + filterDefinition: Pick, ) => { return ( filterDefinition.compositeFieldName === diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/components/ObjectSortDropdownButton.tsx b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/components/ObjectSortDropdownButton.tsx index f1e399c1a0b9..f069f854a243 100644 --- a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/components/ObjectSortDropdownButton.tsx +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/components/ObjectSortDropdownButton.tsx @@ -1,20 +1,22 @@ import styled from '@emotion/styled'; import { useRecoilValue } from 'recoil'; -import { IconChevronDown, useIcons } from 'twenty-ui'; +import { IconChevronDown, MenuItem, useIcons } from 'twenty-ui'; import { OBJECT_SORT_DROPDOWN_ID } from '@/object-record/object-sort-dropdown/constants/ObjectSortDropdownId'; import { useObjectSortDropdown } from '@/object-record/object-sort-dropdown/hooks/useObjectSortDropdown'; import { ObjectSortDropdownScope } from '@/object-record/object-sort-dropdown/scopes/ObjectSortDropdownScope'; import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; -import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; +import { hiddenTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/hiddenTableColumnsComponentSelector'; +import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { StyledHeaderDropdownButton } from '@/ui/layout/dropdown/components/StyledHeaderDropdownButton'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; -import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { useContext } from 'react'; import { SORT_DIRECTIONS } from '../types/SortDirection'; @@ -42,17 +44,13 @@ export const StyledInput = styled.input` } `; -const StyledContainer = styled.div` - position: relative; -`; - const StyledSelectedSortDirectionContainer = styled.div` background: ${({ theme }) => theme.background.secondary}; box-shadow: ${({ theme }) => theme.boxShadow.light}; border-radius: ${({ theme }) => theme.border.radius.md}; - left: 10px; + position: absolute; - top: 10px; + top: 32px; width: 100%; z-index: 1000; `; @@ -80,6 +78,8 @@ export const ObjectSortDropdownButton = ({ resetSearchInput, } = useObjectSortDropdown(); + const { recordIndexId } = useContext(RecordIndexRootPropsContext); + const { isDropdownOpen } = useDropdown(OBJECT_SORT_DROPDOWN_ID); const handleButtonClick = () => { @@ -97,15 +97,17 @@ export const ObjectSortDropdownButton = ({ const { getIcon } = useIcons(); - const { recordIndexId } = useContext(RecordIndexRootPropsContext); - const { hiddenTableColumnsSelector, visibleTableColumnsSelector } = - useRecordTableStates(recordIndexId); - - const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector()); + const visibleTableColumns = useRecoilComponentValueV2( + visibleTableColumnsComponentSelector, + recordIndexId, + ); const visibleColumnsIds = visibleTableColumns.map( (column) => column.fieldMetadataId, ); - const hiddenTableColumns = useRecoilValue(hiddenTableColumnsSelector()); + const hiddenTableColumns = useRecoilComponentValueV2( + hiddenTableColumnsComponentSelector, + recordIndexId, + ); const hiddenColumnIds = hiddenTableColumns.map( (column) => column.fieldMetadataId, ); @@ -166,21 +168,23 @@ export const ObjectSortDropdownButton = ({ )} - - setIsSortDirectionMenuUnfolded(true)} - > - {selectedSortDirection === 'asc' ? 'Ascending' : 'Descending'} - - - setObjectSortDropdownSearchInput(event.target.value) - } - /> + + setIsSortDirectionMenuUnfolded(!isSortDirectionMenuUnfolded) + } + > + {selectedSortDirection === 'asc' ? 'Ascending' : 'Descending'} + + + setObjectSortDropdownSearchInput(event.target.value) + } + /> + {visibleColumnsSortDefinitions.map( (visibleSortDefinition, index) => ( @@ -214,7 +218,7 @@ export const ObjectSortDropdownButton = ({ ), )} - + } onClose={handleDropdownButtonClose} diff --git a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/__tests__/turnSortsIntoOrderBy.test.tsx b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/__tests__/turnSortsIntoOrderBy.test.ts similarity index 98% rename from packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/__tests__/turnSortsIntoOrderBy.test.tsx rename to packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/__tests__/turnSortsIntoOrderBy.test.ts index 12fcaff75558..6d3d8b92e904 100644 --- a/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/__tests__/turnSortsIntoOrderBy.test.tsx +++ b/packages/twenty-front/src/modules/object-record/object-sort-dropdown/utils/__tests__/turnSortsIntoOrderBy.test.ts @@ -25,6 +25,7 @@ const objectMetadataItem: ObjectMetadataItem = { isRemote: false, labelPlural: 'object1s', labelSingular: 'object1', + isLabelSyncedWithName: true, }; describe('turnSortsIntoOrderBy', () => { diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx index ff408eb407de..34f70dffcaba 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoard.tsx @@ -4,46 +4,47 @@ import { useContext, useRef } from 'react'; import { useRecoilCallback, useRecoilValue } from 'recoil'; import { Key } from 'ts-key-enum'; +import { RecordBoardHeader } from '@/object-record/record-board/components/RecordBoardHeader'; +import { RecordBoardStickyHeaderEffect } from '@/object-record/record-board/components/RecordBoardStickyHeaderEffect'; +import { RECORD_BOARD_CLICK_OUTSIDE_LISTENER_ID } from '@/object-record/record-board/constants/RecordBoardClickOutsideListenerId'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; import { useRecordBoardSelection } from '@/object-record/record-board/hooks/useRecordBoardSelection'; import { RecordBoardColumn } from '@/object-record/record-board/record-board-column/components/RecordBoardColumn'; import { RecordBoardScope } from '@/object-record/record-board/scopes/RecordBoardScope'; -import { getDraggedRecordPosition } from '@/object-record/record-board/utils/get-dragged-record-position.util'; +import { getDraggedRecordPosition } from '@/object-record/record-board/utils/getDraggedRecordPosition'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { TableHotkeyScope } from '@/object-record/record-table/types/TableHotkeyScope'; import { DragSelect } from '@/ui/utilities/drag-select/components/DragSelect'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; -import { useListenClickOutsideByClassName } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; +import { useListenClickOutsideV2 } from '@/ui/utilities/pointer-event/hooks/useListenClickOutsideV2'; import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; import { useScrollRestoration } from '~/hooks/useScrollRestoration'; -export type RecordBoardProps = { - recordBoardId: string; -}; - const StyledContainer = styled.div` - border-top: 1px solid ${({ theme }) => theme.border.color.light}; - overflow: auto; display: flex; flex: 1; flex-direction: row; - min-height: calc(100% - 1px); `; -const StyledWrapper = styled.div` +const StyledColumnContainer = styled.div` + display: flex; + & > *:not(:first-of-type) { + border-left: 1px solid ${({ theme }) => theme.border.color.light}; + } +`; + +const StyledContainerContainer = styled.div` display: flex; flex-direction: column; height: 100%; - overflow: hidden; - position: relative; - width: 100%; `; -const StyledBoardHeader = styled.div` - position: relative; - z-index: 1; +const StyledBoardContentContainer = styled.div` + display: flex; + flex-direction: column; + height: calc(100% - 48px); `; const RecordBoardScrollRestoreEffect = () => { @@ -51,8 +52,8 @@ const RecordBoardScrollRestoreEffect = () => { return null; }; -export const RecordBoard = ({ recordBoardId }: RecordBoardProps) => { - const { updateOneRecord, selectFieldMetadataItem } = +export const RecordBoard = () => { + const { updateOneRecord, selectFieldMetadataItem, recordBoardId } = useContext(RecordBoardContext); const boardRef = useRef(null); @@ -67,15 +68,21 @@ export const RecordBoard = ({ recordBoardId }: RecordBoardProps) => { const { resetRecordSelection, setRecordAsSelected } = useRecordBoardSelection(recordBoardId); - useListenClickOutsideByClassName({ - classNames: ['record-board-card'], - excludeClassNames: ['bottom-bar', 'context-menu'], + useListenClickOutsideV2({ + excludeClassNames: [ + 'bottom-bar', + 'action-menu-dropdown', + 'command-menu', + 'modal-backdrop', + ], + listenerId: RECORD_BOARD_CLICK_OUTSIDE_LISTENER_ID, + refs: [boardRef], callback: resetRecordSelection, }); useScopedHotkeys([Key.Escape], resetRecordSelection, TableHotkeyScope.Table); - const onDragEnd: OnDragEndResponder = useRecoilCallback( + const handleDragEnd: OnDragEndResponder = useRecoilCallback( ({ snapshot }) => (result) => { if (!result.destination) return; @@ -140,33 +147,44 @@ export const RecordBoard = ({ recordBoardId }: RecordBoardProps) => { ], ); + // FixMe: Check if we really need this as it depends on the times it takes to update the view groups + // if (isPersistingViewGroups) { + // // TODO: Add skeleton state + // return null; + // } + return ( {}} onFieldsChange={() => {}} > - - - - - - {columnIds.map((columnId) => ( - - ))} - - - - - - + + + + + + + + + {columnIds.map((columnId) => ( + + ))} + + + + + + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx new file mode 100644 index 000000000000..b284532386dd --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardHeader.tsx @@ -0,0 +1,38 @@ +import { useRecoilValue } from 'recoil'; + +import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; +import { RecordBoardColumnHeaderWrapper } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderWrapper'; +import styled from '@emotion/styled'; + +const StyledHeaderContainer = styled.div` + display: flex; + flex-direction: row; + height: 40px; + z-index: 10; + + overflow: visible; + width: 100%; + + &.header-sticky { + position: sticky; + top: 0; + } + + & > *:not(:first-of-type) { + border-left: 1px solid ${({ theme }) => theme.border.color.light}; + } +`; + +export const RecordBoardHeader = () => { + const { columnIdsState } = useRecordBoardStates(); + + const columnIds = useRecoilValue(columnIdsState); + + return ( + + {columnIds.map((columnId) => ( + + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardStickyHeaderEffect.tsx b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardStickyHeaderEffect.tsx new file mode 100644 index 000000000000..5544a7801a2f --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/components/RecordBoardStickyHeaderEffect.tsx @@ -0,0 +1,22 @@ +import { useEffect } from 'react'; + +import { useScrollTopValue } from '@/ui/utilities/scroll/hooks/useScrollTopValue'; + +export const RecordBoardStickyHeaderEffect = () => { + const scrollTop = useScrollTopValue('recordBoard'); + + // TODO: move this outside because it might cause way too many re-renders for other hooks + useEffect(() => { + if (scrollTop > 0) { + document + .getElementById('record-board-header') + ?.classList.add('header-sticky'); + } else { + document + .getElementById('record-board-header') + ?.classList.remove('header-sticky'); + } + }, [scrollTop]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardClickOutsideListenerId.ts b/packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardClickOutsideListenerId.ts new file mode 100644 index 000000000000..5179225aad9d --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/constants/RecordBoardClickOutsideListenerId.ts @@ -0,0 +1 @@ +export const RECORD_BOARD_CLICK_OUTSIDE_LISTENER_ID = 'record-board'; diff --git a/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardContext.ts b/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardContext.ts index d8d7f1490738..266e1a445460 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/contexts/RecordBoardContext.ts @@ -16,6 +16,7 @@ type RecordBoardContextProps = { updateOneRecordInput: Partial>; }) => void; deleteOneRecord: (idToDelete: string) => Promise; + recordBoardId: string; }; export const RecordBoardContext = createContext( diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts index aece94d4830f..65428a0197ca 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useRecordBoardStates.ts @@ -1,6 +1,4 @@ import { RecordBoardScopeInternalContext } from '@/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext'; -import { isFirstRecordBoardColumnComponentFamilyState } from '@/object-record/record-board/states/isFirstRecordBoardColumnComponentFamilyState'; -import { isLastRecordBoardColumnComponentFamilyState } from '@/object-record/record-board/states/isLastRecordBoardColumnComponentFamilyState'; import { isRecordBoardCardSelectedComponentFamilyState } from '@/object-record/record-board/states/isRecordBoardCardSelectedComponentFamilyState'; import { isRecordBoardCompactModeActiveComponentState } from '@/object-record/record-board/states/isRecordBoardCompactModeActiveComponentState'; import { isRecordBoardFetchingRecordsByColumnFamilyState } from '@/object-record/record-board/states/isRecordBoardFetchingRecordsByColumnFamilyState'; @@ -51,14 +49,6 @@ export const useRecordBoardStates = (recordBoardId?: string) => { recordBoardColumnIdsComponentState, scopeId, ), - isFirstColumnFamilyState: extractComponentFamilyState( - isFirstRecordBoardColumnComponentFamilyState, - scopeId, - ), - isLastColumnFamilyState: extractComponentFamilyState( - isLastRecordBoardColumnComponentFamilyState, - scopeId, - ), columnsFamilySelector: extractComponentFamilyState( recordBoardColumnsComponentFamilySelector, scopeId, diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardColumns.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardColumns.ts index d330a476d64f..ec2dffc656e7 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardColumns.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/internal/useSetRecordBoardColumns.ts @@ -1,7 +1,7 @@ import { useRecoilCallback } from 'recoil'; import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; -import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; +import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; export const useSetRecordBoardColumns = (recordBoardId?: string) => { @@ -10,22 +10,19 @@ export const useSetRecordBoardColumns = (recordBoardId?: string) => { const setColumns = useRecoilCallback( ({ set, snapshot }) => - (columns: RecordBoardColumnDefinition[]) => { + (columns: RecordGroupDefinition[]) => { const currentColumnsIds = snapshot .getLoadable(columnIdsState) .getValue(); - const columnIds = columns.map(({ id }) => id); + const columnIds = columns + .filter(({ isVisible }) => isVisible) + .map(({ id }) => id); - if (isDeeplyEqual(currentColumnsIds, columnIds)) { - return; + if (!isDeeplyEqual(currentColumnsIds, columnIds)) { + set(columnIdsState, columnIds); } - set( - columnIdsState, - columns.map((column) => column.id), - ); - columns.forEach((column) => { const currentColumn = snapshot .getLoadable(columnsFamilySelector(column.id)) diff --git a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts index 8b2d11837ae1..baba7e9f9a5a 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/hooks/useRecordBoardSelection.ts @@ -1,5 +1,7 @@ import { useRecoilCallback } from 'recoil'; +import { getActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getActionMenuDropdownIdFromActionMenuId'; +import { getActionMenuIdFromRecordIndexId } from '@/action-menu/utils/getActionMenuIdFromRecordIndexId'; import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; import { isDropdownOpenComponentState } from '@/ui/layout/dropdown/states/isDropdownOpenComponentState'; import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; @@ -8,14 +10,16 @@ export const useRecordBoardSelection = (recordBoardId: string) => { const { selectedRecordIdsSelector, isRecordBoardCardSelectedFamilyState } = useRecordBoardStates(recordBoardId); + const isActionMenuDropdownOpenState = extractComponentState( + isDropdownOpenComponentState, + getActionMenuDropdownIdFromActionMenuId( + getActionMenuIdFromRecordIndexId(recordBoardId), + ), + ); + const resetRecordSelection = useRecoilCallback( ({ snapshot, set }) => () => { - const isActionMenuDropdownOpenState = extractComponentState( - isDropdownOpenComponentState, - `action-menu-dropdown-${recordBoardId}`, - ); - set(isActionMenuDropdownOpenState, false); const recordIds = snapshot @@ -27,7 +31,7 @@ export const useRecordBoardSelection = (recordBoardId: string) => { } }, [ - recordBoardId, + isActionMenuDropdownOpenState, selectedRecordIdsSelector, isRecordBoardCardSelectedFamilyState, ], diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx index a2ea5b03def3..66dbd0695553 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-card/components/RecordBoardCard.tsx @@ -1,5 +1,7 @@ import { useActionMenu } from '@/action-menu/hooks/useActionMenu'; -import { actionMenuDropdownPositionComponentState } from '@/action-menu/states/actionMenuDropdownPositionComponentState'; +import { recordIndexActionMenuDropdownPositionComponentState } from '@/action-menu/states/recordIndexActionMenuDropdownPositionComponentState'; +import { getActionMenuDropdownIdFromActionMenuId } from '@/action-menu/utils/getActionMenuDropdownIdFromActionMenuId'; +import { getActionMenuIdFromRecordIndexId } from '@/action-menu/utils/getActionMenuIdFromRecordIndexId'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; import { RecordBoardCardContext } from '@/object-record/record-board/record-board-card/contexts/RecordBoardCardContext'; @@ -17,18 +19,23 @@ import { InlineCellHotkeyScope } from '@/object-record/record-inline-cell/types/ import { RecordValueSetterEffect } from '@/object-record/record-store/components/RecordValueSetterEffect'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; -import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; -import { Checkbox, CheckboxVariant } from '@/ui/input/components/Checkbox'; import { TextInput } from '@/ui/input/components/TextInput'; -import { AnimatedEaseInOut } from '@/ui/utilities/animation/components/AnimatedEaseInOut'; import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; import { RecordBoardScrollWrapperContext } from '@/ui/utilities/scroll/contexts/ScrollWrapperContexts'; import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; import styled from '@emotion/styled'; import { ReactNode, useContext, useState } from 'react'; -import { useInView } from 'react-intersection-observer'; +import { InView, useInView } from 'react-intersection-observer'; import { useRecoilState, useRecoilValue, useSetRecoilState } from 'recoil'; -import { AvatarChipVariant, IconEye, IconEyeOff } from 'twenty-ui'; +import { + AnimatedEaseInOut, + AvatarChipVariant, + Checkbox, + CheckboxVariant, + IconEye, + IconEyeOff, + LightIconButton, +} from 'twenty-ui'; import { useDebouncedCallback } from 'use-debounce'; import { useAddNewCard } from '../../record-board-column/hooks/useAddNewCard'; @@ -141,10 +148,6 @@ const StyledCompactIconContainer = styled.div` margin-left: ${({ theme }) => theme.spacing(1)}; `; -const StyledRecordInlineCellPlaceholder = styled.div` - height: 24px; -`; - export const RecordBoardCard = ({ isCreating = false, onCreateSuccess, @@ -182,14 +185,19 @@ export const RecordBoardCard = ({ RecordBoardScopeInternalContext, ); + const actionMenuId = getActionMenuIdFromRecordIndexId(recordBoardId); + + const actionMenuDropdownId = + getActionMenuDropdownIdFromActionMenuId(actionMenuId); + const setActionMenuDropdownPosition = useSetRecoilState( extractComponentState( - actionMenuDropdownPositionComponentState, - `action-menu-dropdown-${recordBoardId}`, + recordIndexActionMenuDropdownPositionComponentState, + actionMenuDropdownId, ), ); - const { openActionMenuDropdown } = useActionMenu(recordBoardId); + const { openActionMenuDropdown } = useActionMenu(actionMenuId); const handleActionMenuDropdown = (event: React.MouseEvent) => { event.preventDefault(); @@ -234,7 +242,7 @@ export const RecordBoardCard = ({ const scrollWrapperRef = useContext(RecordBoardScrollWrapperContext); - const { ref: cardRef, inView } = useInView({ + const { ref: cardRef } = useInView({ root: scrollWrapperRef?.ref.current, rootMargin: '1000px', }); @@ -250,125 +258,123 @@ export const RecordBoardCard = ({ return ( {!isCreating && } - { - if (!isCreating) { - setIsCurrentCardSelected(!isCurrentCardSelected); - } - }} - > - - {isCreating && position !== undefined ? ( - - - handleInputEnter( - labelIdentifierField?.label ?? '', - newLabelValue, - position, - onCreateSuccess, - ) - } - onBlur={() => - handleBlur( - labelIdentifierField?.label ?? '', - newLabelValue, - position, - onCreateSuccess, - ) - } - onChange={(text: string) => setNewLabelValue(text)} - placeholder={labelIdentifierField?.label} - /> - - ) : ( - - )} - - {!isCreating && ( - <> - {isCompactModeActive && ( - - { - e.stopPropagation(); - setIsCardExpanded((prev) => !prev); - }} - /> - - )} - - - - setIsCurrentCardSelected(!isCurrentCardSelected) + + { + if (!isCreating) { + setIsCurrentCardSelected(!isCurrentCardSelected); + } + }} + > + + {isCreating && position !== undefined ? ( + + + handleInputEnter( + labelIdentifierField?.label ?? '', + newLabelValue, + position, + onCreateSuccess, + ) } - variant={CheckboxVariant.Secondary} + onBlur={() => + handleBlur( + labelIdentifierField?.label ?? '', + newLabelValue, + position, + onCreateSuccess, + ) + } + onChange={(text: string) => setNewLabelValue(text)} + placeholder={labelIdentifierField?.label} /> - - - )} - - - - - {visibleFieldDefinitionsFiltered.map((fieldDefinition) => ( - - + ) : ( + + )} + + {!isCreating && ( + <> + {isCompactModeActive && ( + + { + e.stopPropagation(); + setIsCardExpanded((prev) => !prev); + }} + /> + + )} + + + + setIsCurrentCardSelected(!isCurrentCardSelected) + } + variant={CheckboxVariant.Secondary} + /> + + + )} + + + + + {visibleFieldDefinitionsFiltered.map((fieldDefinition) => ( + - {inView ? ( + - ) : ( - - )} - - - ))} - - - + + + ))} + + + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx index 70660329bdcc..26072d4a7e06 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumn.tsx @@ -4,21 +4,18 @@ import { useRecoilValue } from 'recoil'; import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; import { RecordBoardColumnCardsContainer } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnCardsContainer'; -import { RecordBoardColumnHeader } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeader'; import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; -const StyledColumn = styled.div<{ isFirstColumn: boolean }>` +const StyledColumn = styled.div` background-color: ${({ theme }) => theme.background.primary}; - border-left: 1px solid - ${({ theme, isFirstColumn }) => - isFirstColumn ? 'none' : theme.border.color.light}; display: flex; flex-direction: column; max-width: 200px; min-width: 200px; - padding: ${({ theme }) => theme.spacing(2)}; + padding-top: 0px; position: relative; + height: 100%; `; type RecordBoardColumnProps = { @@ -28,24 +25,12 @@ type RecordBoardColumnProps = { export const RecordBoardColumn = ({ recordBoardColumnId, }: RecordBoardColumnProps) => { - const { - isFirstColumnFamilyState, - isLastColumnFamilyState, - columnsFamilySelector, - recordIdsByColumnIdFamilyState, - } = useRecordBoardStates(); + const { columnsFamilySelector, recordIdsByColumnIdFamilyState } = + useRecordBoardStates(); const columnDefinition = useRecoilValue( columnsFamilySelector(recordBoardColumnId), ); - const isFirstColumn = useRecoilValue( - isFirstColumnFamilyState(recordBoardColumnId), - ); - - const isLastColumn = useRecoilValue( - isLastColumnFamilyState(recordBoardColumnId), - ); - const recordIds = useRecoilValue( recordIdsByColumnIdFamilyState(recordBoardColumnId), ); @@ -58,15 +43,14 @@ export const RecordBoardColumn = ({ {(droppableProvided) => ( - - + + + {(draggableProvided) => ( +
+ {objectMetadataItem.nameSingular === + CoreObjectNameSingular.Opportunity && + !isOpportunitiesCompanyFieldDisabled ? ( + + ) : ( + + )} +
+ )} +
{isRecordIndexBoardColumnLoading ? ( Array.from( { @@ -98,7 +126,7 @@ export const RecordBoardColumnCardsContainer = ({ )} @@ -108,16 +136,23 @@ export const RecordBoardColumnCardsContainer = ({ // eslint-disable-next-line react/jsx-props-no-spreading {...draggableProvided?.draggableProps} > + {objectMetadataItem.nameSingular === + CoreObjectNameSingular.Opportunity && + !isOpportunitiesCompanyFieldDisabled ? ( + + ) : ( + + )} - {objectMetadataItem.nameSingular === - CoreObjectNameSingular.Opportunity && - !isOpportunitiesCompanyFieldDisabled ? ( - - ) : ( - - )} + )} diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu.tsx index ef129890ad02..bfbfb7e9d0e1 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu.tsx @@ -1,11 +1,11 @@ import styled from '@emotion/styled'; -import { useCallback, useContext, useRef } from 'react'; +import { useCallback, useRef } from 'react'; -import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; +import { useRecordGroupActions } from '@/object-record/record-group/hooks/useRecordGroupActions'; import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; -import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; +import { MenuItem } from 'twenty-ui'; const StyledMenuContainer = styled.div` position: absolute; @@ -25,6 +25,8 @@ export const RecordBoardColumnDropdownMenu = ({ }: RecordBoardColumnDropdownMenuProps) => { const boardColumnMenuRef = useRef(null); + const recordGroupActions = useRecordGroupActions(); + const closeMenu = useCallback(() => { onClose(); }, [onClose]); @@ -34,13 +36,11 @@ export const RecordBoardColumnDropdownMenu = ({ callback: closeMenu, }); - const { columnDefinition } = useContext(RecordBoardColumnContext); - return ( - {columnDefinition.actions.map((action) => ( + {recordGroupActions.map((action) => ( { diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx index e0ae827280d9..6c6590ec0545 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeader.tsx @@ -1,19 +1,16 @@ import styled from '@emotion/styled'; import { useContext, useState } from 'react'; -import { IconDotsVertical, IconPlus, Tag } from 'twenty-ui'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { RecordBoardContext } from '@/object-record/record-board/contexts/RecordBoardContext'; -import { RecordBoardCard } from '@/object-record/record-board/record-board-card/components/RecordBoardCard'; import { RecordBoardColumnDropdownMenu } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnDropdownMenu'; import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; import { useColumnNewCardActions } from '@/object-record/record-board/record-board-column/hooks/useColumnNewCardActions'; import { useIsOpportunitiesCompanyFieldDisabled } from '@/object-record/record-board/record-board-column/hooks/useIsOpportunitiesCompanyFieldDisabled'; import { RecordBoardColumnHotkeyScope } from '@/object-record/record-board/types/BoardColumnHotkeyScope'; -import { RecordBoardColumnDefinitionType } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; -import { SingleEntitySelect } from '@/object-record/relation-picker/components/SingleEntitySelect'; -import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; +import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition'; import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; +import { IconDotsVertical, IconPlus, LightIconButton, Tag } from 'twenty-ui'; const StyledHeader = styled.div` align-items: center; @@ -21,7 +18,6 @@ const StyledHeader = styled.div` display: flex; flex-direction: row; justify-content: left; - margin-bottom: ${({ theme }) => theme.spacing(2)}; width: 100%; `; @@ -37,7 +33,7 @@ const StyledNumChildren = styled.div` height: 24px; justify-content: center; line-height: ${({ theme }) => theme.text.lineHeight.lg}; - width: 16px; + width: 22px; `; const StyledHeaderActions = styled.div` @@ -45,6 +41,7 @@ const StyledHeaderActions = styled.div` margin-left: auto; `; const StyledHeaderContainer = styled.div` + background: ${({ theme }) => theme.background.primary}; display: flex; justify-content: space-between; width: 100%; @@ -52,6 +49,7 @@ const StyledHeaderContainer = styled.div` const StyledLeftContainer = styled.div` align-items: center; display: flex; + gap: ${({ theme }) => theme.spacing(1)}; `; const StyledRightContainer = styled.div` @@ -59,13 +57,26 @@ const StyledRightContainer = styled.div` display: flex; `; +const StyledColumn = styled.div` + background-color: ${({ theme }) => theme.background.primary}; + display: flex; + flex-direction: column; + max-width: 200px; + min-width: 200px; + + padding: ${({ theme }) => theme.spacing(2)}; + + position: relative; +`; + export const RecordBoardColumnHeader = () => { - const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = useState(false); - const [isHeaderHovered, setIsHeaderHovered] = useState(false); - const { objectMetadataItem } = useContext(RecordBoardContext); const { columnDefinition, recordCount } = useContext( RecordBoardColumnContext, ); + const [isBoardColumnMenuOpen, setIsBoardColumnMenuOpen] = useState(false); + const [isHeaderHovered, setIsHeaderHovered] = useState(false); + + const { objectMetadataItem } = useContext(RecordBoardContext); const { setHotkeyScopeAndMemorizePreviousScope, @@ -89,12 +100,10 @@ export const RecordBoardColumnHeader = () => { const boardColumnTotal = 0; - const { - newRecord, - handleNewButtonClick, - handleCreateSuccess, - handleEntitySelect, - } = useColumnNewCardActions(columnDefinition.id); + const { handleNewButtonClick } = useColumnNewCardActions( + columnDefinition?.id ?? '', + ); + const { isOpportunitiesCompanyFieldDisabled } = useIsOpportunitiesCompanyFieldDisabled(); @@ -103,7 +112,7 @@ export const RecordBoardColumnHeader = () => { !isOpportunitiesCompanyFieldDisabled; return ( - <> + setIsHeaderHovered(true)} onMouseLeave={() => setIsHeaderHovered(false)} @@ -113,18 +122,18 @@ export const RecordBoardColumnHeader = () => { { {isHeaderHovered && ( - {columnDefinition.actions.length > 0 && ( - - )} + { - {isBoardColumnMenuOpen && columnDefinition.actions.length > 0 && ( + {isBoardColumnMenuOpen && ( )} - {newRecord?.isCreating && - newRecord.position === 'first' && - (newRecord.isOpportunity ? ( - handleCreateSuccess('first', columnDefinition.id)} - onEntitySelected={(company) => - company && handleEntitySelect('first', company) - } - relationObjectNameSingular={CoreObjectNameSingular.Company} - relationPickerScopeId="relation-picker" - selectedRelationRecordIds={[]} - /> - ) : ( - handleCreateSuccess('first')} - position="first" - /> - ))} - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderWrapper.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderWrapper.tsx new file mode 100644 index 000000000000..1386b494d6ef --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnHeaderWrapper.tsx @@ -0,0 +1,38 @@ +import { isDefined } from 'twenty-ui'; + +import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; +import { RecordBoardColumnHeader } from '@/object-record/record-board/record-board-column/components/RecordBoardColumnHeader'; +import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; +import { useRecoilValue } from 'recoil'; + +type RecordBoardColumnHeaderWrapperProps = { + columnId: string; +}; + +export const RecordBoardColumnHeaderWrapper = ({ + columnId, +}: RecordBoardColumnHeaderWrapperProps) => { + const { columnsFamilySelector, recordIdsByColumnIdFamilyState } = + useRecordBoardStates(); + + const columnDefinition = useRecoilValue(columnsFamilySelector(columnId)); + + const recordIds = useRecoilValue(recordIdsByColumnIdFamilyState(columnId)); + + if (!isDefined(columnDefinition)) { + return null; + } + + return ( + + + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewOpportunity.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewOpportunity.tsx new file mode 100644 index 000000000000..d8215faeb923 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewOpportunity.tsx @@ -0,0 +1,54 @@ +import styled from '@emotion/styled'; + +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useAddNewCard } from '@/object-record/record-board/record-board-column/hooks/useAddNewCard'; +import { recordBoardNewRecordByColumnIdSelector } from '@/object-record/record-board/states/selectors/recordBoardNewRecordByColumnIdSelector'; +import { SingleEntitySelect } from '@/object-record/relation-picker/components/SingleEntitySelect'; +import { useRecoilValue } from 'recoil'; + +const StyledCompanyPickerContainer = styled.div` + align-items: center; + align-self: baseline; + background-color: ${({ theme }) => theme.background.primary}; + border: none; + border-radius: ${({ theme }) => theme.border.radius.sm}; + color: ${({ theme }) => theme.font.color.tertiary}; + cursor: pointer; + display: flex; + gap: ${({ theme }) => theme.spacing(1)}; +`; + +export const RecordBoardColumnNewOpportunity = ({ + columnId, + position, +}: { + columnId: string; + position: 'last' | 'first'; +}) => { + const newRecord = useRecoilValue( + recordBoardNewRecordByColumnIdSelector({ + familyKey: columnId, + scopeId: columnId, + }), + ); + const { handleCreateSuccess, handleEntitySelect } = useAddNewCard(); + + return ( + <> + {newRecord.isCreating && newRecord.position === position && ( + + handleCreateSuccess(position, columnId, false)} + onEntitySelected={(company) => + company ? handleEntitySelect(position, company) : null + } + relationObjectNameSingular={CoreObjectNameSingular.Company} + relationPickerScopeId="relation-picker" + selectedRelationRecordIds={[]} + /> + + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewOpportunityButton.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewOpportunityButton.tsx deleted file mode 100644 index 55e9e2e1e581..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewOpportunityButton.tsx +++ /dev/null @@ -1,62 +0,0 @@ -import { useTheme } from '@emotion/react'; -import styled from '@emotion/styled'; -import { IconPlus } from 'twenty-ui'; - -import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; -import { useColumnNewCardActions } from '@/object-record/record-board/record-board-column/hooks/useColumnNewCardActions'; -import { SingleEntitySelect } from '@/object-record/relation-picker/components/SingleEntitySelect'; - -const StyledButton = styled.button` - align-items: center; - align-self: baseline; - background-color: ${({ theme }) => theme.background.primary}; - border: none; - border-radius: ${({ theme }) => theme.border.radius.sm}; - color: ${({ theme }) => theme.font.color.tertiary}; - cursor: pointer; - display: flex; - gap: ${({ theme }) => theme.spacing(1)}; - padding: ${({ theme }) => theme.spacing(1)}; - - &:hover { - background-color: ${({ theme }) => theme.background.tertiary}; - } -`; - -export const RecordBoardColumnNewOpportunityButton = ({ - columnId, -}: { - columnId: string; -}) => { - const theme = useTheme(); - - const { - newRecord, - handleNewButtonClick, - handleEntitySelect, - handleCreateSuccess, - } = useColumnNewCardActions(columnId); - return ( - <> - {newRecord.isCreating && - newRecord.position === 'last' && - newRecord.isOpportunity ? ( - handleCreateSuccess('last', columnId, false)} - onEntitySelected={(company) => - company ? handleEntitySelect('last', company) : null - } - relationObjectNameSingular={CoreObjectNameSingular.Company} - relationPickerScopeId="relation-picker" - selectedRelationRecordIds={[]} - /> - ) : ( - handleNewButtonClick('last', true)}> - - New - - )} - - ); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewRecord.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewRecord.tsx new file mode 100644 index 000000000000..091628f35afa --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewRecord.tsx @@ -0,0 +1,32 @@ +import { RecordBoardCard } from '@/object-record/record-board/record-board-card/components/RecordBoardCard'; +import { useAddNewCard } from '@/object-record/record-board/record-board-column/hooks/useAddNewCard'; +import { recordBoardNewRecordByColumnIdSelector } from '@/object-record/record-board/states/selectors/recordBoardNewRecordByColumnIdSelector'; +import { useRecoilValue } from 'recoil'; + +export const RecordBoardColumnNewRecord = ({ + columnId, + position, +}: { + columnId: string; + position: 'first' | 'last'; +}) => { + const newRecord = useRecoilValue( + recordBoardNewRecordByColumnIdSelector({ + familyKey: columnId, + scopeId: columnId, + }), + ); + const { handleCreateSuccess } = useAddNewCard(); + + return ( + <> + {newRecord.isCreating && newRecord.position === position && ( + handleCreateSuccess(position)} + position={position} + /> + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewButton.tsx b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewRecordButton.tsx similarity index 64% rename from packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewButton.tsx rename to packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewRecordButton.tsx index 428b9921f55d..2ecbb309a095 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewButton.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/components/RecordBoardColumnNewRecordButton.tsx @@ -1,4 +1,3 @@ -import { RecordBoardCard } from '@/object-record/record-board/record-board-card/components/RecordBoardCard'; import { useColumnNewCardActions } from '@/object-record/record-board/record-board-column/hooks/useColumnNewCardActions'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; @@ -15,34 +14,20 @@ const StyledNewButton = styled.button` display: flex; gap: ${({ theme }) => theme.spacing(1)}; padding: ${({ theme }) => theme.spacing(1)}; + &:hover { background-color: ${({ theme }) => theme.background.tertiary}; } `; -export const RecordBoardColumnNewButton = ({ +export const RecordBoardColumnNewRecordButton = ({ columnId, }: { columnId: string; }) => { const theme = useTheme(); - const { newRecord, handleNewButtonClick, handleCreateSuccess } = - useColumnNewCardActions(columnId); - - if ( - newRecord.isCreating && - newRecord.position === 'last' && - !newRecord.isOpportunity - ) { - return ( - handleCreateSuccess('last')} - position="last" - /> - ); - } + const { handleNewButtonClick } = useColumnNewCardActions(columnId); return ( handleNewButtonClick('last', false)}> diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts index 8a9ced3eb00b..1d000084f3b5 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext.ts @@ -1,12 +1,12 @@ import { createContext } from 'react'; -import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; +import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; type RecordBoardColumnContextProps = { - columnDefinition: RecordBoardColumnDefinition; - isFirstColumn: boolean; - isLastColumn: boolean; + columnDefinition: RecordGroupDefinition; recordCount: number; + columnId: string; + recordIds: string[]; }; export const RecordBoardColumnContext = diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAddNewCard.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAddNewCard.ts index 97cb8c5d6052..533a834e4c09 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAddNewCard.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useAddNewCard.ts @@ -7,7 +7,9 @@ import { RelationPickerHotkeyScope } from '@/object-record/relation-picker/types import { usePreviousHotkeyScope } from '@/ui/utilities/hotkey/hooks/usePreviousHotkeyScope'; import { useCallback, useContext } from 'react'; import { RecoilState, useRecoilCallback } from 'recoil'; +import { isDefined } from 'twenty-ui'; import { v4 as uuidv4 } from 'uuid'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; type SetFunction = ( recoilVal: RecoilState, @@ -16,7 +18,7 @@ type SetFunction = ( export const useAddNewCard = () => { const columnContext = useContext(RecordBoardColumnContext); - const { createOneRecord, selectFieldMetadataItem } = + const { createOneRecord, selectFieldMetadataItem, objectMetadataItem } = useContext(RecordBoardContext); const { resetSearchFilter } = useEntitySelectSearch({ relationPickerScopeId: 'relation-picker', @@ -75,16 +77,47 @@ export const useAddNewCard = () => { (isOpportunity && company !== null) || (!isOpportunity && labelValue !== '') ) { + // TODO: Refactor this whole section (Add new card): this should be: + // - simpler + // - piloted by metadata, + // - avoid drill down props, especially internal stuff + // - and follow record table pending record creation logic + let computedLabelIdentifierValue: any = labelValue; + + const labelIdentifierField = objectMetadataItem?.fields.find( + (field) => + field.id === objectMetadataItem.labelIdentifierFieldMetadataId, + ); + + if (!isDefined(labelIdentifierField)) { + throw new Error('Label identifier field not found'); + } + + if (labelIdentifierField.type === FieldMetadataType.FullName) { + computedLabelIdentifierValue = { + firstName: labelValue, + lastName: '', + }; + } + createOneRecord({ [selectFieldMetadataItem.name]: columnContext?.columnDefinition.value, position, ...(isOpportunity ? { companyId: company?.id, name: company?.name } - : { [labelIdentifier.toLowerCase()]: labelValue }), + : { + [labelIdentifier.toLowerCase()]: computedLabelIdentifierValue, + }), }); } }, - [createOneRecord, columnContext, selectFieldMetadataItem], + [ + objectMetadataItem?.fields, + objectMetadataItem?.labelIdentifierFieldMetadataId, + createOneRecord, + selectFieldMetadataItem?.name, + columnContext?.columnDefinition?.value, + ], ); const handleAddNewCardClick = useRecoilCallback( diff --git a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useColumnNewCardActions.ts b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useColumnNewCardActions.ts index eb498d5ae447..cb087c0813a8 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useColumnNewCardActions.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/record-board-column/hooks/useColumnNewCardActions.ts @@ -1,6 +1,5 @@ import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; import { useAddNewCard } from '@/object-record/record-board/record-board-column/hooks/useAddNewCard'; -import { recordBoardNewRecordByColumnIdSelector } from '@/object-record/record-board/states/selectors/recordBoardNewRecordByColumnIdSelector'; import { useRecoilValue } from 'recoil'; export const useColumnNewCardActions = (columnId: string) => { @@ -12,15 +11,7 @@ export const useColumnNewCardActions = (columnId: string) => { (field) => field.isLabelIdentifier, ); - const { handleAddNewCardClick, handleCreateSuccess, handleEntitySelect } = - useAddNewCard(); - - const newRecord = useRecoilValue( - recordBoardNewRecordByColumnIdSelector({ - familyKey: columnId, - scopeId: columnId, - }), - ); + const { handleAddNewCardClick } = useAddNewCard(); const handleNewButtonClick = ( position: 'first' | 'last', @@ -36,9 +27,6 @@ export const useColumnNewCardActions = (columnId: string) => { }; return { - newRecord, handleNewButtonClick, - handleCreateSuccess, - handleEntitySelect, }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-board/scopes/RecordBoardScope.tsx b/packages/twenty-front/src/modules/object-record/record-board/scopes/RecordBoardScope.tsx index 0de4d6025714..2f61769ed16c 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/scopes/RecordBoardScope.tsx +++ b/packages/twenty-front/src/modules/object-record/record-board/scopes/RecordBoardScope.tsx @@ -1,17 +1,18 @@ import { ReactNode } from 'react'; import { RecordBoardScopeInternalContext } from '@/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext'; -import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; +import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; type RecordBoardScopeProps = { children: ReactNode; recordBoardScopeId: string; onFieldsChange: (fields: FieldDefinition[]) => void; - onColumnsChange: (column: RecordBoardColumnDefinition[]) => void; + onColumnsChange: (column: RecordGroupDefinition[]) => void; }; +/** @deprecated */ export const RecordBoardScope = ({ children, recordBoardScopeId, diff --git a/packages/twenty-front/src/modules/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext.ts b/packages/twenty-front/src/modules/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext.ts index 44ac1e08e1b8..330ff18aba5e 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/scopes/scope-internal-context/RecordBoardScopeInternalContext.ts @@ -1,12 +1,12 @@ -import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; +import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext'; import { RecoilComponentStateKey } from '@/ui/utilities/state/component-state/types/RecoilComponentStateKey'; type RecordBoardScopeInternalContextProps = RecoilComponentStateKey & { onFieldsChange: (fields: FieldDefinition[]) => void; - onColumnsChange: (column: RecordBoardColumnDefinition[]) => void; + onColumnsChange: (column: RecordGroupDefinition[]) => void; }; export const RecordBoardScopeInternalContext = diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isFirstRecordBoardColumnComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isFirstRecordBoardColumnComponentFamilyState.ts deleted file mode 100644 index bef4219700d4..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/isFirstRecordBoardColumnComponentFamilyState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; - -export const isFirstRecordBoardColumnComponentFamilyState = - createComponentFamilyState({ - key: 'isFirstRecordBoardColumnComponentFamilyState', - defaultValue: false, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/isLastRecordBoardColumnComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/isLastRecordBoardColumnComponentFamilyState.ts deleted file mode 100644 index 9174fba1cac2..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/states/isLastRecordBoardColumnComponentFamilyState.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; - -export const isLastRecordBoardColumnComponentFamilyState = - createComponentFamilyState({ - key: 'isLastRecordBoardColumnComponentFamilyState', - defaultValue: false, - }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnsComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnsComponentFamilyState.ts index c2b6cc1cfe5d..1530820d803f 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnsComponentFamilyState.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/states/recordBoardColumnsComponentFamilyState.ts @@ -1,8 +1,8 @@ -import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; +import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; export const recordBoardColumnsComponentFamilyState = - createComponentFamilyState({ + createComponentFamilyState({ key: 'recordBoardColumnsComponentFamilyState', defaultValue: undefined, }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardColumnsComponentFamilySelector.ts b/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardColumnsComponentFamilySelector.ts index 22dd7aa8df66..fefff8451060 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardColumnsComponentFamilySelector.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/states/selectors/recordBoardColumnsComponentFamilySelector.ts @@ -1,19 +1,9 @@ -import { isUndefined } from '@sniptt/guards'; - -import { isFirstRecordBoardColumnComponentFamilyState } from '@/object-record/record-board/states/isFirstRecordBoardColumnComponentFamilyState'; -import { isLastRecordBoardColumnComponentFamilyState } from '@/object-record/record-board/states/isLastRecordBoardColumnComponentFamilyState'; -import { recordBoardColumnIdsComponentState } from '@/object-record/record-board/states/recordBoardColumnIdsComponentState'; import { recordBoardColumnsComponentFamilyState } from '@/object-record/record-board/states/recordBoardColumnsComponentFamilyState'; -import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; -import { guardRecoilDefaultValue } from '@/ui/utilities/recoil-scope/utils/guardRecoilDefaultValue'; import { createComponentFamilySelector } from '@/ui/utilities/state/component-state/utils/createComponentFamilySelector'; -import { isDefined } from '~/utils/isDefined'; +import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; export const recordBoardColumnsComponentFamilySelector = - createComponentFamilySelector< - RecordBoardColumnDefinition | undefined, - string - >({ + createComponentFamilySelector({ key: 'recordBoardColumnsComponentFamilySelector', get: ({ @@ -39,7 +29,7 @@ export const recordBoardColumnsComponentFamilySelector = scopeId: string; familyKey: string; }) => - ({ set, get }, newColumn) => { + ({ set }, newColumn) => { set( recordBoardColumnsComponentFamilyState({ scopeId, @@ -47,72 +37,5 @@ export const recordBoardColumnsComponentFamilySelector = }), newColumn, ); - - if (guardRecoilDefaultValue(newColumn)) return; - - const columnIds = get(recordBoardColumnIdsComponentState({ scopeId })); - - const columns = columnIds - .map((columnId) => { - return get( - recordBoardColumnsComponentFamilyState({ - scopeId, - familyKey: columnId, - }), - ); - }) - .filter(isDefined); - - const lastColumn = [...columns].sort( - (a, b) => b.position - a.position, - )[0]; - - const firstColumn = [...columns].sort( - (a, b) => a.position - b.position, - )[0]; - - if (!newColumn) { - return; - } - - if (!lastColumn || newColumn.position > lastColumn.position) { - set( - isLastRecordBoardColumnComponentFamilyState({ - scopeId, - familyKey: columnId, - }), - true, - ); - - if (!isUndefined(lastColumn)) { - set( - isLastRecordBoardColumnComponentFamilyState({ - scopeId, - familyKey: lastColumn.id, - }), - false, - ); - } - } - - if (!firstColumn || newColumn.position < firstColumn.position) { - set( - isFirstRecordBoardColumnComponentFamilyState({ - scopeId, - familyKey: columnId, - }), - true, - ); - - if (!isUndefined(firstColumn)) { - set( - isFirstRecordBoardColumnComponentFamilyState({ - scopeId, - familyKey: firstColumn.id, - }), - false, - ); - } - } }, }); diff --git a/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnDefinition.ts b/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnDefinition.ts deleted file mode 100644 index b5e443b0fd9a..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnDefinition.ts +++ /dev/null @@ -1,31 +0,0 @@ -import { ThemeColor } from 'twenty-ui'; - -import { RecordBoardColumnAction } from '@/object-record/record-board/types/RecordBoardColumnAction'; - -export const enum RecordBoardColumnDefinitionType { - Value = 'value', - NoValue = 'no-value', -} - -export type RecordBoardColumnDefinitionNoValue = { - id: 'no-value'; - type: RecordBoardColumnDefinitionType.NoValue; - title: 'No Value'; - position: number; - value: null; - actions: RecordBoardColumnAction[]; -}; - -export type RecordBoardColumnDefinitionValue = { - id: string; - type: RecordBoardColumnDefinitionType.Value; - title: string; - value: string; - color: ThemeColor; - position: number; - actions: RecordBoardColumnAction[]; -}; - -export type RecordBoardColumnDefinition = - | RecordBoardColumnDefinitionValue - | RecordBoardColumnDefinitionNoValue; diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/get-dragged-record-position.util.test.ts b/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/getDraggedRecordPosition.test.ts similarity index 92% rename from packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/get-dragged-record-position.util.test.ts rename to packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/getDraggedRecordPosition.test.ts index 1359c2d597be..483c323f07c6 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/get-dragged-record-position.util.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-board/utils/__tests__/getDraggedRecordPosition.test.ts @@ -1,4 +1,4 @@ -import { getDraggedRecordPosition } from '../get-dragged-record-position.util'; +import { getDraggedRecordPosition } from '../getDraggedRecordPosition'; describe('getDraggedRecordPosition', () => { it('when both records defined and positive, should return the average of the two positions', () => { diff --git a/packages/twenty-front/src/modules/object-record/record-board/utils/get-dragged-record-position.util.ts b/packages/twenty-front/src/modules/object-record/record-board/utils/getDraggedRecordPosition.ts similarity index 100% rename from packages/twenty-front/src/modules/object-record/record-board/utils/get-dragged-record-position.util.ts rename to packages/twenty-front/src/modules/object-record/record-board/utils/getDraggedRecordPosition.ts diff --git a/packages/twenty-front/src/modules/object-record/record-field/__mocks__/fieldDefinitions.ts b/packages/twenty-front/src/modules/object-record/record-field/__mocks__/fieldDefinitions.ts index 07cb30704498..0fe2fc3caa74 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/__mocks__/fieldDefinitions.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/__mocks__/fieldDefinitions.ts @@ -5,7 +5,7 @@ import { FieldFullNameMetadata, FieldRatingMetadata, FieldSelectMetadata, - FieldTextMetadata + FieldTextMetadata, } from '@/object-record/record-field/types/FieldMetadata'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; @@ -29,7 +29,6 @@ if (!mockedPersonObjectMetadataItem) { throw new Error('Person object metadata item not found'); } - const relationFieldMetadataItem = mockedPersonObjectMetadataItem?.fields?.find( ({ name }) => name === 'company', ); @@ -110,4 +109,4 @@ export const actorFieldDefinition: FieldDefinition = { metadata: { fieldName: 'actor', }, -}; \ No newline at end of file +}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/components/LightCopyIconButton.tsx b/packages/twenty-front/src/modules/object-record/record-field/components/LightCopyIconButton.tsx index 61b6b0fb69b3..79ee5f222281 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/components/LightCopyIconButton.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/components/LightCopyIconButton.tsx @@ -1,10 +1,9 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { IconCopy } from 'twenty-ui'; +import { IconCopy, LightIconButton } from 'twenty-ui'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; const StyledButtonContainer = styled.div` padding: 0 ${({ theme }) => theme.spacing(1)}; diff --git a/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useToggleEditOnlyInput.test.tsx b/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useToggleEditOnlyInput.test.tsx index 5ec442483de4..7f3695a35d5f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useToggleEditOnlyInput.test.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/hooks/__tests__/useToggleEditOnlyInput.test.tsx @@ -47,22 +47,6 @@ const mocks: MockedResponse[] = [ userId } accountOwnerId - activityTargets { - edges { - node { - __typename - activityId - companyId - createdAt - deletedAt - id - opportunityId - personId - rocketId - updatedAt - } - } - } address { addressStreet1 addressStreet2 @@ -81,7 +65,6 @@ const mocks: MockedResponse[] = [ edges { node { __typename - activityId authorId companyId createdAt @@ -129,6 +112,8 @@ const mocks: MockedResponse[] = [ updatedAt viewId workflowId + workflowRunId + workflowVersionId workspaceMemberId } } @@ -278,6 +263,9 @@ const mocks: MockedResponse[] = [ rocketId taskId updatedAt + workflowId + workflowRunId + workflowVersionId workspaceMemberId } } diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/__stories__/FieldContextProvider.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/components/FieldContextProvider.tsx similarity index 100% rename from packages/twenty-front/src/modules/object-record/record-field/meta-types/__stories__/FieldContextProvider.tsx rename to packages/twenty-front/src/modules/object-record/record-field/meta-types/components/FieldContextProvider.tsx diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/ChipFieldDisplay.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/ChipFieldDisplay.tsx index 94360aa0f23e..851460f5d24d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/ChipFieldDisplay.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/display/components/ChipFieldDisplay.tsx @@ -1,6 +1,7 @@ import { RecordChip } from '@/object-record/components/RecordChip'; import { useChipFieldDisplay } from '@/object-record/record-field/meta-types/hooks/useChipFieldDisplay'; import { RecordIdentifierChip } from '@/object-record/record-index/components/RecordIndexRecordChip'; +import { ChipSize } from 'twenty-ui'; export const ChipFieldDisplay = () => { const { recordValue, objectNameSingular, isLabelIdentifier } = @@ -14,6 +15,7 @@ export const ChipFieldDisplay = () => { ) : ( diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useChipFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useChipFieldDisplay.ts index 6451634ef5d7..6569e987c38f 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useChipFieldDisplay.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useChipFieldDisplay.ts @@ -1,7 +1,7 @@ import { isNonEmptyString } from '@sniptt/guards'; import { useContext } from 'react'; -import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext'; +import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext'; import { isFieldFullName } from '@/object-record/record-field/types/guards/isFieldFullName'; import { isFieldNumber } from '@/object-record/record-field/types/guards/isFieldNumber'; import { isFieldText } from '@/object-record/record-field/types/guards/isFieldText'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay.ts index 760a70b987ea..aec21e44b0d3 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationFromManyFieldDisplay.ts @@ -1,7 +1,7 @@ -import { useContext } from 'react'; import { isNonEmptyString } from '@sniptt/guards'; +import { useContext } from 'react'; -import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext'; +import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext'; import { generateDefaultRecordChipData } from '@/object-metadata/utils/generateDefaultRecordChipData'; import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationToOneFieldDisplay.ts b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationToOneFieldDisplay.ts index 85afbd90743d..ff380aff3e81 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationToOneFieldDisplay.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/hooks/useRelationToOneFieldDisplay.ts @@ -1,7 +1,7 @@ -import { useContext } from 'react'; import { isNonEmptyString } from '@sniptt/guards'; +import { useContext } from 'react'; -import { PreComputedChipGeneratorsContext } from '@/object-metadata/context/PreComputedChipGeneratorsContext'; +import { PreComputedChipGeneratorsContext } from '@/object-metadata/contexts/PreComputedChipGeneratorsContext'; import { generateDefaultRecordChipData } from '@/object-metadata/utils/generateDefaultRecordChipData'; import { useRecordFieldValue } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; import { ObjectRecord } from '@/object-record/types/ObjectRecord'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx index 7e3e93ec2c48..dd383e3477f5 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiItemFieldInput.tsx @@ -1,10 +1,9 @@ import styled from '@emotion/styled'; import React, { useRef, useState } from 'react'; import { Key } from 'ts-key-enum'; -import { IconCheck, IconPlus } from 'twenty-ui'; +import { IconCheck, IconPlus, LightIconButton, MenuItem } from 'twenty-ui'; import { PhoneRecord } from '@/object-record/record-field/types/FieldMetadata'; -import { LightIconButton } from '@/ui/input/button/components/LightIconButton'; import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { DropdownMenuInput, @@ -12,17 +11,16 @@ import { } from '@/ui/layout/dropdown/components/DropdownMenuInput'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; -import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { moveArrayItem } from '~/utils/array/moveArrayItem'; import { toSpliced } from '~/utils/array/toSpliced'; +import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly'; const StyledDropdownMenu = styled(DropdownMenu)` - left: -1px; - position: absolute; - top: -1px; + margin-left: -1px; + margin-top: -1px; `; type MultiItemFieldInputProps = { @@ -46,6 +44,7 @@ type MultiItemFieldInputProps = { }; // Todo: the API of this component does not look healthy: we have renderInput, renderItem, formatInput, ... +// This should be refactored with a hook instead that exposes those events in a context around this component and its children. export const MultiItemFieldInput = ({ items, onPersist, @@ -64,9 +63,14 @@ export const MultiItemFieldInput = ({ onCancel?.(); }; + const handleDropdownCloseOutside = (event: MouseEvent | TouchEvent) => { + onCancel?.(); + event.stopImmediatePropagation(); + }; + useListenClickOutside({ refs: [containerRef], - callback: handleDropdownClose, + callback: handleDropdownCloseOutside, }); useScopedHotkeys(Key.Escape, handleDropdownClose, hotkeyScope); @@ -84,9 +88,9 @@ export const MultiItemFieldInput = ({ setInputValue(value); if (!validateInput) return; - if (errorData.isValid) { - setErrorData(errorData); - } + setErrorData( + errorData.isValid ? errorData : { isValid: true, errorMessage: '' }, + ); }; const handleAddButtonClick = () => { @@ -190,7 +194,11 @@ export const MultiItemFieldInput = ({ }) : undefined } - onChange={(event) => handleOnChange(event.target.value)} + onChange={(event) => + handleOnChange( + turnIntoEmptyStringIfWhitespacesOnly(event.target.value), + ) + } onEnter={handleSubmitInput} rightComponent={ = { @@ -24,12 +21,6 @@ type MultiItemFieldMenuItemProps = { hasPrimaryButton?: boolean; }; -const StyledIconBookmark = styled(IconBookmark)` - color: ${({ theme }) => theme.font.color.light}; - height: ${({ theme }) => theme.icon.size.sm}px; - width: ${({ theme }) => theme.icon.size.sm}px; -`; - export const MultiItemFieldMenuItem = ({ dropdownId, isPrimary, @@ -44,69 +35,59 @@ export const MultiItemFieldMenuItem = ({ const { isDropdownOpen, closeDropdown } = useDropdown(dropdownId); const handleMouseEnter = () => setIsHovered(true); - const handleMouseLeave = () => setIsHovered(false); + const handleMouseLeave = () => { + setIsHovered(false); + if (isDropdownOpen) { + closeDropdown(); + } + }; const handleDeleteClick = () => { + closeDropdown(); setIsHovered(false); onDelete?.(); }; - useEffect(() => { - if (isDropdownOpen) { - return () => closeDropdown(); - } - }, [closeDropdown, isDropdownOpen]); + const handleSetAsPrimaryClick = () => { + closeDropdown(); + onSetAsPrimary?.(); + }; + + const handleEditClick = () => { + closeDropdown(); + onEdit?.(); + }; return ( - } isIconDisplayedOnHoverOnly={!isPrimary && !isDropdownOpen} - iconButtons={[ - { - Wrapper: isHovered - ? ({ iconButton }) => ( - - {hasPrimaryButton && !isPrimary && ( - - )} - - - - } - /> - ) - : undefined, - Icon: - isPrimary && !isHovered - ? (StyledIconBookmark as IconComponent) - : IconDotsVertical, - accent: 'tertiary', - onClick: isHovered ? () => {} : undefined, - }, - ]} + RightIcon={!isHovered && isPrimary ? IconBookmark : null} + dropdownId={dropdownId} + dropdownContent={ + + {hasPrimaryButton && !isPrimary && ( + + )} + + + + } /> ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx index 7ebe1951edb6..acebdcda987d 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/MultiSelectFieldInput.tsx @@ -12,9 +12,9 @@ import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownM import { SelectableList } from '@/ui/layout/selectable-list/components/SelectableList'; import { useSelectableListStates } from '@/ui/layout/selectable-list/hooks/internal/useSelectableListStates'; import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectableList'; -import { MenuItemMultiSelectTag } from '@/ui/navigation/menu-item/components/MenuItemMultiSelectTag'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; +import { MenuItemMultiSelectTag } from 'twenty-ui'; import { isDefined } from '~/utils/isDefined'; import { turnIntoEmptyStringIfWhitespacesOnly } from '~/utils/string/turnIntoEmptyStringIfWhitespacesOnly'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFromManyFieldInput.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFromManyFieldInput.tsx index be1cba38161b..d2ce24f8cc2e 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFromManyFieldInput.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFromManyFieldInput.tsx @@ -1,6 +1,5 @@ import { useContext } from 'react'; -import { ObjectMetadataItemsRelationPickerEffect } from '@/object-metadata/components/ObjectMetadataItemsRelationPickerEffect'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { FieldContext } from '@/object-record/record-field/contexts/FieldContext'; import { RelationFromManyFieldInputMultiRecordsEffect } from '@/object-record/record-field/meta-types/input/components/RelationFromManyFieldInputMultiRecordsEffect'; @@ -11,6 +10,7 @@ import { FieldRelationMetadata } from '@/object-record/record-field/types/FieldM import { MultiRecordSelect } from '@/object-record/relation-picker/components/MultiRecordSelect'; import { useAddNewRecordAndOpenRightDrawer } from '@/object-record/relation-picker/hooks/useAddNewRecordAndOpenRightDrawer'; import { RelationPickerScope } from '@/object-record/relation-picker/scopes/RelationPickerScope'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; type RelationFromManyFieldInputProps = { onSubmit?: FieldInputEvent; @@ -51,15 +51,17 @@ export const RelationFromManyFieldInput = ({ recordId, }); + const { dropdownPlacement } = useDropdown(relationPickerScopeId); + return ( <> - diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFromManyFieldInputMultiRecordsEffect.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFromManyFieldInputMultiRecordsEffect.tsx index 6f42a7fbad2e..866edfdada99 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFromManyFieldInputMultiRecordsEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/RelationFromManyFieldInputMultiRecordsEffect.tsx @@ -5,10 +5,10 @@ import { useObjectRecordMultiSelectScopedStates } from '@/activities/hooks/useOb import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useRelationField } from '@/object-record/record-field/meta-types/hooks/useRelationField'; import { objectRecordMultiSelectComponentFamilyState } from '@/object-record/record-field/states/objectRecordMultiSelectComponentFamilyState'; -import { ObjectRecordForSelect } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; import { useRelationPickerEntitiesOptions } from '@/object-record/relation-picker/hooks/useRelationPickerEntitiesOptions'; import { RelationPickerScopeInternalContext } from '@/object-record/relation-picker/scopes/scope-internal-context/RelationPickerScopeInternalContext'; import { EntityForSelect } from '@/object-record/relation-picker/types/EntityForSelect'; +import { ObjectRecordForSelect } from '@/object-record/types/ObjectRecordForSelect'; import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/AddressFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/AddressFieldInput.stories.tsx index be1a3cefa206..2cda4b62cb05 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/AddressFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/AddressFieldInput.stories.tsx @@ -1,6 +1,6 @@ -import { useEffect } from 'react'; import { Decorator, Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, waitFor } from '@storybook/test'; +import { useEffect } from 'react'; import { useAddressField } from '@/object-record/record-field/meta-types/hooks/useAddressField'; import { FieldAddressDraftValue } from '@/object-record/record-field/types/FieldInputDraftValue'; @@ -11,7 +11,7 @@ import { import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { FieldMetadataType } from '~/generated-metadata/graphql'; -import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; +import { FieldContextProvider } from '@/object-record/record-field/meta-types/components/FieldContextProvider'; const AddressValueSetterEffect = ({ value, diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/BooleanFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/BooleanFieldInput.stories.tsx index 3b6f3300b530..764dca8fa480 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/BooleanFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/BooleanFieldInput.stories.tsx @@ -1,12 +1,12 @@ -import { useEffect } from 'react'; import { Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, within } from '@storybook/test'; +import { useEffect } from 'react'; import { useSetRecoilState } from 'recoil'; import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; import { FieldMetadataType } from '~/generated/graphql'; -import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; +import { FieldContextProvider } from '@/object-record/record-field/meta-types/components/FieldContextProvider'; import { BooleanFieldInput, BooleanFieldInputProps, diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/DateTimeFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/DateTimeFieldInput.stories.tsx index 4c6c0d19a15f..c2155357971c 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/DateTimeFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/DateTimeFieldInput.stories.tsx @@ -1,11 +1,11 @@ -import { useEffect } from 'react'; import { Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, within } from '@storybook/test'; +import { useEffect } from 'react'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { FieldMetadataType } from '~/generated/graphql'; -import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; +import { FieldContextProvider } from '@/object-record/record-field/meta-types/components/FieldContextProvider'; import { useDateTimeField } from '../../../hooks/useDateTimeField'; import { DateTimeFieldInput, diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/NumberFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/NumberFieldInput.stories.tsx index 79d32f974ae6..e0b8e85bd5ca 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/NumberFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/NumberFieldInput.stories.tsx @@ -1,12 +1,12 @@ -import { useEffect } from 'react'; import { Decorator, Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; +import { useEffect } from 'react'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { FieldMetadataType } from '~/generated/graphql'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; -import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; +import { FieldContextProvider } from '@/object-record/record-field/meta-types/components/FieldContextProvider'; import { useNumberField } from '../../../hooks/useNumberField'; import { NumberFieldInput, NumberFieldInputProps } from '../NumberFieldInput'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RatingFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RatingFieldInput.stories.tsx index 5317c6ec36f5..dddbfbfc84d9 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RatingFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RatingFieldInput.stories.tsx @@ -1,13 +1,13 @@ -import { useEffect } from 'react'; import { Decorator, Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; +import { useEffect } from 'react'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { isDefined } from '~/utils/isDefined'; +import { FieldContextProvider } from '@/object-record/record-field/meta-types/components/FieldContextProvider'; import { FieldRatingValue } from '../../../../types/FieldMetadata'; -import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; import { useRatingField } from '../../../hooks/useRatingField'; import { RatingFieldInput, RatingFieldInputProps } from '../RatingFieldInput'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationManyFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationManyFieldInput.stories.tsx index 97f8f7e206e7..3a4cd095e1fa 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationManyFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationManyFieldInput.stories.tsx @@ -1,5 +1,5 @@ -import { useEffect } from 'react'; import { Meta, StoryObj } from '@storybook/react'; +import { useEffect } from 'react'; import { useSetRecoilState } from 'recoil'; import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; @@ -17,7 +17,7 @@ import { mockedWorkspaceMemberData, } from '~/testing/mock-data/users'; -import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; +import { FieldContextProvider } from '@/object-record/record-field/meta-types/components/FieldContextProvider'; const RelationWorkspaceSetterEffect = () => { const setCurrentWorkspace = useSetRecoilState(currentWorkspaceState); diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationToOneFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationToOneFieldInput.stories.tsx index 09c405061fb7..6bb6f02d2b03 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationToOneFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/RelationToOneFieldInput.stories.tsx @@ -25,7 +25,7 @@ import { mockedWorkspaceMemberData, } from '~/testing/mock-data/users'; -import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; +import { FieldContextProvider } from '@/object-record/record-field/meta-types/components/FieldContextProvider'; import { RelationToOneFieldInput, RelationToOneFieldInputProps, diff --git a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/TextFieldInput.stories.tsx b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/TextFieldInput.stories.tsx index ef9bdced6a5c..ec17c5ff92a3 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/TextFieldInput.stories.tsx +++ b/packages/twenty-front/src/modules/object-record/record-field/meta-types/input/components/__stories__/TextFieldInput.stories.tsx @@ -1,12 +1,12 @@ -import { useEffect } from 'react'; import { Decorator, Meta, StoryObj } from '@storybook/react'; import { expect, fn, userEvent, waitFor, within } from '@storybook/test'; +import { useEffect } from 'react'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { FieldMetadataType } from '~/generated/graphql'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; -import { FieldContextProvider } from '../../../__stories__/FieldContextProvider'; +import { FieldContextProvider } from '../../../components/FieldContextProvider'; import { useTextField } from '../../../hooks/useTextField'; import { TextFieldInput, TextFieldInputProps } from '../TextFieldInput'; diff --git a/packages/twenty-front/src/modules/object-record/record-field/states/objectRecordMultiSelectComponentFamilyState.ts b/packages/twenty-front/src/modules/object-record/record-field/states/objectRecordMultiSelectComponentFamilyState.ts index 0e15c962e11b..e4150ae029c6 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/states/objectRecordMultiSelectComponentFamilyState.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/states/objectRecordMultiSelectComponentFamilyState.ts @@ -1,4 +1,4 @@ -import { ObjectRecordForSelect } from '@/object-record/relation-picker/hooks/useMultiObjectSearch'; +import { ObjectRecordForSelect } from '@/object-record/types/ObjectRecordForSelect'; import { createComponentFamilyState } from '@/ui/utilities/state/component-state/utils/createComponentFamilyState'; export type ObjectRecordAndSelected = ObjectRecordForSelect & { diff --git a/packages/twenty-front/src/modules/object-record/record-field/states/objectRecordMultiSelectMatchesFilterRecordsIdsComponentState.ts b/packages/twenty-front/src/modules/object-record/record-field/states/objectRecordMultiSelectMatchesFilterRecordsIdsComponentState.ts new file mode 100644 index 000000000000..bfaebeaa86fa --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-field/states/objectRecordMultiSelectMatchesFilterRecordsIdsComponentState.ts @@ -0,0 +1,8 @@ +import { ObjectRecordForSelect } from '@/object-record/types/ObjectRecordForSelect'; +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const objectRecordMultiSelectMatchesFilterRecordsIdsComponentState = + createComponentState({ + key: 'objectRecordMultiSelectMatchesFilterRecordsIdsComponentState', + defaultValue: [], + }); diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/__tests__/isFieldValueEmpty.test.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/__tests__/isFieldValueEmpty.test.ts index 07761aef8822..db247716ab9a 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/__tests__/isFieldValueEmpty.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/__tests__/isFieldValueEmpty.test.ts @@ -46,6 +46,18 @@ describe('isFieldValueEmpty', () => { fieldValue: { foo: 'bar' }, }), ).toBe(false); + expect( + isFieldValueEmpty({ + fieldDefinition: relationFieldDefinition, + fieldValue: [], + }), + ).toBe(true); + expect( + isFieldValueEmpty({ + fieldDefinition: relationFieldDefinition, + fieldValue: [{ id: '123' }], + }), + ).toBe(false); }); it('should return correct value for select field', () => { diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.tsx b/packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.ts similarity index 100% rename from packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.tsx rename to packages/twenty-front/src/modules/object-record/record-field/utils/getFieldButtonIcon.ts diff --git a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts index 93ee5eaa5458..e8e3ebe3fb86 100644 --- a/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts +++ b/packages/twenty-front/src/modules/object-record/record-field/utils/isFieldValueEmpty.ts @@ -1,4 +1,4 @@ -import { isString } from '@sniptt/guards'; +import { isArray, isNonEmptyArray, isString } from '@sniptt/guards'; import { FieldDefinition } from '@/object-record/record-field/types/FieldDefinition'; import { FieldMetadata } from '@/object-record/record-field/types/FieldMetadata'; @@ -58,7 +58,6 @@ export const isFieldValueEmpty = ({ isFieldNumber(fieldDefinition) || isFieldRating(fieldDefinition) || isFieldBoolean(fieldDefinition) || - isFieldRelation(fieldDefinition) || isFieldRawJson(fieldDefinition) || isFieldRichText(fieldDefinition) || isFieldPosition(fieldDefinition) @@ -73,11 +72,19 @@ export const isFieldValueEmpty = ({ ); } + if (isFieldRelation(fieldDefinition)) { + if (isArray(fieldValue)) { + return !isNonEmptyArray(fieldValue); + } + return isValueEmpty(fieldValue); + } + if (isFieldMultiSelect(fieldDefinition) || isFieldArray(fieldDefinition)) { return ( !isFieldArrayValue(fieldValue) || !isFieldMultiSelectValue(fieldValue, selectOptionValues) || - !isDefined(fieldValue) + !isDefined(fieldValue) || + !isNonEmptyArray(fieldValue) ); } diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/turnObjectDropdownFilterIntoQueryFilter.test.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/computeViewRecordGqlOperationFilter.test.ts similarity index 97% rename from packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/turnObjectDropdownFilterIntoQueryFilter.test.ts rename to packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/computeViewRecordGqlOperationFilter.test.ts index 6486ca29b92e..31e31f86afb3 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/turnObjectDropdownFilterIntoQueryFilter.test.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/__tests__/computeViewRecordGqlOperationFilter.test.ts @@ -1,5 +1,5 @@ import { Filter } from '@/object-record/object-filter-dropdown/types/Filter'; -import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter'; +import { computeViewRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { getCompaniesMock } from '~/testing/mock-data/companies'; import { generatedMockObjectMetadataItems } from '~/testing/mock-data/generatedMockObjectMetadataItems'; @@ -16,7 +16,7 @@ const personMockObjectMetadataItem = generatedMockObjectMetadataItems.find( jest.useFakeTimers().setSystemTime(new Date('2020-01-01')); -describe('turnObjectDropdownFilterIntoQueryFilter', () => { +describe('computeViewRecordGqlOperationFilter', () => { it('should work as expected for single filter', () => { const companyMockNameFieldMetadataId = companyMockObjectMetadataItem.fields.find( @@ -37,9 +37,10 @@ describe('turnObjectDropdownFilterIntoQueryFilter', () => { }, }; - const result = turnObjectDropdownFilterIntoQueryFilter( + const result = computeViewRecordGqlOperationFilter( [nameFilter], companyMockObjectMetadataItem.fields, + [], ); expect(result).toEqual({ @@ -88,9 +89,10 @@ describe('turnObjectDropdownFilterIntoQueryFilter', () => { }, }; - const result = turnObjectDropdownFilterIntoQueryFilter( + const result = computeViewRecordGqlOperationFilter( [nameFilter, employeesFilter], companyMockObjectMetadataItem.fields, + [], ); expect(result).toEqual({ @@ -173,7 +175,7 @@ describe('should work as expected for the different field types', () => { }, }; - const result = turnObjectDropdownFilterIntoQueryFilter( + const result = computeViewRecordGqlOperationFilter( [ addressFilterContains, addressFilterDoesNotContain, @@ -181,6 +183,7 @@ describe('should work as expected for the different field types', () => { addressFilterIsNotEmpty, ], companyMockObjectMetadataItem.fields, + [], ); expect(result).toEqual({ @@ -554,7 +557,7 @@ describe('should work as expected for the different field types', () => { }, }; - const result = turnObjectDropdownFilterIntoQueryFilter( + const result = computeViewRecordGqlOperationFilter( [ phonesFilterContains, phonesFilterDoesNotContain, @@ -562,6 +565,7 @@ describe('should work as expected for the different field types', () => { phonesFilterIsNotEmpty, ], personMockObjectMetadataItem.fields, + [], ); expect(result).toEqual({ @@ -754,7 +758,7 @@ describe('should work as expected for the different field types', () => { }, }; - const result = turnObjectDropdownFilterIntoQueryFilter( + const result = computeViewRecordGqlOperationFilter( [ emailsFilterContains, emailsFilterDoesNotContain, @@ -762,6 +766,7 @@ describe('should work as expected for the different field types', () => { emailsFilterIsNotEmpty, ], personMockObjectMetadataItem.fields, + [], ); expect(result).toEqual({ @@ -908,7 +913,7 @@ describe('should work as expected for the different field types', () => { }, }; - const result = turnObjectDropdownFilterIntoQueryFilter( + const result = computeViewRecordGqlOperationFilter( [ dateFilterIsAfter, dateFilterIsBefore, @@ -917,6 +922,7 @@ describe('should work as expected for the different field types', () => { dateFilterIsNotEmpty, ], companyMockObjectMetadataItem.fields, + [], ); expect(result).toEqual({ @@ -1023,7 +1029,7 @@ describe('should work as expected for the different field types', () => { }, }; - const result = turnObjectDropdownFilterIntoQueryFilter( + const result = computeViewRecordGqlOperationFilter( [ employeesFilterIsGreaterThan, employeesFilterIsLessThan, @@ -1031,6 +1037,7 @@ describe('should work as expected for the different field types', () => { employeesFilterIsNotEmpty, ], companyMockObjectMetadataItem.fields, + [], ); expect(result).toEqual({ diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/computeViewRecordGqlOperationFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/computeViewRecordGqlOperationFilter.ts new file mode 100644 index 000000000000..c57bce3b4f52 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/computeViewRecordGqlOperationFilter.ts @@ -0,0 +1,903 @@ +import { isNonEmptyString } from '@sniptt/guards'; + +import { + ActorFilter, + AddressFilter, + CurrencyFilter, + DateFilter, + EmailsFilter, + FloatFilter, + RawJsonFilter, + RecordGqlOperationFilter, + RelationFilter, + StringFilter, + UUIDFilter, +} from '@/object-record/graphql/types/RecordGqlOperationFilter'; +import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; +import { Field } from '~/generated/graphql'; +import { generateILikeFiltersForCompositeFields } from '~/utils/array/generateILikeFiltersForCompositeFields'; +import { isDefined } from '~/utils/isDefined'; + +import { + convertGreaterThanRatingToArrayOfRatingValues, + convertLessThanRatingToArrayOfRatingValues, + convertRatingToRatingValue, +} from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRatingInput'; +import { Filter } from '@/object-record/object-filter-dropdown/types/Filter'; +import { getEmptyRecordGqlOperationFilter } from '@/object-record/record-filter/utils/getEmptyRecordGqlOperationFilter'; +import { ViewFilterGroup } from '@/views/types/ViewFilterGroup'; +import { ViewFilterGroupLogicalOperator } from '@/views/types/ViewFilterGroupLogicalOperator'; +import { resolveFilterValue } from '@/views/view-filter-value/utils/resolveFilterValue'; +import { endOfDay, roundToNearestMinutes, startOfDay } from 'date-fns'; +import { z } from 'zod'; + +const computeFilterRecordGqlOperationFilter = ( + filter: Filter, + fields: Pick[], +): RecordGqlOperationFilter | undefined => { + const correspondingField = fields.find( + (field) => field.id === filter.fieldMetadataId, + ); + + const compositeFieldName = filter.definition.compositeFieldName; + + const isCompositeFieldFiter = isNonEmptyString(compositeFieldName); + + const isEmptyOperand = [ + ViewFilterOperand.IsEmpty, + ViewFilterOperand.IsNotEmpty, + ViewFilterOperand.IsInPast, + ViewFilterOperand.IsInFuture, + ViewFilterOperand.IsToday, + ].includes(filter.operand); + + if (!correspondingField) { + return; + } + + if (!isEmptyOperand) { + if (!isDefined(filter.value) || filter.value === '') { + return; + } + } + + switch (filter.definition.type) { + case 'TEXT': + switch (filter.operand) { + case ViewFilterOperand.Contains: + return { + [correspondingField.name]: { + ilike: `%${filter.value}%`, + } as StringFilter, + }; + case ViewFilterOperand.DoesNotContain: + return { + not: { + [correspondingField.name]: { + ilike: `%${filter.value}%`, + } as StringFilter, + }, + }; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + return getEmptyRecordGqlOperationFilter( + filter.operand, + correspondingField, + filter.definition, + ); + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, + ); + } + case 'RAW_JSON': + switch (filter.operand) { + case ViewFilterOperand.Contains: + return { + [correspondingField.name]: { + like: `%${filter.value}%`, + } as RawJsonFilter, + }; + case ViewFilterOperand.DoesNotContain: + return { + not: { + [correspondingField.name]: { + like: `%${filter.value}%`, + } as RawJsonFilter, + }, + }; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + return getEmptyRecordGqlOperationFilter( + filter.operand, + correspondingField, + filter.definition, + ); + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, + ); + } + case 'DATE': + case 'DATE_TIME': { + const resolvedFilterValue = resolveFilterValue(filter); + const now = roundToNearestMinutes(new Date()); + const date = + resolvedFilterValue instanceof Date ? resolvedFilterValue : now; + + switch (filter.operand) { + case ViewFilterOperand.IsAfter: { + return { + [correspondingField.name]: { + gt: date.toISOString(), + } as DateFilter, + }; + } + case ViewFilterOperand.IsBefore: { + return { + [correspondingField.name]: { + lt: date.toISOString(), + } as DateFilter, + }; + } + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: { + return getEmptyRecordGqlOperationFilter( + filter.operand, + correspondingField, + filter.definition, + ); + } + case ViewFilterOperand.IsRelative: { + const dateRange = z + .object({ start: z.date(), end: z.date() }) + .safeParse(resolvedFilterValue).data; + + const defaultDateRange = resolveFilterValue({ + value: 'PAST_1_DAY', + definition: { + type: 'DATE', + }, + operand: ViewFilterOperand.IsRelative, + }); + + if (!defaultDateRange) { + throw new Error('Failed to resolve default date range'); + } + + const { start, end } = dateRange ?? defaultDateRange; + + return { + and: [ + { + [correspondingField.name]: { + gte: start.toISOString(), + } as DateFilter, + }, + { + [correspondingField.name]: { + lte: end.toISOString(), + } as DateFilter, + }, + ], + }; + } + case ViewFilterOperand.Is: { + const isValid = resolvedFilterValue instanceof Date; + const date = isValid ? resolvedFilterValue : now; + + return { + and: [ + { + [correspondingField.name]: { + lte: endOfDay(date).toISOString(), + } as DateFilter, + }, + { + [correspondingField.name]: { + gte: startOfDay(date).toISOString(), + } as DateFilter, + }, + ], + }; + } + case ViewFilterOperand.IsInPast: + return { + [correspondingField.name]: { + lte: now.toISOString(), + } as DateFilter, + }; + case ViewFilterOperand.IsInFuture: + return { + [correspondingField.name]: { + gte: now.toISOString(), + } as DateFilter, + }; + case ViewFilterOperand.IsToday: { + return { + and: [ + { + [correspondingField.name]: { + lte: endOfDay(now).toISOString(), + } as DateFilter, + }, + { + [correspondingField.name]: { + gte: startOfDay(now).toISOString(), + } as DateFilter, + }, + ], + }; + } + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, // + ); + } + } + case 'RATING': + switch (filter.operand) { + case ViewFilterOperand.Is: + return { + [correspondingField.name]: { + eq: convertRatingToRatingValue(parseFloat(filter.value)), + } as StringFilter, + }; + case ViewFilterOperand.GreaterThan: + return { + [correspondingField.name]: { + in: convertGreaterThanRatingToArrayOfRatingValues( + parseFloat(filter.value), + ), + } as StringFilter, + }; + case ViewFilterOperand.LessThan: + return { + [correspondingField.name]: { + in: convertLessThanRatingToArrayOfRatingValues( + parseFloat(filter.value), + ), + } as StringFilter, + }; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + return getEmptyRecordGqlOperationFilter( + filter.operand, + correspondingField, + filter.definition, + ); + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, + ); + } + case 'NUMBER': + switch (filter.operand) { + case ViewFilterOperand.GreaterThan: + return { + [correspondingField.name]: { + gte: parseFloat(filter.value), + } as FloatFilter, + }; + case ViewFilterOperand.LessThan: + return { + [correspondingField.name]: { + lte: parseFloat(filter.value), + } as FloatFilter, + }; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + return getEmptyRecordGqlOperationFilter( + filter.operand, + correspondingField, + filter.definition, + ); + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, + ); + } + case 'RELATION': { + if (!isEmptyOperand) { + try { + JSON.parse(filter.value); + } catch (e) { + throw new Error( + `Cannot parse filter value for RELATION filter : "${filter.value}"`, + ); + } + + const parsedRecordIds = JSON.parse(filter.value) as string[]; + + if (parsedRecordIds.length > 0) { + switch (filter.operand) { + case ViewFilterOperand.Is: + return { + [correspondingField.name + 'Id']: { + in: parsedRecordIds, + } as RelationFilter, + }; + case ViewFilterOperand.IsNot: + if (parsedRecordIds.length > 0) { + return { + not: { + [correspondingField.name + 'Id']: { + in: parsedRecordIds, + } as RelationFilter, + }, + }; + } + break; + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, + ); + } + } + } else { + switch (filter.operand) { + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + return getEmptyRecordGqlOperationFilter( + filter.operand, + correspondingField, + filter.definition, + ); + default: + throw new Error( + `Unknown empty operand ${filter.operand} for ${filter.definition.type} filter`, + ); + } + } + break; + } + case 'CURRENCY': + switch (filter.operand) { + case ViewFilterOperand.GreaterThan: + return { + [correspondingField.name]: { + amountMicros: { gte: parseFloat(filter.value) * 1000000 }, + } as CurrencyFilter, + }; + case ViewFilterOperand.LessThan: + return { + [correspondingField.name]: { + amountMicros: { lte: parseFloat(filter.value) * 1000000 }, + } as CurrencyFilter, + }; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + return getEmptyRecordGqlOperationFilter( + filter.operand, + correspondingField, + filter.definition, + ); + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, + ); + } + case 'LINKS': { + const linksFilters = generateILikeFiltersForCompositeFields( + filter.value, + correspondingField.name, + ['primaryLinkLabel', 'primaryLinkUrl'], + ); + + switch (filter.operand) { + case ViewFilterOperand.Contains: + if (!isCompositeFieldFiter) { + return { + or: linksFilters, + }; + } else { + return { + [correspondingField.name]: { + [compositeFieldName]: { + ilike: `%${filter.value}%`, + }, + }, + }; + } + case ViewFilterOperand.DoesNotContain: + if (!isCompositeFieldFiter) { + return { + and: linksFilters.map((filter) => { + return { + not: filter, + }; + }), + }; + } else { + return { + not: { + [correspondingField.name]: { + [compositeFieldName]: { + ilike: `%${filter.value}%`, + }, + }, + }, + }; + } + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + return getEmptyRecordGqlOperationFilter( + filter.operand, + correspondingField, + filter.definition, + ); + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, + ); + } + } + case 'FULL_NAME': { + const fullNameFilters = generateILikeFiltersForCompositeFields( + filter.value, + correspondingField.name, + ['firstName', 'lastName'], + ); + switch (filter.operand) { + case ViewFilterOperand.Contains: + if (!isCompositeFieldFiter) { + return { + or: fullNameFilters, + }; + } else { + return { + [correspondingField.name]: { + [compositeFieldName]: { + ilike: `%${filter.value}%`, + }, + }, + }; + } + case ViewFilterOperand.DoesNotContain: + if (!isCompositeFieldFiter) { + return { + and: fullNameFilters.map((filter) => { + return { + not: filter, + }; + }), + }; + } else { + return { + not: { + [correspondingField.name]: { + [compositeFieldName]: { + ilike: `%${filter.value}%`, + }, + }, + }, + }; + } + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + return getEmptyRecordGqlOperationFilter( + filter.operand, + correspondingField, + filter.definition, + ); + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, + ); + } + } + case 'ADDRESS': + switch (filter.operand) { + case ViewFilterOperand.Contains: + if (!isCompositeFieldFiter) { + return { + or: [ + { + [correspondingField.name]: { + addressStreet1: { + ilike: `%${filter.value}%`, + }, + } as AddressFilter, + }, + { + [correspondingField.name]: { + addressStreet2: { + ilike: `%${filter.value}%`, + }, + } as AddressFilter, + }, + { + [correspondingField.name]: { + addressCity: { + ilike: `%${filter.value}%`, + }, + } as AddressFilter, + }, + { + [correspondingField.name]: { + addressState: { + ilike: `%${filter.value}%`, + }, + } as AddressFilter, + }, + { + [correspondingField.name]: { + addressCountry: { + ilike: `%${filter.value}%`, + }, + } as AddressFilter, + }, + { + [correspondingField.name]: { + addressPostcode: { + ilike: `%${filter.value}%`, + }, + } as AddressFilter, + }, + ], + }; + } else { + return { + [correspondingField.name]: { + [compositeFieldName]: { + ilike: `%${filter.value}%`, + } as AddressFilter, + }, + }; + } + case ViewFilterOperand.DoesNotContain: + if (!isCompositeFieldFiter) { + return { + and: [ + { + not: { + [correspondingField.name]: { + addressStreet1: { + ilike: `%${filter.value}%`, + }, + } as AddressFilter, + }, + }, + { + not: { + [correspondingField.name]: { + addressStreet2: { + ilike: `%${filter.value}%`, + }, + } as AddressFilter, + }, + }, + { + not: { + [correspondingField.name]: { + addressCity: { + ilike: `%${filter.value}%`, + }, + } as AddressFilter, + }, + }, + ], + }; + } else { + return { + not: { + [correspondingField.name]: { + [compositeFieldName]: { + ilike: `%${filter.value}%`, + } as AddressFilter, + }, + }, + }; + } + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + return getEmptyRecordGqlOperationFilter( + filter.operand, + correspondingField, + filter.definition, + ); + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, + ); + } + case 'SELECT': { + if (isEmptyOperand) { + return getEmptyRecordGqlOperationFilter( + filter.operand, + correspondingField, + filter.definition, + ); + } + const stringifiedSelectValues = filter.value; + let parsedOptionValues: string[] = []; + + if (!isNonEmptyString(stringifiedSelectValues)) { + break; + } + + try { + parsedOptionValues = JSON.parse(stringifiedSelectValues); + } catch (e) { + throw new Error( + `Cannot parse filter value for SELECT filter : "${stringifiedSelectValues}"`, + ); + } + + if (parsedOptionValues.length > 0) { + switch (filter.operand) { + case ViewFilterOperand.Is: + return { + [correspondingField.name]: { + in: parsedOptionValues, + } as UUIDFilter, + }; + case ViewFilterOperand.IsNot: + return { + not: { + [correspondingField.name]: { + in: parsedOptionValues, + } as UUIDFilter, + }, + }; + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, + ); + } + } + break; + } + // TODO: fix this with a new composite field in ViewFilter entity + case 'ACTOR': { + switch (filter.operand) { + case ViewFilterOperand.Is: { + const parsedRecordIds = JSON.parse(filter.value) as string[]; + + return { + [correspondingField.name]: { + source: { + in: parsedRecordIds, + } as RelationFilter, + }, + }; + } + case ViewFilterOperand.IsNot: { + const parsedRecordIds = JSON.parse(filter.value) as string[]; + + if (parsedRecordIds.length > 0) { + return { + not: { + [correspondingField.name]: { + source: { + in: parsedRecordIds, + } as RelationFilter, + }, + }, + }; + } + break; + } + case ViewFilterOperand.Contains: + return { + or: [ + { + [correspondingField.name]: { + name: { + ilike: `%${filter.value}%`, + }, + } as ActorFilter, + }, + ], + }; + case ViewFilterOperand.DoesNotContain: + return { + and: [ + { + not: { + [correspondingField.name]: { + name: { + ilike: `%${filter.value}%`, + }, + } as ActorFilter, + }, + }, + ], + }; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + return getEmptyRecordGqlOperationFilter( + filter.operand, + correspondingField, + filter.definition, + ); + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.definition.label} filter`, + ); + } + break; + } + case 'EMAILS': + switch (filter.operand) { + case ViewFilterOperand.Contains: + return { + or: [ + { + [correspondingField.name]: { + primaryEmail: { + ilike: `%${filter.value}%`, + }, + } as EmailsFilter, + }, + ], + }; + case ViewFilterOperand.DoesNotContain: + return { + and: [ + { + not: { + [correspondingField.name]: { + primaryEmail: { + ilike: `%${filter.value}%`, + }, + } as EmailsFilter, + }, + }, + ], + }; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + return getEmptyRecordGqlOperationFilter( + filter.operand, + correspondingField, + filter.definition, + ); + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, + ); + } + case 'PHONES': { + const phonesFilters = generateILikeFiltersForCompositeFields( + filter.value, + correspondingField.name, + ['primaryPhoneNumber', 'primaryPhoneCountryCode'], + ); + switch (filter.operand) { + case ViewFilterOperand.Contains: + return { + or: phonesFilters, + }; + case ViewFilterOperand.DoesNotContain: + return { + and: phonesFilters.map((filter) => { + return { + not: filter, + }; + }), + }; + case ViewFilterOperand.IsEmpty: + case ViewFilterOperand.IsNotEmpty: + return getEmptyRecordGqlOperationFilter( + filter.operand, + correspondingField, + filter.definition, + ); + default: + throw new Error( + `Unknown operand ${filter.operand} for ${filter.definition.type} filter`, + ); + } + } + default: + throw new Error('Unknown filter type'); + } +}; + +const computeViewFilterGroupRecordGqlOperationFilter = ( + filters: Filter[], + fields: Pick[], + viewFilterGroups: ViewFilterGroup[], + currentViewFilterGroupId?: string, +): RecordGqlOperationFilter | undefined => { + const currentViewFilterGroup = viewFilterGroups.find( + (viewFilterGroup) => viewFilterGroup.id === currentViewFilterGroupId, + ); + + if (!currentViewFilterGroup) { + return undefined; + } + + const groupFilters = filters.filter( + (filter) => filter.viewFilterGroupId === currentViewFilterGroupId, + ); + + const groupRecordGqlOperationFilters = groupFilters + .map((filter) => computeFilterRecordGqlOperationFilter(filter, fields)) + .filter(isDefined); + + const subGroupRecordGqlOperationFilters = viewFilterGroups + .filter( + (viewFilterGroup) => + viewFilterGroup.parentViewFilterGroupId === currentViewFilterGroupId, + ) + .map((subViewFilterGroup) => + computeViewFilterGroupRecordGqlOperationFilter( + filters, + fields, + viewFilterGroups, + subViewFilterGroup.id, + ), + ) + .filter(isDefined); + + if ( + currentViewFilterGroup.logicalOperator === + ViewFilterGroupLogicalOperator.AND + ) { + return { + and: [ + ...groupRecordGqlOperationFilters, + ...subGroupRecordGqlOperationFilters, + ], + }; + } else if ( + currentViewFilterGroup.logicalOperator === ViewFilterGroupLogicalOperator.OR + ) { + return { + or: [ + ...groupRecordGqlOperationFilters, + ...subGroupRecordGqlOperationFilters, + ], + }; + } else { + throw new Error( + `Unknown logical operator ${currentViewFilterGroup.logicalOperator}`, + ); + } +}; + +export const computeViewRecordGqlOperationFilter = ( + filters: Filter[], + fields: Pick[], + viewFilterGroups: ViewFilterGroup[], +): RecordGqlOperationFilter => { + const regularRecordGqlOperationFilter: RecordGqlOperationFilter[] = filters + .filter((filter) => !filter.viewFilterGroupId) + .map((regularFilter) => + computeFilterRecordGqlOperationFilter(regularFilter, fields), + ) + .filter(isDefined); + + const outermostFilterGroupId = viewFilterGroups.find( + (viewFilterGroup) => !viewFilterGroup.parentViewFilterGroupId, + )?.id; + + const advancedRecordGqlOperationFilter = + computeViewFilterGroupRecordGqlOperationFilter( + filters, + fields, + viewFilterGroups, + outermostFilterGroupId, + ); + + const recordGqlOperationFilters = [ + ...regularRecordGqlOperationFilter, + advancedRecordGqlOperationFilter, + ].filter(isDefined); + + if (recordGqlOperationFilters.length === 0) { + return {}; + } + + if (recordGqlOperationFilters.length === 1) { + return recordGqlOperationFilters[0]; + } + + const recordGqlOperationFilter = { + and: recordGqlOperationFilters, + }; + + return recordGqlOperationFilter; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/applyEmptyFilters.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/getEmptyRecordGqlOperationFilter.ts similarity index 94% rename from packages/twenty-front/src/modules/object-record/record-filter/utils/applyEmptyFilters.ts rename to packages/twenty-front/src/modules/object-record/record-filter/utils/getEmptyRecordGqlOperationFilter.ts index e004288ceba0..f670e46e0f60 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/applyEmptyFilters.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/getEmptyRecordGqlOperationFilter.ts @@ -1,10 +1,12 @@ import { ActorFilter, AddressFilter, + ArrayFilter, CurrencyFilter, DateFilter, EmailsFilter, FloatFilter, + RawJsonFilter, RecordGqlOperationFilter, RelationFilter, StringFilter, @@ -17,11 +19,9 @@ import { isNonEmptyString } from '@sniptt/guards'; import { Field } from '~/generated/graphql'; import { generateILikeFiltersForCompositeFields } from '~/utils/array/generateILikeFiltersForCompositeFields'; -// TODO: fix this -export const applyEmptyFilters = ( +export const getEmptyRecordGqlOperationFilter = ( operand: ViewFilterOperand, correspondingField: Pick, - objectRecordFilters: RecordGqlOperationFilter[], definition: FilterDefinition, ) => { let emptyRecordFilter: RecordGqlOperationFilter = {}; @@ -290,6 +290,24 @@ export const applyEmptyFilters = ( ], }; break; + case 'ARRAY': + emptyRecordFilter = { + or: [ + { + [correspondingField.name]: { is: 'NULL' } as ArrayFilter, + }, + ], + }; + break; + case 'RAW_JSON': + emptyRecordFilter = { + or: [ + { + [correspondingField.name]: { is: 'NULL' } as RawJsonFilter, + }, + ], + }; + break; case 'EMAILS': emptyRecordFilter = { or: [ @@ -312,13 +330,11 @@ export const applyEmptyFilters = ( switch (operand) { case ViewFilterOperand.IsEmpty: - objectRecordFilters.push(emptyRecordFilter); - break; + return emptyRecordFilter; case ViewFilterOperand.IsNotEmpty: - objectRecordFilters.push({ + return { not: emptyRecordFilter, - }); - break; + }; default: throw new Error( `Unknown operand ${operand} for ${definition.type} filter`, diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingArrayFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingArrayFilter.ts new file mode 100644 index 000000000000..7578a04aac49 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingArrayFilter.ts @@ -0,0 +1,34 @@ +import { ArrayFilter } from '@/object-record/graphql/types/RecordGqlOperationFilter'; + +export const isMatchingArrayFilter = ({ + arrayFilter, + value, +}: { + arrayFilter: ArrayFilter; + value: string[]; +}) => { + if (value === null || !Array.isArray(value)) { + return false; + } + + switch (true) { + case arrayFilter.contains !== undefined: { + return arrayFilter.contains.every((item) => value.includes(item)); + } + case arrayFilter.not_contains !== undefined: { + return !arrayFilter.not_contains.some((item) => value.includes(item)); + } + case arrayFilter.is !== undefined: { + if (arrayFilter.is === 'NULL') { + return value === null; + } else { + return value !== null; + } + } + default: { + throw new Error( + `Unexpected value for array filter: ${JSON.stringify(arrayFilter)}`, + ); + } + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingRawJsonFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingRawJsonFilter.ts new file mode 100644 index 000000000000..8251bca722a5 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isMatchingRawJsonFilter.ts @@ -0,0 +1,32 @@ +import { RawJsonFilter } from '../../graphql/types/RecordGqlOperationFilter'; + +export const isMatchingRawJsonFilter = ({ + rawJsonFilter, + value, +}: { + rawJsonFilter: RawJsonFilter; + value: string; +}) => { + switch (true) { + case rawJsonFilter.like !== undefined: { + const regexPattern = rawJsonFilter.like.replace(/%/g, '.*'); + const regexCaseInsensitive = new RegExp(`^${regexPattern}$`, 'i'); + + const stringValue = JSON.stringify(value); + + return regexCaseInsensitive.test(stringValue); + } + case rawJsonFilter.is !== undefined: { + if (rawJsonFilter.is === 'NULL') { + return value === null; + } else { + return value !== null; + } + } + default: { + throw new Error( + `Unexpected value for string filter : ${JSON.stringify(rawJsonFilter)}`, + ); + } + } +}; diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts index 929956d77128..c2a5da47fdd8 100644 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-filter/utils/isRecordMatchingFilter.ts @@ -5,6 +5,7 @@ import { ActorFilter, AddressFilter, AndObjectRecordFilter, + ArrayFilter, BooleanFilter, CurrencyFilter, DateFilter, @@ -16,14 +17,17 @@ import { NotObjectRecordFilter, OrObjectRecordFilter, PhonesFilter, + RawJsonFilter, RecordGqlOperationFilter, StringFilter, UUIDFilter, } from '@/object-record/graphql/types/RecordGqlOperationFilter'; +import { isMatchingArrayFilter } from '@/object-record/record-filter/utils/isMatchingArrayFilter'; import { isMatchingBooleanFilter } from '@/object-record/record-filter/utils/isMatchingBooleanFilter'; import { isMatchingCurrencyFilter } from '@/object-record/record-filter/utils/isMatchingCurrencyFilter'; import { isMatchingDateFilter } from '@/object-record/record-filter/utils/isMatchingDateFilter'; import { isMatchingFloatFilter } from '@/object-record/record-filter/utils/isMatchingFloatFilter'; +import { isMatchingRawJsonFilter } from '@/object-record/record-filter/utils/isMatchingRawJsonFilter'; import { isMatchingStringFilter } from '@/object-record/record-filter/utils/isMatchingStringFilter'; import { isMatchingUUIDFilter } from '@/object-record/record-filter/utils/isMatchingUUIDFilter'; import { FieldMetadataType } from '~/generated-metadata/graphql'; @@ -165,6 +169,18 @@ export const isRecordMatchingFilter = ({ value: record[filterKey], }); } + case FieldMetadataType.Array: { + return isMatchingArrayFilter({ + arrayFilter: filterValue as ArrayFilter, + value: record[filterKey], + }); + } + case FieldMetadataType.RawJson: { + return isMatchingRawJsonFilter({ + rawJsonFilter: filterValue as RawJsonFilter, + value: record[filterKey], + }); + } case FieldMetadataType.FullName: { const fullNameFilter = filterValue as FullNameFilter; @@ -302,6 +318,7 @@ export const isRecordMatchingFilter = ({ `Not implemented yet, use UUID filter instead on the corredponding "${filterKey}Id" field`, ); } + default: { throw new Error( `Not implemented yet for field type "${objectMetadataField.type}"`, diff --git a/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts b/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts deleted file mode 100644 index ff0199344948..000000000000 --- a/packages/twenty-front/src/modules/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter.ts +++ /dev/null @@ -1,844 +0,0 @@ -import { isNonEmptyString } from '@sniptt/guards'; - -import { - ActorFilter, - AddressFilter, - CurrencyFilter, - DateFilter, - EmailsFilter, - FloatFilter, - RecordGqlOperationFilter, - RelationFilter, - StringFilter, - UUIDFilter, -} from '@/object-record/graphql/types/RecordGqlOperationFilter'; -import { makeAndFilterVariables } from '@/object-record/utils/makeAndFilterVariables'; -import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; -import { Field } from '~/generated/graphql'; -import { generateILikeFiltersForCompositeFields } from '~/utils/array/generateILikeFiltersForCompositeFields'; -import { isDefined } from '~/utils/isDefined'; - -import { - convertGreaterThanRatingToArrayOfRatingValues, - convertLessThanRatingToArrayOfRatingValues, - convertRatingToRatingValue, -} from '@/object-record/object-filter-dropdown/components/ObjectFilterDropdownRatingInput'; -import { Filter } from '@/object-record/object-filter-dropdown/types/Filter'; -import { applyEmptyFilters } from '@/object-record/record-filter/utils/applyEmptyFilters'; -import { resolveFilterValue } from '@/views/utils/view-filter-value/resolveFilterValue'; -import { endOfDay, roundToNearestMinutes, startOfDay } from 'date-fns'; -import { z } from 'zod'; - -// TODO: break this down into smaller functions and make the whole thing immutable -// Especially applyEmptyFilters -export const turnObjectDropdownFilterIntoQueryFilter = ( - rawUIFilters: Filter[], - fields: Pick[], -): RecordGqlOperationFilter | undefined => { - const objectRecordFilters: RecordGqlOperationFilter[] = []; - - for (const rawUIFilter of rawUIFilters) { - const correspondingField = fields.find( - (field) => field.id === rawUIFilter.fieldMetadataId, - ); - - const compositeFieldName = rawUIFilter.definition.compositeFieldName; - - const isCompositeFieldFiter = isNonEmptyString(compositeFieldName); - - const isEmptyOperand = [ - ViewFilterOperand.IsEmpty, - ViewFilterOperand.IsNotEmpty, - ViewFilterOperand.IsInPast, - ViewFilterOperand.IsInFuture, - ViewFilterOperand.IsToday, - ].includes(rawUIFilter.operand); - - if (!correspondingField) { - continue; - } - - if (!isEmptyOperand) { - if (!isDefined(rawUIFilter.value) || rawUIFilter.value === '') { - continue; - } - } - - switch (rawUIFilter.definition.type) { - case 'TEXT': - switch (rawUIFilter.operand) { - case ViewFilterOperand.Contains: - objectRecordFilters.push({ - [correspondingField.name]: { - ilike: `%${rawUIFilter.value}%`, - } as StringFilter, - }); - break; - case ViewFilterOperand.DoesNotContain: - objectRecordFilters.push({ - not: { - [correspondingField.name]: { - ilike: `%${rawUIFilter.value}%`, - } as StringFilter, - }, - }); - break; - case ViewFilterOperand.IsEmpty: - case ViewFilterOperand.IsNotEmpty: - applyEmptyFilters( - rawUIFilter.operand, - correspondingField, - objectRecordFilters, - rawUIFilter.definition, - ); - break; - default: - throw new Error( - `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, - ); - } - break; - case 'DATE': - case 'DATE_TIME': { - const resolvedFilterValue = resolveFilterValue(rawUIFilter); - const now = roundToNearestMinutes(new Date()); - const date = - resolvedFilterValue instanceof Date ? resolvedFilterValue : now; - - switch (rawUIFilter.operand) { - case ViewFilterOperand.IsAfter: { - objectRecordFilters.push({ - [correspondingField.name]: { - gt: date.toISOString(), - } as DateFilter, - }); - break; - } - case ViewFilterOperand.IsBefore: { - objectRecordFilters.push({ - [correspondingField.name]: { - lt: date.toISOString(), - } as DateFilter, - }); - break; - } - case ViewFilterOperand.IsEmpty: - case ViewFilterOperand.IsNotEmpty: { - applyEmptyFilters( - rawUIFilter.operand, - correspondingField, - objectRecordFilters, - rawUIFilter.definition, - ); - break; - } - case ViewFilterOperand.IsRelative: { - const dateRange = z - .object({ start: z.date(), end: z.date() }) - .safeParse(resolvedFilterValue).data; - - const defaultDateRange = resolveFilterValue({ - value: 'PAST_1_DAY', - definition: { - type: 'DATE', - }, - operand: ViewFilterOperand.IsRelative, - }); - - if (!defaultDateRange) { - throw new Error('Failed to resolve default date range'); - } - - const { start, end } = dateRange ?? defaultDateRange; - - objectRecordFilters.push({ - and: [ - { - [correspondingField.name]: { - gte: start.toISOString(), - } as DateFilter, - }, - { - [correspondingField.name]: { - lte: end.toISOString(), - } as DateFilter, - }, - ], - }); - break; - } - case ViewFilterOperand.Is: { - const isValid = resolvedFilterValue instanceof Date; - const date = isValid ? resolvedFilterValue : now; - - objectRecordFilters.push({ - and: [ - { - [correspondingField.name]: { - lte: endOfDay(date).toISOString(), - } as DateFilter, - }, - { - [correspondingField.name]: { - gte: startOfDay(date).toISOString(), - } as DateFilter, - }, - ], - }); - break; - } - case ViewFilterOperand.IsInPast: - objectRecordFilters.push({ - [correspondingField.name]: { - lte: now.toISOString(), - } as DateFilter, - }); - break; - case ViewFilterOperand.IsInFuture: - objectRecordFilters.push({ - [correspondingField.name]: { - gte: now.toISOString(), - } as DateFilter, - }); - break; - case ViewFilterOperand.IsToday: { - objectRecordFilters.push({ - and: [ - { - [correspondingField.name]: { - lte: endOfDay(now).toISOString(), - } as DateFilter, - }, - { - [correspondingField.name]: { - gte: startOfDay(now).toISOString(), - } as DateFilter, - }, - ], - }); - break; - } - default: - throw new Error( - `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, // - ); - } - break; - } - case 'RATING': - switch (rawUIFilter.operand) { - case ViewFilterOperand.Is: - objectRecordFilters.push({ - [correspondingField.name]: { - eq: convertRatingToRatingValue(parseFloat(rawUIFilter.value)), - } as StringFilter, - }); - break; - case ViewFilterOperand.GreaterThan: - objectRecordFilters.push({ - [correspondingField.name]: { - in: convertGreaterThanRatingToArrayOfRatingValues( - parseFloat(rawUIFilter.value), - ), - } as StringFilter, - }); - break; - case ViewFilterOperand.LessThan: - objectRecordFilters.push({ - [correspondingField.name]: { - in: convertLessThanRatingToArrayOfRatingValues( - parseFloat(rawUIFilter.value), - ), - } as StringFilter, - }); - break; - case ViewFilterOperand.IsEmpty: - case ViewFilterOperand.IsNotEmpty: - applyEmptyFilters( - rawUIFilter.operand, - correspondingField, - objectRecordFilters, - rawUIFilter.definition, - ); - break; - default: - throw new Error( - `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, - ); - } - break; - case 'NUMBER': - switch (rawUIFilter.operand) { - case ViewFilterOperand.GreaterThan: - objectRecordFilters.push({ - [correspondingField.name]: { - gte: parseFloat(rawUIFilter.value), - } as FloatFilter, - }); - break; - case ViewFilterOperand.LessThan: - objectRecordFilters.push({ - [correspondingField.name]: { - lte: parseFloat(rawUIFilter.value), - } as FloatFilter, - }); - break; - case ViewFilterOperand.IsEmpty: - case ViewFilterOperand.IsNotEmpty: - applyEmptyFilters( - rawUIFilter.operand, - correspondingField, - objectRecordFilters, - rawUIFilter.definition, - ); - break; - default: - throw new Error( - `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, - ); - } - break; - case 'RELATION': { - if (!isEmptyOperand) { - try { - JSON.parse(rawUIFilter.value); - } catch (e) { - throw new Error( - `Cannot parse filter value for RELATION filter : "${rawUIFilter.value}"`, - ); - } - - const parsedRecordIds = JSON.parse(rawUIFilter.value) as string[]; - - if (parsedRecordIds.length > 0) { - switch (rawUIFilter.operand) { - case ViewFilterOperand.Is: - objectRecordFilters.push({ - [correspondingField.name + 'Id']: { - in: parsedRecordIds, - } as RelationFilter, - }); - break; - case ViewFilterOperand.IsNot: - if (parsedRecordIds.length > 0) { - objectRecordFilters.push({ - not: { - [correspondingField.name + 'Id']: { - in: parsedRecordIds, - } as RelationFilter, - }, - }); - } - break; - default: - throw new Error( - `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, - ); - } - } - } else { - switch (rawUIFilter.operand) { - case ViewFilterOperand.IsEmpty: - case ViewFilterOperand.IsNotEmpty: - applyEmptyFilters( - rawUIFilter.operand, - correspondingField, - objectRecordFilters, - rawUIFilter.definition, - ); - break; - default: - throw new Error( - `Unknown empty operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, - ); - } - } - break; - } - case 'CURRENCY': - switch (rawUIFilter.operand) { - case ViewFilterOperand.GreaterThan: - objectRecordFilters.push({ - [correspondingField.name]: { - amountMicros: { gte: parseFloat(rawUIFilter.value) * 1000000 }, - } as CurrencyFilter, - }); - break; - case ViewFilterOperand.LessThan: - objectRecordFilters.push({ - [correspondingField.name]: { - amountMicros: { lte: parseFloat(rawUIFilter.value) * 1000000 }, - } as CurrencyFilter, - }); - break; - case ViewFilterOperand.IsEmpty: - case ViewFilterOperand.IsNotEmpty: - applyEmptyFilters( - rawUIFilter.operand, - correspondingField, - objectRecordFilters, - rawUIFilter.definition, - ); - break; - default: - throw new Error( - `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, - ); - } - break; - case 'LINKS': { - const linksFilters = generateILikeFiltersForCompositeFields( - rawUIFilter.value, - correspondingField.name, - ['primaryLinkLabel', 'primaryLinkUrl'], - ); - - switch (rawUIFilter.operand) { - case ViewFilterOperand.Contains: - if (!isCompositeFieldFiter) { - objectRecordFilters.push({ - or: linksFilters, - }); - } else { - objectRecordFilters.push({ - [correspondingField.name]: { - [compositeFieldName]: { - ilike: `%${rawUIFilter.value}%`, - }, - }, - }); - } - break; - case ViewFilterOperand.DoesNotContain: - if (!isCompositeFieldFiter) { - objectRecordFilters.push({ - and: linksFilters.map((filter) => { - return { - not: filter, - }; - }), - }); - } else { - objectRecordFilters.push({ - not: { - [correspondingField.name]: { - [compositeFieldName]: { - ilike: `%${rawUIFilter.value}%`, - }, - }, - }, - }); - } - break; - case ViewFilterOperand.IsEmpty: - case ViewFilterOperand.IsNotEmpty: - applyEmptyFilters( - rawUIFilter.operand, - correspondingField, - objectRecordFilters, - rawUIFilter.definition, - ); - break; - default: - throw new Error( - `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, - ); - } - break; - } - case 'FULL_NAME': { - const fullNameFilters = generateILikeFiltersForCompositeFields( - rawUIFilter.value, - correspondingField.name, - ['firstName', 'lastName'], - ); - switch (rawUIFilter.operand) { - case ViewFilterOperand.Contains: - if (!isCompositeFieldFiter) { - objectRecordFilters.push({ - or: fullNameFilters, - }); - } else { - objectRecordFilters.push({ - [correspondingField.name]: { - [compositeFieldName]: { - ilike: `%${rawUIFilter.value}%`, - }, - }, - }); - } - break; - case ViewFilterOperand.DoesNotContain: - if (!isCompositeFieldFiter) { - objectRecordFilters.push({ - and: fullNameFilters.map((filter) => { - return { - not: filter, - }; - }), - }); - } else { - objectRecordFilters.push({ - not: { - [correspondingField.name]: { - [compositeFieldName]: { - ilike: `%${rawUIFilter.value}%`, - }, - }, - }, - }); - } - break; - case ViewFilterOperand.IsEmpty: - case ViewFilterOperand.IsNotEmpty: - applyEmptyFilters( - rawUIFilter.operand, - correspondingField, - objectRecordFilters, - rawUIFilter.definition, - ); - break; - default: - throw new Error( - `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, - ); - } - break; - } - case 'ADDRESS': - switch (rawUIFilter.operand) { - case ViewFilterOperand.Contains: - if (!isCompositeFieldFiter) { - objectRecordFilters.push({ - or: [ - { - [correspondingField.name]: { - addressStreet1: { - ilike: `%${rawUIFilter.value}%`, - }, - } as AddressFilter, - }, - { - [correspondingField.name]: { - addressStreet2: { - ilike: `%${rawUIFilter.value}%`, - }, - } as AddressFilter, - }, - { - [correspondingField.name]: { - addressCity: { - ilike: `%${rawUIFilter.value}%`, - }, - } as AddressFilter, - }, - { - [correspondingField.name]: { - addressState: { - ilike: `%${rawUIFilter.value}%`, - }, - } as AddressFilter, - }, - { - [correspondingField.name]: { - addressCountry: { - ilike: `%${rawUIFilter.value}%`, - }, - } as AddressFilter, - }, - { - [correspondingField.name]: { - addressPostcode: { - ilike: `%${rawUIFilter.value}%`, - }, - } as AddressFilter, - }, - ], - }); - } else { - objectRecordFilters.push({ - [correspondingField.name]: { - [compositeFieldName]: { - ilike: `%${rawUIFilter.value}%`, - } as AddressFilter, - }, - }); - } - break; - case ViewFilterOperand.DoesNotContain: - if (!isCompositeFieldFiter) { - objectRecordFilters.push({ - and: [ - { - not: { - [correspondingField.name]: { - addressStreet1: { - ilike: `%${rawUIFilter.value}%`, - }, - } as AddressFilter, - }, - }, - { - not: { - [correspondingField.name]: { - addressStreet2: { - ilike: `%${rawUIFilter.value}%`, - }, - } as AddressFilter, - }, - }, - { - not: { - [correspondingField.name]: { - addressCity: { - ilike: `%${rawUIFilter.value}%`, - }, - } as AddressFilter, - }, - }, - ], - }); - } else { - objectRecordFilters.push({ - not: { - [correspondingField.name]: { - [compositeFieldName]: { - ilike: `%${rawUIFilter.value}%`, - } as AddressFilter, - }, - }, - }); - } - break; - case ViewFilterOperand.IsEmpty: - case ViewFilterOperand.IsNotEmpty: - applyEmptyFilters( - rawUIFilter.operand, - correspondingField, - objectRecordFilters, - rawUIFilter.definition, - ); - break; - default: - throw new Error( - `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, - ); - } - break; - case 'SELECT': { - if (isEmptyOperand) { - applyEmptyFilters( - rawUIFilter.operand, - correspondingField, - objectRecordFilters, - rawUIFilter.definition, - ); - break; - } - const stringifiedSelectValues = rawUIFilter.value; - let parsedOptionValues: string[] = []; - - if (!isNonEmptyString(stringifiedSelectValues)) { - break; - } - - try { - parsedOptionValues = JSON.parse(stringifiedSelectValues); - } catch (e) { - throw new Error( - `Cannot parse filter value for SELECT filter : "${stringifiedSelectValues}"`, - ); - } - - if (parsedOptionValues.length > 0) { - switch (rawUIFilter.operand) { - case ViewFilterOperand.Is: - objectRecordFilters.push({ - [correspondingField.name]: { - in: parsedOptionValues, - } as UUIDFilter, - }); - break; - case ViewFilterOperand.IsNot: - objectRecordFilters.push({ - not: { - [correspondingField.name]: { - in: parsedOptionValues, - } as UUIDFilter, - }, - }); - break; - default: - throw new Error( - `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, - ); - } - } - break; - } - // TODO: fix this with a new composite field in ViewFilter entity - case 'ACTOR': { - switch (rawUIFilter.operand) { - case ViewFilterOperand.Is: { - const parsedRecordIds = JSON.parse(rawUIFilter.value) as string[]; - - objectRecordFilters.push({ - [correspondingField.name]: { - source: { - in: parsedRecordIds, - } as RelationFilter, - }, - }); - - break; - } - case ViewFilterOperand.IsNot: { - const parsedRecordIds = JSON.parse(rawUIFilter.value) as string[]; - - if (parsedRecordIds.length > 0) { - objectRecordFilters.push({ - not: { - [correspondingField.name]: { - source: { - in: parsedRecordIds, - } as RelationFilter, - }, - }, - }); - } - break; - } - case ViewFilterOperand.Contains: - objectRecordFilters.push({ - or: [ - { - [correspondingField.name]: { - name: { - ilike: `%${rawUIFilter.value}%`, - }, - } as ActorFilter, - }, - ], - }); - break; - case ViewFilterOperand.DoesNotContain: - objectRecordFilters.push({ - and: [ - { - not: { - [correspondingField.name]: { - name: { - ilike: `%${rawUIFilter.value}%`, - }, - } as ActorFilter, - }, - }, - ], - }); - break; - case ViewFilterOperand.IsEmpty: - case ViewFilterOperand.IsNotEmpty: - applyEmptyFilters( - rawUIFilter.operand, - correspondingField, - objectRecordFilters, - rawUIFilter.definition, - ); - break; - - default: - throw new Error( - `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.label} filter`, - ); - } - break; - } - case 'EMAILS': - switch (rawUIFilter.operand) { - case ViewFilterOperand.Contains: - objectRecordFilters.push({ - or: [ - { - [correspondingField.name]: { - primaryEmail: { - ilike: `%${rawUIFilter.value}%`, - }, - } as EmailsFilter, - }, - ], - }); - break; - case ViewFilterOperand.DoesNotContain: - objectRecordFilters.push({ - and: [ - { - not: { - [correspondingField.name]: { - primaryEmail: { - ilike: `%${rawUIFilter.value}%`, - }, - } as EmailsFilter, - }, - }, - ], - }); - break; - case ViewFilterOperand.IsEmpty: - case ViewFilterOperand.IsNotEmpty: - applyEmptyFilters( - rawUIFilter.operand, - correspondingField, - objectRecordFilters, - rawUIFilter.definition, - ); - break; - default: - throw new Error( - `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, - ); - } - break; - case 'PHONES': { - const phonesFilters = generateILikeFiltersForCompositeFields( - rawUIFilter.value, - correspondingField.name, - ['primaryPhoneNumber', 'primaryPhoneCountryCode'], - ); - switch (rawUIFilter.operand) { - case ViewFilterOperand.Contains: - objectRecordFilters.push({ - or: phonesFilters, - }); - break; - case ViewFilterOperand.DoesNotContain: - objectRecordFilters.push({ - and: phonesFilters.map((filter) => { - return { - not: filter, - }; - }), - }); - break; - case ViewFilterOperand.IsEmpty: - case ViewFilterOperand.IsNotEmpty: - applyEmptyFilters( - rawUIFilter.operand, - correspondingField, - objectRecordFilters, - rawUIFilter.definition, - ); - break; - default: - throw new Error( - `Unknown operand ${rawUIFilter.operand} for ${rawUIFilter.definition.type} filter`, - ); - } - break; - } - default: - throw new Error('Unknown filter type'); - } - } - - return makeAndFilterVariables(objectRecordFilters); -}; diff --git a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupActions.ts b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupActions.ts new file mode 100644 index 000000000000..2fa75b470fdb --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupActions.ts @@ -0,0 +1,96 @@ +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { getFieldSlug } from '@/object-metadata/utils/getFieldSlug'; +import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug'; +import { RecordBoardColumnContext } from '@/object-record/record-board/record-board-column/contexts/RecordBoardColumnContext'; +import { useRecordGroups } from '@/object-record/record-group/hooks/useRecordGroups'; +import { useRecordGroupVisibility } from '@/object-record/record-group/hooks/useRecordGroupVisibility'; +import { RecordGroupAction } from '@/object-record/record-group/types/RecordGroupActions'; +import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition'; +import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; +import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; +import { useCallback, useContext, useMemo } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; +import { useSetRecoilState } from 'recoil'; +import { IconEyeOff, IconSettings, isDefined } from 'twenty-ui'; + +export const useRecordGroupActions = () => { + const navigate = useNavigate(); + const location = useLocation(); + + const { objectNameSingular, recordIndexId } = useContext( + RecordIndexRootPropsContext, + ); + + const { columnDefinition: recordGroupDefinition } = useContext( + RecordBoardColumnContext, + ); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const { viewGroupFieldMetadataItem } = useRecordGroups({ + objectNameSingular, + }); + + const { handleVisibilityChange: handleRecordGroupVisibilityChange } = + useRecordGroupVisibility({ + viewBarId: recordIndexId, + }); + + const setNavigationMemorizedUrl = useSetRecoilState( + navigationMemorizedUrlState, + ); + + const navigateToSelectSettings = useCallback(() => { + setNavigationMemorizedUrl(location.pathname + location.search); + + if (!isDefined(viewGroupFieldMetadataItem)) { + throw new Error('viewGroupFieldMetadataItem is not a non-empty string'); + } + + const settingsPath = `/settings/objects/${getObjectSlug(objectMetadataItem)}/${getFieldSlug(viewGroupFieldMetadataItem)}`; + + navigate(settingsPath); + }, [ + setNavigationMemorizedUrl, + location.pathname, + location.search, + navigate, + objectMetadataItem, + viewGroupFieldMetadataItem, + ]); + + const recordGroupActions: RecordGroupAction[] = useMemo( + () => + [ + { + id: 'edit', + label: 'Edit', + icon: IconSettings, + position: 0, + callback: () => { + navigateToSelectSettings(); + }, + }, + recordGroupDefinition.type !== RecordGroupDefinitionType.NoValue + ? { + id: 'hide', + label: 'Hide', + icon: IconEyeOff, + position: 1, + callback: () => { + handleRecordGroupVisibilityChange(recordGroupDefinition); + }, + } + : undefined, + ].filter(isDefined), + [ + handleRecordGroupVisibilityChange, + navigateToSelectSettings, + recordGroupDefinition, + ], + ); + + return recordGroupActions; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupReorder.ts b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupReorder.ts new file mode 100644 index 000000000000..97151a5838c2 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupReorder.ts @@ -0,0 +1,59 @@ +import { OnDragEndResponder } from '@hello-pangea/dnd'; +import { useCallback } from 'react'; + +import { useRecordGroups } from '@/object-record/record-group/hooks/useRecordGroups'; +import { recordGroupDefinitionsComponentState } from '@/object-record/record-group/states/recordGroupDefinitionsComponentState'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { useSaveCurrentViewGroups } from '@/views/hooks/useSaveCurrentViewGroups'; +import { mapRecordGroupDefinitionsToViewGroups } from '@/views/utils/mapRecordGroupDefinitionsToViewGroups'; +import { moveArrayItem } from '~/utils/array/moveArrayItem'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; + +type UseRecordGroupHandlersParams = { + objectNameSingular: string; + viewBarId: string; +}; + +export const useRecordGroupReorder = ({ + objectNameSingular, + viewBarId, +}: UseRecordGroupHandlersParams) => { + const setRecordGroupDefinitions = useSetRecoilComponentStateV2( + recordGroupDefinitionsComponentState, + ); + + const { visibleRecordGroups } = useRecordGroups({ + objectNameSingular, + }); + + const { saveViewGroups } = useSaveCurrentViewGroups(viewBarId); + + const handleOrderChange: OnDragEndResponder = useCallback( + (result) => { + if (!result.destination) { + return; + } + + const reorderedVisibleBoardGroups = moveArrayItem(visibleRecordGroups, { + fromIndex: result.source.index - 1, + toIndex: result.destination.index - 1, + }); + + if (isDeeplyEqual(visibleRecordGroups, reorderedVisibleBoardGroups)) + return; + + const updatedGroups = [...reorderedVisibleBoardGroups].map( + (group, index) => ({ ...group, position: index }), + ); + + setRecordGroupDefinitions(updatedGroups); + saveViewGroups(mapRecordGroupDefinitionsToViewGroups(updatedGroups)); + }, + [saveViewGroups, setRecordGroupDefinitions, visibleRecordGroups], + ); + + return { + visibleRecordGroups, + handleOrderChange, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupVisibility.ts b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupVisibility.ts new file mode 100644 index 000000000000..c9ecbd7760ac --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroupVisibility.ts @@ -0,0 +1,45 @@ +import { useCallback } from 'react'; + +import { recordGroupDefinitionsComponentState } from '@/object-record/record-group/states/recordGroupDefinitionsComponentState'; +import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; +import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; +import { useSaveCurrentViewGroups } from '@/views/hooks/useSaveCurrentViewGroups'; +import { mapRecordGroupDefinitionsToViewGroups } from '@/views/utils/mapRecordGroupDefinitionsToViewGroups'; + +type UseRecordGroupVisibilityParams = { + viewBarId: string; +}; + +export const useRecordGroupVisibility = ({ + viewBarId, +}: UseRecordGroupVisibilityParams) => { + const [recordGroupDefinitions, setRecordGroupDefinitions] = + useRecoilComponentStateV2(recordGroupDefinitionsComponentState); + + const { saveViewGroups } = useSaveCurrentViewGroups(viewBarId); + + const handleVisibilityChange = useCallback( + async (updatedRecordGroupDefinition: RecordGroupDefinition) => { + const updatedRecordGroupDefinitions = recordGroupDefinitions.map( + (groupDefinition) => + groupDefinition.id === updatedRecordGroupDefinition.id + ? { + ...groupDefinition, + isVisible: !groupDefinition.isVisible, + } + : groupDefinition, + ); + + setRecordGroupDefinitions(updatedRecordGroupDefinitions); + + saveViewGroups( + mapRecordGroupDefinitionsToViewGroups(updatedRecordGroupDefinitions), + ); + }, + [recordGroupDefinitions, setRecordGroupDefinitions, saveViewGroups], + ); + + return { + handleVisibilityChange, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroups.ts b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroups.ts new file mode 100644 index 000000000000..8dcea64d73c4 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/hooks/useRecordGroups.ts @@ -0,0 +1,58 @@ +import { useMemo } from 'react'; + +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { recordGroupDefinitionsComponentState } from '@/object-record/record-group/states/recordGroupDefinitionsComponentState'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; + +type UseRecordGroupsParams = { + objectNameSingular: string; +}; + +export const useRecordGroups = ({ + objectNameSingular, +}: UseRecordGroupsParams) => { + const recordGroupDefinitions = useRecoilComponentValueV2( + recordGroupDefinitionsComponentState, + ); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const viewGroupFieldMetadataItem = useMemo(() => { + if (recordGroupDefinitions.length === 0) return null; + // We're assuming that all groups have the same fieldMetadataId for now + const fieldMetadataId = + 'fieldMetadataId' in recordGroupDefinitions[0] + ? recordGroupDefinitions[0].fieldMetadataId + : null; + + if (!fieldMetadataId) return null; + + return objectMetadataItem.fields.find( + (field) => field.id === fieldMetadataId, + ); + }, [objectMetadataItem, recordGroupDefinitions]); + + const visibleRecordGroups = useMemo( + () => + recordGroupDefinitions + .filter((boardGroup) => boardGroup.isVisible) + .sort( + (boardGroupA, boardGroupB) => + boardGroupA.position - boardGroupB.position, + ), + [recordGroupDefinitions], + ); + + const hiddenRecordGroups = useMemo( + () => recordGroupDefinitions.filter((boardGroup) => !boardGroup.isVisible), + [recordGroupDefinitions], + ); + + return { + hiddenRecordGroups, + visibleRecordGroups, + viewGroupFieldMetadataItem, + }; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-group/states/recordGroupDefinitionsComponentState.ts b/packages/twenty-front/src/modules/object-record/record-group/states/recordGroupDefinitionsComponentState.ts new file mode 100644 index 000000000000..56ec80fcc243 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/states/recordGroupDefinitionsComponentState.ts @@ -0,0 +1,11 @@ +import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; +import { createComponentStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentStateV2'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; + +export const recordGroupDefinitionsComponentState = createComponentStateV2< + RecordGroupDefinition[] +>({ + key: 'recordGroupDefinitionsComponentState', + defaultValue: [], + componentInstanceContext: ViewComponentInstanceContext, +}); diff --git a/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnAction.ts b/packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupActions.ts similarity index 78% rename from packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnAction.ts rename to packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupActions.ts index 46e880ff84ff..7fd2d731be7e 100644 --- a/packages/twenty-front/src/modules/object-record/record-board/types/RecordBoardColumnAction.ts +++ b/packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupActions.ts @@ -1,6 +1,6 @@ import { IconComponent } from 'twenty-ui'; -export type RecordBoardColumnAction = { +export type RecordGroupAction = { id: string; label: string; icon: IconComponent; diff --git a/packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupDefinition.ts b/packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupDefinition.ts new file mode 100644 index 000000000000..2c6884ce103c --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-group/types/RecordGroupDefinition.ts @@ -0,0 +1,17 @@ +import { ThemeColor } from 'twenty-ui'; + +export const enum RecordGroupDefinitionType { + Value = 'value', + NoValue = 'no-value', +} + +export type RecordGroupDefinition = { + id: string; + fieldMetadataId: string; + type: RecordGroupDefinitionType; + title: string; + value: string | null; + color: ThemeColor | 'transparent'; + position: number; + isVisible: boolean; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardColumnLoaderEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardColumnLoaderEffect.tsx index e9c909b72418..6f6a2e2bede4 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardColumnLoaderEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardColumnLoaderEffect.tsx @@ -9,14 +9,12 @@ import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/get export const RecordIndexBoardColumnLoaderEffect = ({ objectNameSingular, - boardFieldSelectValue, boardFieldMetadataId, recordBoardId, columnId, }: { recordBoardId: string; objectNameSingular: string; - boardFieldSelectValue: string | null; boardFieldMetadataId: string | null; columnId: string; }) => { @@ -40,7 +38,6 @@ export const RecordIndexBoardColumnLoaderEffect = ({ objectNameSingular, recordBoardId, boardFieldMetadataId, - columnFieldSelectValue: boardFieldSelectValue, columnId, }); @@ -70,7 +67,6 @@ export const RecordIndexBoardColumnLoaderEffect = ({ fetchMoreRecords, loading, shouldFetchMore, - boardFieldSelectValue, setLoadingRecordsForThisColumn, loadingRecordsForThisColumn, diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx index b34fc8b7ac98..73c2f372e126 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardContainer.tsx @@ -46,9 +46,10 @@ export const RecordIndexBoardContainer = ({ createOneRecord, updateOneRecord, deleteOneRecord, + recordBoardId, }} > - + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoader.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoader.tsx index 8bacfe03551e..194580587dc9 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoader.tsx @@ -26,23 +26,18 @@ export const RecordIndexBoardDataLoader = ({ (field) => field.id === recordIndexKanbanFieldMetadataId, ); - const possibleKanbanSelectFieldValues = - recordIndexKanbanFieldMetadataItem?.options ?? []; - const { columnIdsState } = useRecordBoardStates(recordBoardId); - // TODO: we should make sure there's no way to have a mismatch between columnIds and possibleKanbanSelectFieldValues order const columnIds = useRecoilValue(columnIdsState); return ( <> - {possibleKanbanSelectFieldValues.map((option, index) => ( + {columnIds.map((columnId, index) => ( ))} @@ -50,7 +45,6 @@ export const RecordIndexBoardDataLoader = ({ diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx index 354abcf09dab..d1ef9eef40f4 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexBoardDataLoaderEffect.tsx @@ -1,17 +1,15 @@ -import { useCallback, useEffect } from 'react'; -import { useLocation, useNavigate } from 'react-router-dom'; +import { useEffect } from 'react'; import { useRecoilValue, useSetRecoilState } from 'recoil'; -import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState'; -import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState'; +import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { getObjectSlug } from '@/object-metadata/utils/getObjectSlug'; import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard'; +import { recordGroupDefinitionsComponentState } from '@/object-record/record-group/states/recordGroupDefinitionsComponentState'; import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; import { recordIndexIsCompactModeActiveState } from '@/object-record/record-index/states/recordIndexIsCompactModeActiveState'; import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState'; -import { computeRecordBoardColumnDefinitionsFromObjectMetadata } from '@/object-record/utils/computeRecordBoardColumnDefinitionsFromObjectMetadata'; -import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { FieldMetadataType } from '~/generated-metadata/graphql'; import { isDefined } from '~/utils/isDefined'; @@ -32,6 +30,10 @@ export const RecordIndexBoardDataLoaderEffect = ({ recordIndexFieldDefinitionsState, ); + const recordIndexGroupDefinitions = useRecoilComponentValueV2( + recordGroupDefinitionsComponentState, + ); + const recordIndexKanbanFieldMetadataId = useRecoilValue( recordIndexKanbanFieldMetadataIdState, ); @@ -60,43 +62,17 @@ export const RecordIndexBoardDataLoaderEffect = ({ setFieldDefinitions(recordIndexFieldDefinitions); }, [recordIndexFieldDefinitions, setFieldDefinitions]); - const navigate = useNavigate(); - const location = useLocation(); - const setNavigationMemorizedUrl = useSetRecoilState( - navigationMemorizedUrlState, - ); - - const navigateToSelectSettings = useCallback(() => { - setNavigationMemorizedUrl(location.pathname + location.search); - navigate(`/settings/objects/${getObjectSlug(objectMetadataItem)}`); - }, [ - navigate, - objectMetadataItem, - location.pathname, - location.search, - setNavigationMemorizedUrl, - ]); - useEffect(() => { setObjectSingularName(objectNameSingular); }, [objectNameSingular, setObjectSingularName]); useEffect(() => { - setColumns( - computeRecordBoardColumnDefinitionsFromObjectMetadata( - objectMetadataItem, - recordIndexKanbanFieldMetadataId ?? '', - navigateToSelectSettings, - ), - ); - }, [ - navigateToSelectSettings, - objectMetadataItem, - objectNameSingular, - recordIndexKanbanFieldMetadataId, - setColumns, - ]); + setColumns(recordIndexGroupDefinitions); + }, [recordIndexGroupDefinitions, setColumns]); + // TODO: Remove this duplicate useEffect by ensuring it's not here because + // We want it to be triggered by a change of objectMetadataItem, which would be an anti-pattern + // As it is an unnecessary dependency useEffect(() => { setFieldDefinitions(recordIndexFieldDefinitions); }, [objectMetadataItem, setFieldDefinitions, recordIndexFieldDefinitions]); @@ -121,32 +97,23 @@ export const RecordIndexBoardDataLoaderEffect = ({ const selectedRecordIds = useRecoilValue(selectedRecordIdsSelector()); - const setContextStoreTargetedRecordIds = useSetRecoilState( - contextStoreTargetedRecordIdsState, - ); - - const setContextStoreCurrentObjectMetadataItem = useSetRecoilState( - contextStoreCurrentObjectMetadataIdState, + const setContextStoreTargetedRecords = useSetRecoilComponentStateV2( + contextStoreTargetedRecordsRuleComponentState, ); useEffect(() => { - setContextStoreTargetedRecordIds(selectedRecordIds); - }, [selectedRecordIds, setContextStoreTargetedRecordIds]); - - useEffect(() => { - setContextStoreTargetedRecordIds(selectedRecordIds); - setContextStoreCurrentObjectMetadataItem(objectMetadataItem?.id); + setContextStoreTargetedRecords({ + mode: 'selection', + selectedRecordIds: selectedRecordIds, + }); return () => { - setContextStoreTargetedRecordIds([]); - setContextStoreCurrentObjectMetadataItem(null); + setContextStoreTargetedRecords({ + mode: 'selection', + selectedRecordIds: [], + }); }; - }, [ - objectMetadataItem?.id, - selectedRecordIds, - setContextStoreCurrentObjectMetadataItem, - setContextStoreTargetedRecordIds, - ]); + }, [selectedRecordIds, setContextStoreTargetedRecords]); return <>; }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx index 50ce4ed693d0..14e1d9f729a0 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainer.tsx @@ -2,8 +2,6 @@ import styled from '@emotion/styled'; import { useRecoilCallback, useRecoilState, useSetRecoilState } from 'recoil'; import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; -import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; -import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; import { RecordIndexBoardContainer } from '@/object-record/record-index/components/RecordIndexBoardContainer'; import { RecordIndexBoardDataLoader } from '@/object-record/record-index/components/RecordIndexBoardDataLoader'; import { RecordIndexBoardDataLoaderEffect } from '@/object-record/record-index/components/RecordIndexBoardDataLoaderEffect'; @@ -24,18 +22,20 @@ import { RecordFieldValueSelectorContextProvider } from '@/object-record/record- import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; import { SpreadsheetImportProvider } from '@/spreadsheet-import/provider/components/SpreadsheetImportProvider'; -import { RecordActionMenuEntriesSetter } from '@/action-menu/actions/record-actions/components/RecordActionMenuEntriesSetter'; -import { ActionMenuBar } from '@/action-menu/components/ActionMenuBar'; -import { ActionMenuConfirmationModals } from '@/action-menu/components/ActionMenuConfirmationModals'; -import { ActionMenuDropdown } from '@/action-menu/components/ActionMenuDropdown'; -import { ActionMenuEffect } from '@/action-menu/components/ActionMenuEffect'; -import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; +import { RecordIndexActionMenu } from '@/action-menu/components/RecordIndexActionMenu'; +import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; +import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard'; +import { recordGroupDefinitionsComponentState } from '@/object-record/record-group/states/recordGroupDefinitionsComponentState'; +import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { ViewBar } from '@/views/components/ViewBar'; -import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; import { ViewField } from '@/views/types/ViewField'; +import { ViewGroup } from '@/views/types/ViewGroup'; import { ViewType } from '@/views/types/ViewType'; import { mapViewFieldsToColumnDefinitions } from '@/views/utils/mapViewFieldsToColumnDefinitions'; import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; +import { mapViewGroupsToRecordGroupDefinitions } from '@/views/utils/mapViewGroupsToRecordGroupDefinitions'; import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts'; import { useContext } from 'react'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; @@ -45,12 +45,14 @@ const StyledContainer = styled.div` flex-direction: column; height: 100%; width: 100%; - overflow: auto; + + overflow: hidden; `; -const StyledContainerWithPadding = styled.div<{ fullHeight?: boolean }>` - height: ${({ fullHeight }) => (fullHeight ? '100%' : 'auto')}; - padding-left: ${({ theme }) => theme.table.horizontalCellPadding}; +const StyledContainerWithPadding = styled.div` + height: calc(100% - 40px); + margin-left: ${({ theme }) => theme.spacing(2)}; + width: 100%; `; export const RecordIndexContainer = () => { @@ -58,21 +60,23 @@ export const RecordIndexContainer = () => { recordIndexViewTypeState, ); - const { objectNamePlural, recordIndexId } = useContext( - RecordIndexRootPropsContext, - ); - - const { objectNameSingular } = useObjectNameSingularFromPlural({ + const { objectNamePlural, - }); - - const { objectMetadataItem } = useObjectMetadataItem({ + recordIndexId, + objectMetadataItem, objectNameSingular, - }); + } = useContext(RecordIndexRootPropsContext); + + const recordGroupDefinitionsCallbackState = useRecoilComponentCallbackStateV2( + recordGroupDefinitionsComponentState, + ); const { columnDefinitions, filterDefinitions, sortDefinitions } = useColumnDefinitionsFromFieldMetadata(objectMetadataItem); + const setRecordIndexViewFilterGroups = useSetRecoilState( + recordIndexViewFilterGroupsState, + ); const setRecordIndexFilters = useSetRecoilState(recordIndexFiltersState); const setRecordIndexSorts = useSetRecoilState(recordIndexSortsState); const setRecordIndexIsCompactModeActive = useSetRecoilState( @@ -82,10 +86,17 @@ export const RecordIndexContainer = () => { recordIndexKanbanFieldMetadataIdState, ); - const { setTableFilters, setTableSorts, setTableColumns } = useRecordTable({ + const { + setTableViewFilterGroups, + setTableFilters, + setTableSorts, + setTableColumns, + } = useRecordTable({ recordTableId: recordIndexId, }); + const { setColumns } = useRecordBoard(recordIndexId); + const onViewFieldsChange = useRecoilCallback( ({ set, snapshot }) => (viewFields: ViewField[]) => { @@ -112,103 +123,119 @@ export const RecordIndexContainer = () => { [columnDefinitions, setTableColumns], ); + const onViewGroupsChange = useRecoilCallback( + ({ set, snapshot }) => + (viewGroups: ViewGroup[]) => { + const newGroupDefinitions = mapViewGroupsToRecordGroupDefinitions({ + objectMetadataItem, + viewGroups, + }); + + setColumns(newGroupDefinitions); + + const existingRecordIndexGroupDefinitions = snapshot + .getLoadable(recordGroupDefinitionsCallbackState) + .getValue(); + + if ( + !isDeeplyEqual( + existingRecordIndexGroupDefinitions, + newGroupDefinitions, + ) + ) { + set(recordGroupDefinitionsCallbackState, newGroupDefinitions); + } + }, + [objectMetadataItem, recordGroupDefinitionsCallbackState, setColumns], + ); + + const setContextStoreTargetedRecordsRule = useSetRecoilComponentStateV2( + contextStoreTargetedRecordsRuleComponentState, + ); + return ( - - - - - - } - onCurrentViewChange={(view) => { - if (!view) { - return; - } - - onViewFieldsChange(view.viewFields); - setTableFilters( - mapViewFiltersToFilters( - view.viewFilters, - filterDefinitions, - ), - ); - setRecordIndexFilters( - mapViewFiltersToFilters( - view.viewFilters, - filterDefinitions, - ), - ); - setTableSorts( - mapViewSortsToSorts(view.viewSorts, sortDefinitions), - ); - setRecordIndexSorts( - mapViewSortsToSorts(view.viewSorts, sortDefinitions), - ); - setRecordIndexViewType(view.type); - setRecordIndexViewKanbanFieldMetadataIdState( - view.kanbanFieldMetadataId, - ); - setRecordIndexIsCompactModeActive(view.isCompact); - }} - /> - - - - - {recordIndexViewType === ViewType.Table && ( - <> - - - - )} - {recordIndexViewType === ViewType.Kanban && ( - - - - + + - - )} - - - - - - - - - + } + onCurrentViewChange={(view) => { + if (!view) { + return; + } + + onViewFieldsChange(view.viewFields); + onViewGroupsChange(view.viewGroups); + setTableViewFilterGroups(view.viewFilterGroups ?? []); + setTableFilters( + mapViewFiltersToFilters(view.viewFilters, filterDefinitions), + ); + setRecordIndexFilters( + mapViewFiltersToFilters(view.viewFilters, filterDefinitions), + ); + setRecordIndexViewFilterGroups(view.viewFilterGroups ?? []); + setContextStoreTargetedRecordsRule((prev) => ({ + ...prev, + filters: mapViewFiltersToFilters( + view.viewFilters, + filterDefinitions, + ), + })); + setTableSorts( + mapViewSortsToSorts(view.viewSorts, sortDefinitions), + ); + setRecordIndexSorts( + mapViewSortsToSorts(view.viewSorts, sortDefinitions), + ); + setRecordIndexViewType(view.type); + setRecordIndexViewKanbanFieldMetadataIdState( + view.kanbanFieldMetadataId, + ); + setRecordIndexIsCompactModeActive(view.isCompact); + }} + /> + + + + {recordIndexViewType === ViewType.Table && ( + <> + + + + )} + {recordIndexViewType === ViewType.Kanban && ( + + + + + + )} + + ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect.tsx new file mode 100644 index 000000000000..d6c6f94dcf4a --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect.tsx @@ -0,0 +1,73 @@ +import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState'; +import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; +import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; +import { computeContextStoreFilters } from '@/context-store/utils/computeContextStoreFilters'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; +import { useFindManyParams } from '@/object-record/record-index/hooks/useLoadRecordIndexTable'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { useContext, useEffect } from 'react'; + +export const RecordIndexContainerContextStoreNumberOfSelectedRecordsEffect = + () => { + const setContextStoreNumberOfSelectedRecords = useSetRecoilComponentStateV2( + contextStoreNumberOfSelectedRecordsComponentState, + ); + + const contextStoreTargetedRecordsRule = useRecoilComponentValueV2( + contextStoreTargetedRecordsRuleComponentState, + ); + + const { objectNamePlural } = useContext(RecordIndexRootPropsContext); + + const { objectNameSingular } = useObjectNameSingularFromPlural({ + objectNamePlural, + }); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + const findManyRecordsParams = useFindManyParams( + objectMetadataItem?.nameSingular ?? '', + objectMetadataItem?.namePlural ?? '', + ); + + const contextStoreFilters = useRecoilComponentValueV2( + contextStoreFiltersComponentState, + ); + + const { totalCount } = useFindManyRecords({ + ...findManyRecordsParams, + recordGqlFields: { + id: true, + }, + filter: computeContextStoreFilters( + contextStoreTargetedRecordsRule, + contextStoreFilters, + objectMetadataItem, + ), + limit: 1, + skip: contextStoreTargetedRecordsRule.mode === 'selection', + }); + + useEffect(() => { + if (contextStoreTargetedRecordsRule.mode === 'selection') { + setContextStoreNumberOfSelectedRecords( + contextStoreTargetedRecordsRule.selectedRecordIds.length, + ); + } + if (contextStoreTargetedRecordsRule.mode === 'exclusion') { + setContextStoreNumberOfSelectedRecords(totalCount ?? 0); + } + }, [ + contextStoreTargetedRecordsRule, + setContextStoreNumberOfSelectedRecords, + totalCount, + ]); + + return null; + }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainerContextStoreObjectMetadataEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainerContextStoreObjectMetadataEffect.tsx new file mode 100644 index 000000000000..03a02ee36f10 --- /dev/null +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexContainerContextStoreObjectMetadataEffect.tsx @@ -0,0 +1,31 @@ +import { contextStoreCurrentObjectMetadataIdComponentState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdComponentState'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectNameSingularFromPlural } from '@/object-metadata/hooks/useObjectNameSingularFromPlural'; +import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; +import { useContext, useEffect } from 'react'; + +export const RecordIndexContainerContextStoreObjectMetadataEffect = () => { + const setContextStoreCurrentObjectMetadataItem = useSetRecoilComponentStateV2( + contextStoreCurrentObjectMetadataIdComponentState, + ); + const { objectNamePlural } = useContext(RecordIndexRootPropsContext); + + const { objectNameSingular } = useObjectNameSingularFromPlural({ + objectNamePlural, + }); + + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + + useEffect(() => { + setContextStoreCurrentObjectMetadataItem(objectMetadataItem.id); + + return () => { + setContextStoreCurrentObjectMetadataItem(null); + }; + }, [objectMetadataItem.id, setContextStoreCurrentObjectMetadataItem]); + + return null; +}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx index f167ad13f19d..60ab0fed8afc 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageHeader.tsx @@ -6,9 +6,9 @@ import { isObjectMetadataReadOnly } from '@/object-metadata/utils/isObjectMetada import { RecordIndexPageKanbanAddButton } from '@/object-record/record-index/components/RecordIndexPageKanbanAddButton'; import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; import { recordIndexViewTypeState } from '@/object-record/record-index/states/recordIndexViewTypeState'; -import { PageAddButton } from '@/ui/layout/page/PageAddButton'; -import { PageHeader } from '@/ui/layout/page/PageHeader'; -import { PageHotkeysEffect } from '@/ui/layout/page/PageHotkeysEffect'; +import { PageAddButton } from '@/ui/layout/page/components/PageAddButton'; +import { PageHeader } from '@/ui/layout/page/components/PageHeader'; +import { PageHotkeysEffect } from '@/ui/layout/page/components/PageHotkeysEffect'; import { ViewType } from '@/views/types/ViewType'; import { useContext } from 'react'; import { capitalize } from '~/utils/string/capitalize'; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageKanbanAddButton.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageKanbanAddButton.tsx index 4c3826fa8b01..65c8b130399f 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageKanbanAddButton.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageKanbanAddButton.tsx @@ -3,11 +3,10 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; import { useAddNewCard } from '@/object-record/record-board/record-board-column/hooks/useAddNewCard'; import { useIsOpportunitiesCompanyFieldDisabled } from '@/object-record/record-board/record-board-column/hooks/useIsOpportunitiesCompanyFieldDisabled'; -import { RecordBoardColumnDefinition } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; +import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; import { RecordIndexPageKanbanAddMenuItem } from '@/object-record/record-index/components/RecordIndexPageKanbanAddMenuItem'; import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; import { recordIndexKanbanFieldMetadataIdState } from '@/object-record/record-index/states/recordIndexKanbanFieldMetadataIdState'; -import { IconButton } from '@/ui/input/button/components/IconButton'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; @@ -15,7 +14,7 @@ import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; import styled from '@emotion/styled'; import { useCallback, useContext } from 'react'; import { useRecoilValue } from 'recoil'; -import { IconPlus } from 'twenty-ui'; +import { IconButton, IconPlus } from 'twenty-ui'; const StyledDropdownMenuItemsContainer = styled(DropdownMenuItemsContainer)` width: 100%; @@ -59,7 +58,7 @@ export const RecordIndexPageKanbanAddButton = () => { const { handleAddNewCardClick } = useAddNewCard(); const handleItemClick = useCallback( - (columnDefinition: RecordBoardColumnDefinition) => { + (columnDefinition: RecordGroupDefinition) => { const isOpportunityEnabled = isOpportunity && !isOpportunitiesCompanyFieldDisabled; handleAddNewCardClick( diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageKanbanAddMenuItem.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageKanbanAddMenuItem.tsx index a99b3abfd862..effa5ce3a49c 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageKanbanAddMenuItem.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexPageKanbanAddMenuItem.tsx @@ -1,11 +1,10 @@ -import { RecordBoardColumnDefinitionType } from '@/object-record/record-board/types/RecordBoardColumnDefinition'; +import { RecordGroupDefinitionType } from '@/object-record/record-group/types/RecordGroupDefinition'; import { useRecordIndexPageKanbanAddMenuItem } from '@/object-record/record-index/hooks/useRecordIndexPageKanbanAddMenuItem'; -import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import styled from '@emotion/styled'; -import { Tag } from 'twenty-ui'; +import { MenuItem, Tag } from 'twenty-ui'; const StyledMenuItem = styled(MenuItem)` - width: 200px; + width: calc(100% - 2 * var(--horizontal-padding)); `; type RecordIndexPageKanbanAddMenuItemProps = { @@ -32,18 +31,18 @@ export const RecordIndexPageKanbanAddMenuItem = ({ text={ { - const { onIndexIdentifierClick } = useContext(RecordIndexRootPropsContext); + const { indexIdentifierUrl } = useContext(RecordIndexRootPropsContext); const { recordChipData } = useRecordChipData({ objectNameSingular, record, }); - const handleAvatarChipClick = () => { - onIndexIdentifierClick(record.id); - }; - const { Icon: LeftIcon, IconColor: LeftIconColor } = useGetStandardObjectIcon(objectNameSingular); return ( @@ -34,10 +32,11 @@ export const RecordIdentifierChip = ({ name={recordChipData.name} avatarType={recordChipData.avatarType} avatarUrl={recordChipData.avatarUrl ?? ''} - onClick={handleAvatarChipClick} + to={indexIdentifierUrl ? indexIdentifierUrl(record.id) : undefined} variant={variant} LeftIcon={LeftIcon} LeftIconColor={LeftIconColor} + size={size} /> ); }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainerEffect.tsx b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainerEffect.tsx index ce5dc7279ded..314b66cef2b5 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainerEffect.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/components/RecordIndexTableContainerEffect.tsx @@ -1,44 +1,38 @@ -import { useEffect } from 'react'; -import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { useContext, useEffect } from 'react'; -import { contextStoreCurrentObjectMetadataIdState } from '@/context-store/states/contextStoreCurrentObjectMetadataIdState'; -import { contextStoreTargetedRecordIdsState } from '@/context-store/states/contextStoreTargetedRecordIdsState'; +import { contextStoreFiltersComponentState } from '@/context-store/states/contextStoreFiltersComponentState'; +import { contextStoreTargetedRecordsRuleComponentState } from '@/context-store/states/contextStoreTargetedRecordsRuleComponentState'; import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/useColumnDefinitionsFromFieldMetadata'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; import { useHandleToggleColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleColumnFilter'; import { useHandleToggleColumnSort } from '@/object-record/record-index/hooks/useHandleToggleColumnSort'; +import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; +import { hasUserSelectedAllRowsComponentState } from '@/object-record/record-table/record-table-row/states/hasUserSelectedAllRowsFamilyState'; +import { selectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/selectedRowIdsComponentSelector'; +import { unselectedRowIdsComponentSelector } from '@/object-record/record-table/states/selectors/unselectedRowIdsComponentSelector'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { useSetRecordCountInCurrentView } from '@/views/hooks/useSetRecordCountInCurrentView'; +import { useRecoilValue } from 'recoil'; -type RecordIndexTableContainerEffectProps = { - objectNameSingular: string; - recordTableId: string; - viewBarId: string; -}; +export const RecordIndexTableContainerEffect = () => { + const { recordIndexId, objectNameSingular } = useContext( + RecordIndexRootPropsContext, + ); + + const viewBarId = recordIndexId; -export const RecordIndexTableContainerEffect = ({ - objectNameSingular, - recordTableId, - viewBarId, -}: RecordIndexTableContainerEffectProps) => { const { setAvailableTableColumns, setOnEntityCountChange, - selectedRowIdsSelector, setOnToggleColumnFilter, setOnToggleColumnSort, } = useRecordTable({ - recordTableId, + recordTableId: recordIndexId, }); - const setContextStoreTargetedRecordIds = useSetRecoilState( - contextStoreTargetedRecordIdsState, - ); - - const setContextStoreCurrentObjectMetadataItem = useSetRecoilState( - contextStoreCurrentObjectMetadataIdState, - ); - const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); @@ -53,8 +47,6 @@ export const RecordIndexTableContainerEffect = ({ setAvailableTableColumns(columnDefinitions); }, [columnDefinitions, setAvailableTableColumns]); - const selectedRowIds = useRecoilValue(selectedRowIdsSelector()); - const handleToggleColumnFilter = useHandleToggleColumnFilter({ objectNameSingular, viewBarId, @@ -85,20 +77,61 @@ export const RecordIndexTableContainerEffect = ({ ); }, [setRecordCountInCurrentView, setOnEntityCountChange]); + const setContextStoreTargetedRecords = useSetRecoilComponentStateV2( + contextStoreTargetedRecordsRuleComponentState, + ); + const hasUserSelectedAllRows = useRecoilComponentValueV2( + hasUserSelectedAllRowsComponentState, + recordIndexId, + ); + const selectedRowIds = useRecoilComponentValueV2( + selectedRowIdsComponentSelector, + recordIndexId, + ); + const unselectedRowIds = useRecoilComponentValueV2( + unselectedRowIdsComponentSelector, + recordIndexId, + ); + + const recordIndexFilters = useRecoilValue(recordIndexFiltersState); + useEffect(() => { - setContextStoreTargetedRecordIds(selectedRowIds); - setContextStoreCurrentObjectMetadataItem(objectMetadataItem?.id); + if (hasUserSelectedAllRows) { + setContextStoreTargetedRecords({ + mode: 'exclusion', + excludedRecordIds: unselectedRowIds, + }); + } else { + setContextStoreTargetedRecords({ + mode: 'selection', + selectedRecordIds: selectedRowIds, + }); + } return () => { - setContextStoreTargetedRecordIds([]); - setContextStoreCurrentObjectMetadataItem(null); + setContextStoreTargetedRecords({ + mode: 'selection', + selectedRecordIds: [], + }); }; }, [ - objectMetadataItem?.id, + hasUserSelectedAllRows, selectedRowIds, - setContextStoreCurrentObjectMetadataItem, - setContextStoreTargetedRecordIds, + setContextStoreTargetedRecords, + unselectedRowIds, ]); + const setContextStoreFilters = useSetRecoilComponentStateV2( + contextStoreFiltersComponentState, + ); + + useEffect(() => { + setContextStoreFilters(recordIndexFilters); + + return () => { + setContextStoreFilters([]); + }; + }, [recordIndexFilters, setContextStoreFilters]); + return <>; }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/contexts/RecordIndexRootPropsContext.ts b/packages/twenty-front/src/modules/object-record/record-index/contexts/RecordIndexRootPropsContext.ts index 6de7cd552657..267501019b46 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/contexts/RecordIndexRootPropsContext.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/contexts/RecordIndexRootPropsContext.ts @@ -1,11 +1,13 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { createRootPropsContext } from '~/utils/createRootPropsContext'; export type RecordIndexRootPropsContextProps = { - onIndexIdentifierClick: (recordId: string) => void; + indexIdentifierUrl: (recordId: string) => string; onIndexRecordsLoaded: () => void; onCreateRecord: () => void; objectNamePlural: string; objectNameSingular: string; + objectMetadataItem: ObjectMetadataItem; recordIndexId: string; }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleIndexIdentifierClick.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleIndexIdentifierClick.ts index 2ea5bcdc416e..9a17c838fca9 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleIndexIdentifierClick.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleIndexIdentifierClick.ts @@ -2,7 +2,6 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { buildShowPageURL } from '@/object-record/record-show/utils/buildShowPageURL'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState'; -import { useNavigate } from 'react-router-dom'; export const useHandleIndexIdentifierClick = ({ objectMetadataItem, @@ -11,22 +10,20 @@ export const useHandleIndexIdentifierClick = ({ recordIndexId: string; objectMetadataItem: ObjectMetadataItem; }) => { - const navigate = useNavigate(); - const currentViewId = useRecoilComponentValueV2( currentViewIdComponentState, recordIndexId, ); - const handleIndexIdentifierClick = (recordId: string) => { + const indexIdentifierUrl = (recordId: string) => { const showPageURL = buildShowPageURL( objectMetadataItem.nameSingular, recordId, currentViewId, ); - navigate(showPageURL); + return showPageURL; }; - return { handleIndexIdentifierClick }; + return { indexIdentifierUrl }; }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleTrashColumnFilter.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleTrashColumnFilter.ts index 31dac4ae702e..157e06e14279 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleTrashColumnFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useHandleToggleTrashColumnFilter.ts @@ -5,7 +5,8 @@ import { useColumnDefinitionsFromFieldMetadata } from '@/object-metadata/hooks/u import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { getFilterTypeFromFieldType } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions'; import { Filter } from '@/object-record/object-filter-dropdown/types/Filter'; -import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; +import { isSoftDeleteFilterActiveComponentState } from '@/object-record/record-table/states/isSoftDeleteFilterActiveComponentState'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; import { useUpsertCombinedViewFilters } from '@/views/hooks/useUpsertCombinedViewFilters'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; import { useRecoilCallback } from 'recoil'; @@ -28,7 +29,12 @@ export const useHandleToggleTrashColumnFilter = ({ useColumnDefinitionsFromFieldMetadata(objectMetadataItem); const { upsertCombinedViewFilter } = useUpsertCombinedViewFilters(viewBarId); - const { isSoftDeleteActiveState } = useRecordTableStates(viewBarId); + + const isSoftDeleteFilterActiveComponentRecoilState = + useRecoilComponentCallbackStateV2( + isSoftDeleteFilterActiveComponentState, + viewBarId, + ); const handleToggleTrashColumnFilter = useCallback(() => { const trashFieldMetadata = objectMetadataItem.fields.find( @@ -69,9 +75,9 @@ export const useHandleToggleTrashColumnFilter = ({ const toggleSoftDeleteFilterState = useRecoilCallback( ({ set }) => (currentState: boolean) => { - set(isSoftDeleteActiveState, currentState); + set(isSoftDeleteFilterActiveComponentRecoilState, currentState); }, - [isSoftDeleteActiveState], + [isSoftDeleteFilterActiveComponentRecoilState], ); return { handleToggleTrashColumnFilter, diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts index 297f1dcf8088..1dc4612d331b 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoard.ts @@ -5,13 +5,16 @@ import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadata import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard'; -import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter'; +import { computeViewRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter'; +import { recordGroupDefinitionsComponentState } from '@/object-record/record-group/states/recordGroupDefinitionsComponentState'; import { useRecordBoardRecordGqlFields } from '@/object-record/record-index/hooks/useRecordBoardRecordGqlFields'; import { recordIndexFieldDefinitionsState } from '@/object-record/record-index/states/recordIndexFieldDefinitionsState'; import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState'; import { recordIndexIsCompactModeActiveState } from '@/object-record/record-index/states/recordIndexIsCompactModeActiveState'; import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState'; +import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState'; import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { useSetRecordCountInCurrentView } from '@/views/hooks/useSetRecordCountInCurrentView'; type UseLoadRecordIndexBoardProps = { @@ -31,6 +34,7 @@ export const useLoadRecordIndexBoard = ({ const { setRecordIds: setRecordIdsInBoard, setFieldDefinitions, + setColumns, isCompactModeActiveState, } = useRecordBoard(recordBoardId); const { upsertRecords: upsertRecordsInStore } = useUpsertRecordsInStore(); @@ -42,11 +46,24 @@ export const useLoadRecordIndexBoard = ({ setFieldDefinitions(recordIndexFieldDefinitions); }, [recordIndexFieldDefinitions, setFieldDefinitions]); + const recordIndexViewFilterGroups = useRecoilValue( + recordIndexViewFilterGroupsState, + ); + + const recordIndexGroupDefinitions = useRecoilComponentValueV2( + recordGroupDefinitionsComponentState, + ); + + useEffect(() => { + setColumns(recordIndexGroupDefinitions); + }, [recordIndexGroupDefinitions, setColumns]); + const recordIndexFilters = useRecoilValue(recordIndexFiltersState); const recordIndexSorts = useRecoilValue(recordIndexSortsState); - const requestFilters = turnObjectDropdownFilterIntoQueryFilter( + const requestFilters = computeViewRecordGqlOperationFilter( recordIndexFilters, objectMetadataItem?.fields ?? [], + recordIndexViewFilterGroups, ); const orderBy = turnSortsIntoOrderBy(objectMetadataItem, recordIndexSorts); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoardColumn.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoardColumn.ts index 02485fe0d78b..99a5220335b6 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoardColumn.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexBoardColumn.ts @@ -4,11 +4,13 @@ import { useRecoilValue } from 'recoil'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; +import { useRecordBoardStates } from '@/object-record/record-board/hooks/internal/useRecordBoardStates'; import { useRecordBoard } from '@/object-record/record-board/hooks/useRecordBoard'; -import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter'; +import { computeViewRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter'; import { useRecordBoardRecordGqlFields } from '@/object-record/record-index/hooks/useRecordBoardRecordGqlFields'; import { recordIndexFiltersState } from '@/object-record/record-index/states/recordIndexFiltersState'; import { recordIndexSortsState } from '@/object-record/record-index/states/recordIndexSortsState'; +import { recordIndexViewFilterGroupsState } from '@/object-record/record-index/states/recordIndexViewFilterGroupsState'; import { useUpsertRecordsInStore } from '@/object-record/record-store/hooks/useUpsertRecordsInStore'; import { isDefined } from '~/utils/isDefined'; @@ -16,7 +18,6 @@ type UseLoadRecordIndexBoardProps = { objectNameSingular: string; boardFieldMetadataId: string | null; recordBoardId: string; - columnFieldSelectValue: string | null; columnId: string; }; @@ -24,20 +25,26 @@ export const useLoadRecordIndexBoardColumn = ({ objectNameSingular, boardFieldMetadataId, recordBoardId, - columnFieldSelectValue, columnId, }: UseLoadRecordIndexBoardProps) => { const { objectMetadataItem } = useObjectMetadataItem({ objectNameSingular, }); const { setRecordIdsForColumn } = useRecordBoard(recordBoardId); + const { columnsFamilySelector } = useRecordBoardStates(recordBoardId); const { upsertRecords: upsertRecordsInStore } = useUpsertRecordsInStore(); + const recordIndexViewFilterGroups = useRecoilValue( + recordIndexViewFilterGroupsState, + ); const recordIndexFilters = useRecoilValue(recordIndexFiltersState); const recordIndexSorts = useRecoilValue(recordIndexSortsState); - const requestFilters = turnObjectDropdownFilterIntoQueryFilter( + const columnDefinition = useRecoilValue(columnsFamilySelector(columnId)); + + const requestFilters = computeViewRecordGqlOperationFilter( recordIndexFilters, objectMetadataItem?.fields ?? [], + recordIndexViewFilterGroups, ); const orderBy = turnSortsIntoOrderBy(objectMetadataItem, recordIndexSorts); @@ -53,9 +60,9 @@ export const useLoadRecordIndexBoardColumn = ({ const filter = { ...requestFilters, [recordIndexKanbanFieldMetadataItem?.name ?? '']: isDefined( - columnFieldSelectValue, + columnDefinition?.value, ) - ? { in: [columnFieldSelectValue] } + ? { in: [columnDefinition?.value] } : { is: 'NULL' }, }; diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexTable.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexTable.ts index df178df4c4fd..a2b0e03480b4 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexTable.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useLoadRecordIndexTable.ts @@ -5,11 +5,14 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; -import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter'; +import { computeViewRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter'; import { useRecordTableRecordGqlFields } from '@/object-record/record-index/hooks/useRecordTableRecordGqlFields'; -import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; import { useRecordTable } from '@/object-record/record-table/hooks/useRecordTable'; +import { tableFiltersComponentState } from '@/object-record/record-table/states/tableFiltersComponentState'; +import { tableSortsComponentState } from '@/object-record/record-table/states/tableSortsComponentState'; +import { tableViewFilterGroupsComponentState } from '@/object-record/record-table/states/tableViewFilterGroupsComponentState'; import { SIGN_IN_BACKGROUND_MOCK_COMPANIES } from '@/sign-in-background-mock/constants/SignInBackgroundMockCompanies'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { isNull } from '@sniptt/guards'; import { WorkspaceActivationStatus } from '~/generated/graphql'; @@ -21,15 +24,23 @@ export const useFindManyParams = ( objectNameSingular, }); - const { tableFiltersState, tableSortsState } = - useRecordTableStates(recordTableId); - - const tableFilters = useRecoilValue(tableFiltersState); - const tableSorts = useRecoilValue(tableSortsState); + const tableViewFilterGroups = useRecoilComponentValueV2( + tableViewFilterGroupsComponentState, + recordTableId, + ); + const tableFilters = useRecoilComponentValueV2( + tableFiltersComponentState, + recordTableId, + ); + const tableSorts = useRecoilComponentValueV2( + tableSortsComponentState, + recordTableId, + ); - const filter = turnObjectDropdownFilterIntoQueryFilter( + const filter = computeViewRecordGqlOperationFilter( tableFilters, objectMetadataItem?.fields ?? [], + tableViewFilterGroups, ); const orderBy = turnSortsIntoOrderBy(objectMetadataItem, tableSorts); diff --git a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordTableRecordGqlFields.ts b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordTableRecordGqlFields.ts index 6e9661a120a2..ae078774470d 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordTableRecordGqlFields.ts +++ b/packages/twenty-front/src/modules/object-record/record-index/hooks/useRecordTableRecordGqlFields.ts @@ -1,11 +1,10 @@ -import { useRecoilValue } from 'recoil'; - import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { getObjectMetadataIdentifierFields } from '@/object-metadata/utils/getObjectMetadataIdentifierFields'; import { generateDepthOneRecordGqlFields } from '@/object-record/graphql/utils/generateDepthOneRecordGqlFields'; -import { useRecordTableStates } from '@/object-record/record-table/hooks/internal/useRecordTableStates'; +import { visibleTableColumnsComponentSelector } from '@/object-record/record-table/states/selectors/visibleTableColumnsComponentSelector'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { isDefined } from '~/utils/isDefined'; export const useRecordTableRecordGqlFields = ({ @@ -13,12 +12,12 @@ export const useRecordTableRecordGqlFields = ({ }: { objectMetadataItem: ObjectMetadataItem; }) => { - const { visibleTableColumnsSelector } = useRecordTableStates(); - const { imageIdentifierFieldMetadataItem, labelIdentifierFieldMetadataItem } = getObjectMetadataIdentifierFields({ objectMetadataItem }); - const visibleTableColumns = useRecoilValue(visibleTableColumnsSelector()); + const visibleTableColumns = useRecoilComponentValueV2( + visibleTableColumnsComponentSelector, + ); const identifierQueryFields: Record = {}; diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdown.tsx b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdown.tsx index 25e53d8cc5b1..3c2f5b2bae3e 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdown.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdown.tsx @@ -1,3 +1,4 @@ +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { RecordIndexOptionsDropdownButton } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdownButton'; import { RecordIndexOptionsDropdownContent } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdownContent'; import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; @@ -7,13 +8,13 @@ import { ViewType } from '@/views/types/ViewType'; type RecordIndexOptionsDropdownProps = { viewType: ViewType; - objectNameSingular: string; + objectMetadataItem: ObjectMetadataItem; recordIndexId: string; }; export const RecordIndexOptionsDropdown = ({ recordIndexId, - objectNameSingular, + objectMetadataItem, viewType, }: RecordIndexOptionsDropdownProps) => { return ( @@ -26,7 +27,7 @@ export const RecordIndexOptionsDropdown = ({ dropdownComponents={ } diff --git a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx index a884eda8582b..1ed208d4ed53 100644 --- a/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx +++ b/packages/twenty-front/src/modules/object-record/record-index/options/components/RecordIndexOptionsDropdownContent.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Key } from 'ts-key-enum'; import { IconBaselineDensitySmall, @@ -9,15 +9,26 @@ import { IconRotate2, IconSettings, IconTag, + MenuItem, + MenuItemNavigate, + MenuItemToggle, + UndecoratedLink, + useIcons, } from 'twenty-ui'; import { useObjectNamePluralFromSingular } from '@/object-metadata/hooks/useObjectNamePluralFromSingular'; import { useHandleToggleTrashColumnFilter } from '@/object-record/record-index/hooks/useHandleToggleTrashColumnFilter'; import { RECORD_INDEX_OPTIONS_DROPDOWN_ID } from '@/object-record/record-index/options/constants/RecordIndexOptionsDropdownId'; + import { displayedExportProgress, - useExportTableData, -} from '@/object-record/record-index/options/hooks/useExportTableData'; + useExportRecordData, +} from '@/action-menu/hooks/useExportRecordData'; +import { contextStoreNumberOfSelectedRecordsComponentState } from '@/context-store/states/contextStoreNumberOfSelectedRecordsComponentState'; +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { useRecordGroupReorder } from '@/object-record/record-group/hooks/useRecordGroupReorder'; +import { useRecordGroups } from '@/object-record/record-group/hooks/useRecordGroups'; +import { useRecordGroupVisibility } from '@/object-record/record-group/hooks/useRecordGroupVisibility'; import { useRecordIndexOptionsForBoard } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForBoard'; import { useRecordIndexOptionsForTable } from '@/object-record/record-index/options/hooks/useRecordIndexOptionsForTable'; import { TableOptionsHotkeyScope } from '@/object-record/record-table/types/TableOptionsHotkeyScope'; @@ -28,33 +39,39 @@ import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenu import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; -import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink'; -import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; -import { MenuItemNavigate } from '@/ui/navigation/menu-item/components/MenuItemNavigate'; -import { MenuItemToggle } from '@/ui/navigation/menu-item/components/MenuItemToggle'; import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; +import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { ViewFieldsVisibilityDropdownSection } from '@/views/components/ViewFieldsVisibilityDropdownSection'; +import { ViewGroupsVisibilityDropdownSection } from '@/views/components/ViewGroupsVisibilityDropdownSection'; import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; import { ViewType } from '@/views/types/ViewType'; import { useLocation } from 'react-router-dom'; import { useSetRecoilState } from 'recoil'; -type RecordIndexOptionsMenu = 'fields' | 'hiddenFields'; +type RecordIndexOptionsMenu = + | 'viewGroups' + | 'hiddenViewGroups' + | 'fields' + | 'hiddenFields'; type RecordIndexOptionsDropdownContentProps = { recordIndexId: string; - objectNameSingular: string; + objectMetadataItem: ObjectMetadataItem; viewType: ViewType; }; +// TODO: Break this component down export const RecordIndexOptionsDropdownContent = ({ viewType, recordIndexId, - objectNameSingular, + objectMetadataItem, }: RecordIndexOptionsDropdownContentProps) => { const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView(); + const { getIcon } = useIcons(); + const { closeDropdown } = useDropdown(RECORD_INDEX_OPTIONS_DROPDOWN_ID); const [currentMenu, setCurrentMenu] = useState< @@ -68,7 +85,7 @@ export const RecordIndexOptionsDropdownContent = ({ }; const { objectNamePlural } = useObjectNamePluralFromSingular({ - objectNameSingular: objectNameSingular, + objectNameSingular: objectMetadataItem.nameSingular, }); const settingsUrl = getSettingsPagePath(SettingsPath.ObjectDetail, { @@ -92,7 +109,7 @@ export const RecordIndexOptionsDropdownContent = ({ const { handleToggleTrashColumnFilter, toggleSoftDeleteFilterState } = useHandleToggleTrashColumnFilter({ - objectNameSingular, + objectNameSingular: objectMetadataItem.nameSingular, viewBarId: recordIndexId, }); @@ -104,11 +121,33 @@ export const RecordIndexOptionsDropdownContent = ({ isCompactModeActive, setAndPersistIsCompactModeActive, } = useRecordIndexOptionsForBoard({ - objectNameSingular, + objectNameSingular: objectMetadataItem.nameSingular, recordBoardId: recordIndexId, viewBarId: recordIndexId, }); + const { + hiddenRecordGroups, + visibleRecordGroups, + viewGroupFieldMetadataItem, + } = useRecordGroups({ + objectNameSingular: objectMetadataItem.nameSingular, + }); + const { handleVisibilityChange: handleRecordGroupVisibilityChange } = + useRecordGroupVisibility({ + viewBarId: recordIndexId, + }); + const { handleOrderChange: handleRecordGroupOrderChange } = + useRecordGroupReorder({ + objectNameSingular: objectMetadataItem.nameSingular, + viewBarId: recordIndexId, + }); + + const viewGroupSettingsUrl = getSettingsPagePath(SettingsPath.ObjectDetail, { + id: viewGroupFieldMetadataItem?.name, + objectSlug: objectNamePlural, + }); + const visibleRecordFields = viewType === ViewType.Kanban ? visibleBoardFields : visibleTableColumns; @@ -126,12 +165,12 @@ export const RecordIndexOptionsDropdownContent = ({ : handleColumnVisibilityChange; const { openObjectRecordsSpreasheetImportDialog } = - useOpenObjectRecordsSpreasheetImportDialog(objectNameSingular); + useOpenObjectRecordsSpreasheetImportDialog(objectMetadataItem.nameSingular); - const { progress, download } = useExportTableData({ + const { progress, download } = useExportRecordData({ delayMs: 100, - filename: `${objectNameSingular}.csv`, - objectNameSingular, + filename: `${objectMetadataItem.nameSingular}.csv`, + objectMetadataItem, recordIndexId, viewType, }); @@ -141,10 +180,34 @@ export const RecordIndexOptionsDropdownContent = ({ navigationMemorizedUrlState, ); + const isViewGroupMenuItemVisible = + viewGroupFieldMetadataItem && + (visibleRecordGroups.length > 0 || hiddenRecordGroups.length > 0); + + const contextStoreNumberOfSelectedRecords = useRecoilComponentValueV2( + contextStoreNumberOfSelectedRecordsComponentState, + ); + + const mode = contextStoreNumberOfSelectedRecords > 0 ? 'selection' : 'all'; + + useEffect(() => { + if (currentMenu === 'hiddenViewGroups' && hiddenRecordGroups.length === 0) { + setCurrentMenu('viewGroups'); + } + }, [hiddenRecordGroups, currentMenu]); + return ( <> {!currentMenu && ( + {isViewGroupMenuItemVisible && ( + handleSelectMenu('viewGroups')} + LeftIcon={getIcon(currentViewWithCombinedFiltersAndSorts?.icon)} + text={viewGroupFieldMetadataItem.label} + hasSubMenu + /> + )} handleSelectMenu('fields')} LeftIcon={IconTag} @@ -159,7 +222,7 @@ export const RecordIndexOptionsDropdownContent = ({ { @@ -172,20 +235,50 @@ export const RecordIndexOptionsDropdownContent = ({ /> )} - {currentMenu === 'fields' && ( + {currentMenu === 'viewGroups' && ( <> - Fields + {viewGroupFieldMetadataItem?.label} - + {hiddenRecordGroups.length > 0 && ( + <> + + + handleSelectMenu('hiddenViewGroups')} + LeftIcon={IconEyeOff} + text={`Hidden ${viewGroupFieldMetadataItem?.label ?? ''}`} + /> + + + )} + + )} + {currentMenu === 'fields' && ( + <> + + Fields + + + + )} + {currentMenu === 'hiddenViewGroups' && ( + <> + setCurrentMenu('viewGroups')} + > + Hidden {viewGroupFieldMetadataItem?.label} + + + + { + setNavigationMemorizedUrl(location.pathname + location.search); + closeDropdown(); + }} + > + + + + + + )} {currentMenu === 'hiddenFields' && ( <>
); diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsContainer.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsContainer.tsx index 9556441e6267..ae4e0b59a1a7 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsContainer.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsCalendarChannelsContainer.tsx @@ -64,7 +64,9 @@ export const SettingsAccountsCalendarChannelsContainer = () => { {tabs.length > 1 && ( diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsListCard.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsListCard.tsx index 238371241d0a..6000afa1fe98 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsListCard.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsListCard.tsx @@ -1,5 +1,5 @@ import { useNavigate } from 'react-router-dom'; -import { IconGoogle } from 'twenty-ui'; +import { IconComponent, IconGoogle, IconMicrosoft } from 'twenty-ui'; import { ConnectedAccount } from '@/accounts/types/ConnectedAccount'; import { SettingsAccountsListEmptyStateCard } from '@/settings/accounts/components/SettingsAccountsListEmptyStateCard'; @@ -9,6 +9,11 @@ import { SettingsPath } from '@/types/SettingsPath'; import { SettingsAccountsConnectedAccountsRowRightContainer } from '@/settings/accounts/components/SettingsAccountsConnectedAccountsRowRightContainer'; import { SettingsListCard } from '../../components/SettingsListCard'; +const ProviderIcons: { [k: string]: IconComponent } = { + google: IconGoogle, + microsoft: IconMicrosoft, +}; + export const SettingsAccountsConnectedAccountsListCard = ({ accounts, loading, @@ -27,7 +32,7 @@ export const SettingsAccountsConnectedAccountsListCard = ({ items={accounts} getItemLabel={(account) => account.handle} isLoading={loading} - RowIcon={IconGoogle} + RowIconFn={(row) => ProviderIcons[row.provider]} RowRightComponent={({ item: account }) => ( )} diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsRowRightContainer.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsRowRightContainer.tsx index 2091be23645c..37ffe2a3cd4e 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsRowRightContainer.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsConnectedAccountsRowRightContainer.tsx @@ -2,7 +2,7 @@ import { ConnectedAccount } from '@/accounts/types/ConnectedAccount'; import { SettingsAccountsRowDropdownMenu } from '@/settings/accounts/components/SettingsAccountsRowDropdownMenu'; import { SyncStatus } from '@/settings/accounts/constants/SyncStatus'; import { computeSyncStatus } from '@/settings/accounts/utils/computeSyncStatus'; -import { Status } from '@/ui/display/status/components/Status'; +import { Status } from 'twenty-ui'; import styled from '@emotion/styled'; const StyledRowRightContainer = styled.div` diff --git a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsListEmptyStateCard.tsx b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsListEmptyStateCard.tsx index 6178caf68126..d532691fcc5b 100644 --- a/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsListEmptyStateCard.tsx +++ b/packages/twenty-front/src/modules/settings/accounts/components/SettingsAccountsListEmptyStateCard.tsx @@ -1,11 +1,14 @@ +import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; import styled from '@emotion/styled'; -import { IconGoogle } from 'twenty-ui'; - -import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth'; -import { Button } from '@/ui/input/button/components/Button'; -import { Card } from '@/ui/layout/card/components/Card'; -import { CardContent } from '@/ui/layout/card/components/CardContent'; -import { CardHeader } from '@/ui/layout/card/components/CardHeader'; +import { + Button, + Card, + CardContent, + CardHeader, + IconGoogle, + IconMicrosoft, +} from 'twenty-ui'; const StyledHeader = styled(CardHeader)` align-items: center; @@ -16,6 +19,7 @@ const StyledHeader = styled(CardHeader)` const StyledBody = styled(CardContent)` display: flex; justify-content: center; + gap: ${({ theme }) => theme.spacing(2)}; `; type SettingsAccountsListEmptyStateCardProps = { @@ -25,11 +29,10 @@ type SettingsAccountsListEmptyStateCardProps = { export const SettingsAccountsListEmptyStateCard = ({ label, }: SettingsAccountsListEmptyStateCardProps) => { - const { triggerGoogleApisOAuth } = useTriggerGoogleApisOAuth(); - - const handleOnClick = async () => { - await triggerGoogleApisOAuth(); - }; + const { triggerApisOAuth } = useTriggerApisOAuth(); + const isMicrosoftSyncEnabled = useIsFeatureEnabled( + 'IS_MICROSOFT_SYNC_ENABLED', + ); return ( @@ -39,8 +42,16 @@ export const SettingsAccountsListEmptyStateCard = ({ Icon={IconGoogle} title="Connect with Google" variant="secondary" - onClick={handleOnClick} + onClick={() => triggerApisOAuth('google')} /> + {isMicrosoftSyncEnabled && ( + + {isXMLMetadataValid() && ( + + )} + + +
+ + + + + + + + + + + +
+ + ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx new file mode 100644 index 000000000000..ac5eef959f3e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSecurityOptionsList.tsx @@ -0,0 +1,59 @@ +import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { SettingsOptionCardContent } from '@/settings/components/SettingsOptionCardContent'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { useRecoilState } from 'recoil'; +import { Card, IconLink, isDefined } from 'twenty-ui'; +import { useUpdateWorkspaceMutation } from '~/generated/graphql'; + +export const SettingsSecurityOptionsList = () => { + const { enqueueSnackBar } = useSnackBar(); + + const [currentWorkspace, setCurrentWorkspace] = useRecoilState( + currentWorkspaceState, + ); + if (!isDefined(currentWorkspace)) { + throw new Error( + 'The current workspace must be defined to edit its security options.', + ); + } + + const [updateWorkspace] = useUpdateWorkspaceMutation(); + + const handleChange = async (value: boolean) => { + try { + if (!currentWorkspace?.id) { + throw new Error('User is not logged in'); + } + await updateWorkspace({ + variables: { + input: { + isPublicInviteLinkEnabled: value, + }, + }, + }); + setCurrentWorkspace({ + ...currentWorkspace, + isPublicInviteLinkEnabled: value, + }); + } catch (err: any) { + enqueueSnackBar(err?.message, { + variant: SnackBarVariant.Error, + }); + } + }; + + return ( + + + handleChange(!currentWorkspace.isPublicInviteLinkEnabled) + } + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/components/SettingsSecuritySSORowDropdownMenu.tsx b/packages/twenty-front/src/modules/settings/security/components/SettingsSecuritySSORowDropdownMenu.tsx new file mode 100644 index 000000000000..d7de1d3b2e3f --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/components/SettingsSecuritySSORowDropdownMenu.tsx @@ -0,0 +1,104 @@ +/* @license Enterprise */ + +import { + IconArchive, + IconDotsVertical, + IconTrash, + LightIconButton, + MenuItem, +} from 'twenty-ui'; + +import { useDeleteSSOIdentityProvider } from '@/settings/security/hooks/useDeleteSSOIdentityProvider'; +import { useUpdateSSOIdentityProvider } from '@/settings/security/hooks/useUpdateSSOIdentityProvider'; +import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state'; +import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; +import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; +import { UnwrapRecoilValue } from 'recoil'; +import { SsoIdentityProviderStatus } from '~/generated/graphql'; +import { isDefined } from '~/utils/isDefined'; + +type SettingsSecuritySSORowDropdownMenuProps = { + SSOIdp: UnwrapRecoilValue[0]; +}; + +export const SettingsSecuritySSORowDropdownMenu = ({ + SSOIdp, +}: SettingsSecuritySSORowDropdownMenuProps) => { + const dropdownId = `settings-account-row-${SSOIdp.id}`; + + const { enqueueSnackBar } = useSnackBar(); + + const { closeDropdown } = useDropdown(dropdownId); + + const { deleteSSOIdentityProvider } = useDeleteSSOIdentityProvider(); + const { updateSSOIdentityProvider } = useUpdateSSOIdentityProvider(); + + const handleDeleteSSOIdentityProvider = async ( + identityProviderId: string, + ) => { + const result = await deleteSSOIdentityProvider({ + identityProviderId, + }); + if (isDefined(result.errors)) { + enqueueSnackBar('Error deleting SSO Identity Provider', { + variant: SnackBarVariant.Error, + duration: 2000, + }); + } + }; + + const toggleSSOIdentityProviderStatus = async ( + identityProviderId: string, + ) => { + const result = await updateSSOIdentityProvider({ + id: identityProviderId, + status: + SSOIdp.status === 'Active' + ? SsoIdentityProviderStatus.Inactive + : SsoIdentityProviderStatus.Active, + }); + if (isDefined(result.errors)) { + enqueueSnackBar('Error editing SSO Identity Provider', { + variant: SnackBarVariant.Error, + duration: 2000, + }); + } + }; + + return ( + + } + dropdownMenuWidth={160} + dropdownComponents={ + + { + toggleSSOIdentityProviderStatus(SSOIdp.id); + closeDropdown(); + }} + /> + { + handleDeleteSSOIdentityProvider(SSOIdp.id); + closeDropdown(); + }} + /> + + } + /> + ); +}; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/createOIDCSSOIdentityProvider.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createOIDCSSOIdentityProvider.ts new file mode 100644 index 000000000000..e0cd4b6dd323 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createOIDCSSOIdentityProvider.ts @@ -0,0 +1,15 @@ +/* @license Enterprise */ + +import { gql } from '@apollo/client'; + +export const CREATE_OIDC_SSO_IDENTITY_PROVIDER = gql` + mutation CreateOIDCIdentityProvider($input: SetupOIDCSsoInput!) { + createOIDCIdentityProvider(input: $input) { + id + type + issuer + name + status + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/createSAMLSSOIdentityProvider.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createSAMLSSOIdentityProvider.ts new file mode 100644 index 000000000000..3729b4f504a9 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/createSAMLSSOIdentityProvider.ts @@ -0,0 +1,15 @@ +/* @license Enterprise */ + +import { gql } from '@apollo/client'; + +export const CREATE_SAML_SSO_IDENTITY_PROVIDER = gql` + mutation CreateSAMLIdentityProvider($input: SetupSAMLSsoInput!) { + createSAMLIdentityProvider(input: $input) { + id + type + issuer + name + status + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteSSOIdentityProvider.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteSSOIdentityProvider.ts new file mode 100644 index 000000000000..d9153d33d909 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/deleteSSOIdentityProvider.ts @@ -0,0 +1,11 @@ +/* @license Enterprise */ + +import { gql } from '@apollo/client'; + +export const DELETE_SSO_IDENTITY_PROVIDER = gql` + mutation DeleteSSOIdentityProvider($input: DeleteSsoInput!) { + deleteSSOIdentityProvider(input: $input) { + identityProviderId + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/mutations/editSSOIdentityProvider.ts b/packages/twenty-front/src/modules/settings/security/graphql/mutations/editSSOIdentityProvider.ts new file mode 100644 index 000000000000..78a83a3b53fa --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/mutations/editSSOIdentityProvider.ts @@ -0,0 +1,15 @@ +/* @license Enterprise */ + +import { gql } from '@apollo/client'; + +export const EDIT_SSO_IDENTITY_PROVIDER = gql` + mutation EditSSOIdentityProvider($input: EditSsoInput!) { + editSSOIdentityProvider(input: $input) { + id + type + issuer + name + status + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/graphql/queries/getWorkspaceSSOIdentitiesProviders.ts b/packages/twenty-front/src/modules/settings/security/graphql/queries/getWorkspaceSSOIdentitiesProviders.ts new file mode 100644 index 000000000000..0fdd9701e722 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/graphql/queries/getWorkspaceSSOIdentitiesProviders.ts @@ -0,0 +1,15 @@ +/* @license Enterprise */ + +import { gql } from '@apollo/client'; + +export const LIST_WORKSPACE_SSO_IDENTITY_PROVIDERS = gql` + query ListSSOIdentityProvidersByWorkspaceId { + listSSOIdentityProvidersByWorkspaceId { + type + id + name + issuer + status + } + } +`; diff --git a/packages/twenty-front/src/modules/settings/security/hooks/__tests__/useCreateSSOIdentityProvider.test.tsx b/packages/twenty-front/src/modules/settings/security/hooks/__tests__/useCreateSSOIdentityProvider.test.tsx new file mode 100644 index 000000000000..50b71e727b2b --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/hooks/__tests__/useCreateSSOIdentityProvider.test.tsx @@ -0,0 +1,94 @@ +/* @license Enterprise */ + +import { renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { RecoilRoot } from 'recoil'; + +import { useCreateSSOIdentityProvider } from '@/settings/security/hooks/useCreateSSOIdentityProvider'; + +const mutationOIDCCallSpy = jest.fn(); +const mutationSAMLCallSpy = jest.fn(); + +jest.mock('~/generated/graphql', () => ({ + useCreateOidcIdentityProviderMutation: () => [mutationOIDCCallSpy], + useCreateSamlIdentityProviderMutation: () => [mutationSAMLCallSpy], +})); + +const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('useCreateSSOIdentityProvider', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('create OIDC sso identity provider', async () => { + const OIDCParams = { + type: 'OIDC' as const, + name: 'test', + clientID: 'test', + clientSecret: 'test', + issuer: 'test', + }; + renderHook( + () => { + const { createSSOIdentityProvider } = useCreateSSOIdentityProvider(); + createSSOIdentityProvider(OIDCParams); + }, + { wrapper: Wrapper }, + ); + + // eslint-disable-next-line unused-imports/no-unused-vars + const { type, ...input } = OIDCParams; + expect(mutationOIDCCallSpy).toHaveBeenCalledWith({ + onCompleted: expect.any(Function), + variables: { + input, + }, + }); + }); + it('create SAML sso identity provider', async () => { + const SAMLParams = { + type: 'SAML' as const, + name: 'test', + metadata: 'test', + certificate: 'test', + id: 'test', + issuer: 'test', + ssoURL: 'test', + }; + renderHook( + () => { + const { createSSOIdentityProvider } = useCreateSSOIdentityProvider(); + createSSOIdentityProvider(SAMLParams); + }, + { wrapper: Wrapper }, + ); + + // eslint-disable-next-line unused-imports/no-unused-vars + const { type, ...input } = SAMLParams; + expect(mutationOIDCCallSpy).not.toHaveBeenCalled(); + expect(mutationSAMLCallSpy).toHaveBeenCalledWith({ + onCompleted: expect.any(Function), + variables: { + input, + }, + }); + }); + it('throw error if provider is not SAML or OIDC', async () => { + const OTHERParams = { + type: 'OTHER' as const, + }; + renderHook( + async () => { + const { createSSOIdentityProvider } = useCreateSSOIdentityProvider(); + await expect( + // @ts-expect-error - It's expected to throw an error + createSSOIdentityProvider(OTHERParams), + ).rejects.toThrowError(); + }, + { wrapper: Wrapper }, + ); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/security/hooks/__tests__/useDeleteSSOIdentityProvider.test.tsx b/packages/twenty-front/src/modules/settings/security/hooks/__tests__/useDeleteSSOIdentityProvider.test.tsx new file mode 100644 index 000000000000..48b5e101918c --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/hooks/__tests__/useDeleteSSOIdentityProvider.test.tsx @@ -0,0 +1,40 @@ +/* @license Enterprise */ + +import { renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { RecoilRoot } from 'recoil'; + +import { useDeleteSSOIdentityProvider } from '@/settings/security/hooks/useDeleteSSOIdentityProvider'; + +const mutationDeleteSSOIDPCallSpy = jest.fn(); + +jest.mock('~/generated/graphql', () => ({ + useDeleteSsoIdentityProviderMutation: () => [mutationDeleteSSOIDPCallSpy], +})); + +const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('useDeleteSsoIdentityProvider', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('delete SSO identity provider', async () => { + renderHook( + () => { + const { deleteSSOIdentityProvider } = useDeleteSSOIdentityProvider(); + deleteSSOIdentityProvider({ identityProviderId: 'test' }); + }, + { wrapper: Wrapper }, + ); + + expect(mutationDeleteSSOIDPCallSpy).toHaveBeenCalledWith({ + onCompleted: expect.any(Function), + variables: { + input: { identityProviderId: 'test' }, + }, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/security/hooks/__tests__/useEditSSOIdentityProvider.test.tsx b/packages/twenty-front/src/modules/settings/security/hooks/__tests__/useEditSSOIdentityProvider.test.tsx new file mode 100644 index 000000000000..f253f10cb432 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/hooks/__tests__/useEditSSOIdentityProvider.test.tsx @@ -0,0 +1,49 @@ +/* @license Enterprise */ + +import { renderHook } from '@testing-library/react'; +import { ReactNode } from 'react'; +import { RecoilRoot } from 'recoil'; + +import { useUpdateSSOIdentityProvider } from '@/settings/security/hooks/useUpdateSSOIdentityProvider'; +import { SsoIdentityProviderStatus } from '~/generated/graphql'; + +const mutationEditSSOIDPCallSpy = jest.fn(); + +jest.mock('~/generated/graphql', () => { + const actual = jest.requireActual('~/generated/graphql'); + return { + useEditSsoIdentityProviderMutation: () => [mutationEditSSOIDPCallSpy], + SsoIdentityProviderStatus: actual.SsoIdentityProviderStatus, + }; +}); + +const Wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +describe('useEditSsoIdentityProvider', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('Deactivate SSO identity provider', async () => { + const params = { + id: 'test', + status: SsoIdentityProviderStatus.Inactive, + }; + renderHook( + () => { + const { updateSSOIdentityProvider } = useUpdateSSOIdentityProvider(); + updateSSOIdentityProvider(params); + }, + { wrapper: Wrapper }, + ); + + expect(mutationEditSSOIDPCallSpy).toHaveBeenCalledWith({ + onCompleted: expect.any(Function), + variables: { + input: params, + }, + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/security/hooks/useCreateSSOIdentityProvider.ts b/packages/twenty-front/src/modules/settings/security/hooks/useCreateSSOIdentityProvider.ts new file mode 100644 index 000000000000..b7dd56f1b13e --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/hooks/useCreateSSOIdentityProvider.ts @@ -0,0 +1,63 @@ +/* @license Enterprise */ + +import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state'; +import { useSetRecoilState } from 'recoil'; +import { + CreateOidcIdentityProviderMutationVariables, + CreateSamlIdentityProviderMutationVariables, + useCreateOidcIdentityProviderMutation, + useCreateSamlIdentityProviderMutation, +} from '~/generated/graphql'; + +export const useCreateSSOIdentityProvider = () => { + const [createOidcIdentityProviderMutation] = + useCreateOidcIdentityProviderMutation(); + const [createSamlIdentityProviderMutation] = + useCreateSamlIdentityProviderMutation(); + + const setSSOIdentitiesProviders = useSetRecoilState( + SSOIdentitiesProvidersState, + ); + + const createSSOIdentityProvider = async ( + input: + | ({ + type: 'OIDC'; + } & CreateOidcIdentityProviderMutationVariables['input']) + | ({ + type: 'SAML'; + } & CreateSamlIdentityProviderMutationVariables['input']), + ) => { + if (input.type === 'OIDC') { + // eslint-disable-next-line unused-imports/no-unused-vars + const { type, ...params } = input; + return await createOidcIdentityProviderMutation({ + variables: { input: params }, + onCompleted: (data) => { + setSSOIdentitiesProviders((existingProvider) => [ + ...existingProvider, + data.createOIDCIdentityProvider, + ]); + }, + }); + } else if (input.type === 'SAML') { + // eslint-disable-next-line unused-imports/no-unused-vars + const { type, ...params } = input; + return await createSamlIdentityProviderMutation({ + variables: { input: params }, + onCompleted: (data) => { + setSSOIdentitiesProviders((existingProvider) => [ + ...existingProvider, + data.createSAMLIdentityProvider, + ]); + }, + }); + } else { + throw new Error('Invalid IdpType'); + } + }; + + return { + createSSOIdentityProvider, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/security/hooks/useDeleteSSOIdentityProvider.ts b/packages/twenty-front/src/modules/settings/security/hooks/useDeleteSSOIdentityProvider.ts new file mode 100644 index 000000000000..a140444631cb --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/hooks/useDeleteSSOIdentityProvider.ts @@ -0,0 +1,40 @@ +/* @license Enterprise */ + +import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state'; +import { useSetRecoilState } from 'recoil'; +import { + DeleteSsoIdentityProviderMutationVariables, + useDeleteSsoIdentityProviderMutation, +} from '~/generated/graphql'; + +export const useDeleteSSOIdentityProvider = () => { + const [deleteSsoIdentityProviderMutation] = + useDeleteSsoIdentityProviderMutation(); + + const setSSOIdentitiesProviders = useSetRecoilState( + SSOIdentitiesProvidersState, + ); + + const deleteSSOIdentityProvider = async ({ + identityProviderId, + }: DeleteSsoIdentityProviderMutationVariables['input']) => { + return await deleteSsoIdentityProviderMutation({ + variables: { + input: { identityProviderId }, + }, + onCompleted: (data) => { + setSSOIdentitiesProviders((SSOIdentitiesProviders) => + SSOIdentitiesProviders.filter( + (identityProvider) => + identityProvider.id !== + data.deleteSSOIdentityProvider.identityProviderId, + ), + ); + }, + }); + }; + + return { + deleteSSOIdentityProvider, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/security/hooks/useUpdateSSOIdentityProvider.ts b/packages/twenty-front/src/modules/settings/security/hooks/useUpdateSSOIdentityProvider.ts new file mode 100644 index 000000000000..07baaaae6a7a --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/hooks/useUpdateSSOIdentityProvider.ts @@ -0,0 +1,40 @@ +/* @license Enterprise */ + +import { SSOIdentitiesProvidersState } from '@/settings/security/states/SSOIdentitiesProviders.state'; +import { useSetRecoilState } from 'recoil'; +import { + EditSsoIdentityProviderMutationVariables, + useEditSsoIdentityProviderMutation, +} from '~/generated/graphql'; + +export const useUpdateSSOIdentityProvider = () => { + const [editSsoIdentityProviderMutation] = + useEditSsoIdentityProviderMutation(); + + const setSSOIdentitiesProviders = useSetRecoilState( + SSOIdentitiesProvidersState, + ); + + const updateSSOIdentityProvider = async ( + payload: EditSsoIdentityProviderMutationVariables['input'], + ) => { + return await editSsoIdentityProviderMutation({ + variables: { + input: payload, + }, + onCompleted: (data) => { + setSSOIdentitiesProviders((SSOIdentitiesProviders) => + SSOIdentitiesProviders.map((identityProvider) => + identityProvider.id === data.editSSOIdentityProvider.id + ? data.editSSOIdentityProvider + : identityProvider, + ), + ); + }, + }); + }; + + return { + updateSSOIdentityProvider, + }; +}; diff --git a/packages/twenty-front/src/modules/settings/security/states/SSOIdentitiesProviders.state.ts b/packages/twenty-front/src/modules/settings/security/states/SSOIdentitiesProviders.state.ts new file mode 100644 index 000000000000..76dc7cfdfbde --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/states/SSOIdentitiesProviders.state.ts @@ -0,0 +1,11 @@ +/* @license Enterprise */ + +import { SSOIdentityProvider } from '@/settings/security/types/SSOIdentityProvider'; +import { createState } from 'twenty-ui'; + +export const SSOIdentitiesProvidersState = createState< + Omit[] +>({ + key: 'SSOIdentitiesProvidersState', + defaultValue: [], +}); diff --git a/packages/twenty-front/src/modules/settings/security/types/SSOIdentityProvider.ts b/packages/twenty-front/src/modules/settings/security/types/SSOIdentityProvider.ts new file mode 100644 index 000000000000..fe7226c9d2c4 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/types/SSOIdentityProvider.ts @@ -0,0 +1,18 @@ +/* @license Enterprise */ + +import { SSOIdentitiesProvidersParamsSchema } from '@/settings/security/validation-schemas/SSOIdentityProviderSchema'; +import { z } from 'zod'; +import { IdpType, SsoIdentityProviderStatus } from '~/generated/graphql'; + +export type SSOIdentityProvider = { + __typename: 'SSOIdentityProvider'; + id: string; + type: IdpType; + issuer: string; + name?: string | null; + status: SsoIdentityProviderStatus; +}; + +export type SettingSecurityNewSSOIdentityFormValues = z.infer< + typeof SSOIdentitiesProvidersParamsSchema +>; diff --git a/packages/twenty-front/src/modules/settings/security/utils/__tests__/parseSAMLMetadataFromXMLFile.test.ts b/packages/twenty-front/src/modules/settings/security/utils/__tests__/parseSAMLMetadataFromXMLFile.test.ts new file mode 100644 index 000000000000..e1d79168e823 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/utils/__tests__/parseSAMLMetadataFromXMLFile.test.ts @@ -0,0 +1,39 @@ +/* @license Enterprise */ + +import { parseSAMLMetadataFromXMLFile } from '../parseSAMLMetadataFromXMLFile'; + +describe('parseSAMLMetadataFromXMLFile', () => { + it('should parse SAML metadata from XML file', () => { + const xmlString = ` + + + + + test + + + + urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress + + + +`; + const result = parseSAMLMetadataFromXMLFile(xmlString); + expect(result).toEqual({ + success: true, + data: { + entityID: 'https://test.com', + ssoUrl: 'https://test.com', + certificate: 'test', + }, + }); + }); + it('should return error if XML is invalid', () => { + const xmlString = 'invalid xml'; + const result = parseSAMLMetadataFromXMLFile(xmlString); + expect(result).toEqual({ + success: false, + error: new Error('Error parsing XML'), + }); + }); +}); diff --git a/packages/twenty-front/src/modules/settings/security/utils/getColorBySSOIdentityProviderStatus.ts b/packages/twenty-front/src/modules/settings/security/utils/getColorBySSOIdentityProviderStatus.ts new file mode 100644 index 000000000000..94fd86f95dd0 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/utils/getColorBySSOIdentityProviderStatus.ts @@ -0,0 +1,13 @@ +/* @license Enterprise */ + +import { ThemeColor } from 'twenty-ui'; +import { SsoIdentityProviderStatus } from '~/generated/graphql'; + +export const getColorBySSOIdentityProviderStatus: Record< + SsoIdentityProviderStatus, + ThemeColor +> = { + Active: 'green', + Inactive: 'gray', + Error: 'red', +}; diff --git a/packages/twenty-front/src/modules/settings/security/utils/guessSSOIdentityProviderIconByUrl.ts b/packages/twenty-front/src/modules/settings/security/utils/guessSSOIdentityProviderIconByUrl.ts new file mode 100644 index 000000000000..f8582577f999 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/utils/guessSSOIdentityProviderIconByUrl.ts @@ -0,0 +1,13 @@ +/* @license Enterprise */ + +import { IconComponent, IconGoogle, IconKey } from 'twenty-ui'; + +export const guessSSOIdentityProviderIconByUrl = ( + url: string, +): IconComponent => { + if (url.includes('google')) { + return IconGoogle; + } + + return IconKey; +}; diff --git a/packages/twenty-front/src/modules/settings/security/utils/parseSAMLMetadataFromXMLFile.ts b/packages/twenty-front/src/modules/settings/security/utils/parseSAMLMetadataFromXMLFile.ts new file mode 100644 index 000000000000..2e4fdf294b2d --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/utils/parseSAMLMetadataFromXMLFile.ts @@ -0,0 +1,59 @@ +/* @license Enterprise */ + +import { z } from 'zod'; + +const validator = z.object({ + entityID: z.string().url(), + ssoUrl: z.string().url(), + certificate: z.string().min(1), +}); + +export const parseSAMLMetadataFromXMLFile = ( + xmlString: string, +): + | { success: true; data: z.infer } + | { success: false; error: unknown } => { + try { + const parser = new DOMParser(); + const xmlDoc = parser.parseFromString(xmlString, 'application/xml'); + + if (xmlDoc.getElementsByTagName('parsererror').length > 0) { + throw new Error('Error parsing XML'); + } + + const entityDescriptor = xmlDoc.getElementsByTagName( + 'md:EntityDescriptor', + )?.[0]; + const idpSSODescriptor = xmlDoc.getElementsByTagName( + 'md:IDPSSODescriptor', + )?.[0]; + const keyDescriptor = xmlDoc.getElementsByTagName('md:KeyDescriptor')[0]; + const keyInfo = keyDescriptor.getElementsByTagName('ds:KeyInfo')[0]; + const x509Data = keyInfo.getElementsByTagName('ds:X509Data')[0]; + const x509Certificate = x509Data + .getElementsByTagName('ds:X509Certificate')?.[0] + .textContent?.trim(); + + const singleSignOnServices = Array.from( + idpSSODescriptor.getElementsByTagName('md:SingleSignOnService'), + ).map((service) => ({ + Binding: service.getAttribute('Binding'), + Location: service.getAttribute('Location'), + })); + + const result = { + ssoUrl: singleSignOnServices.find((singleSignOnService) => { + return ( + singleSignOnService.Binding === + 'urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect' + ); + })?.Location, + certificate: x509Certificate, + entityID: entityDescriptor?.getAttribute('entityID'), + }; + + return { success: true, data: validator.parse(result) }; + } catch (error) { + return { success: false, error }; + } +}; diff --git a/packages/twenty-front/src/modules/settings/security/utils/sSOIdentityProviderDefaultValues.ts b/packages/twenty-front/src/modules/settings/security/utils/sSOIdentityProviderDefaultValues.ts new file mode 100644 index 000000000000..a5358e948b86 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/utils/sSOIdentityProviderDefaultValues.ts @@ -0,0 +1,25 @@ +/* @license Enterprise */ + +import { SettingSecurityNewSSOIdentityFormValues } from '@/settings/security/types/SSOIdentityProvider'; +import { IdpType } from '~/generated/graphql'; + +export const sSOIdentityProviderDefaultValues: Record< + IdpType, + () => SettingSecurityNewSSOIdentityFormValues +> = { + SAML: () => ({ + type: 'SAML', + ssoURL: '', + name: '', + id: crypto.randomUUID(), + certificate: '', + issuer: '', + }), + OIDC: () => ({ + type: 'OIDC', + name: '', + clientID: '', + clientSecret: '', + issuer: '', + }), +}; diff --git a/packages/twenty-front/src/modules/settings/security/validation-schemas/SSOIdentityProviderSchema.ts b/packages/twenty-front/src/modules/settings/security/validation-schemas/SSOIdentityProviderSchema.ts new file mode 100644 index 000000000000..adfd8680f590 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/security/validation-schemas/SSOIdentityProviderSchema.ts @@ -0,0 +1,34 @@ +/* @license Enterprise */ + +import { z } from 'zod'; + +export const SSOIdentitiesProvidersOIDCParamsSchema = z + .object({ + type: z.literal('OIDC'), + clientID: z.string().optional(), + clientSecret: z.string().optional(), + }) + .required(); + +export const SSOIdentitiesProvidersSAMLParamsSchema = z + .object({ + type: z.literal('SAML'), + id: z.string().optional(), + ssoURL: z.string().url().optional(), + certificate: z.string().optional(), + }) + .required(); + +export const SSOIdentitiesProvidersParamsSchema = z + .discriminatedUnion('type', [ + SSOIdentitiesProvidersOIDCParamsSchema, + SSOIdentitiesProvidersSAMLParamsSchema, + ]) + .and( + z + .object({ + name: z.string().min(1), + issuer: z.string().url().optional(), + }) + .required(), + ); diff --git a/packages/twenty-front/src/modules/ui/input/code-editor/components/CodeEditor.tsx b/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditor.tsx similarity index 58% rename from packages/twenty-front/src/modules/ui/input/code-editor/components/CodeEditor.tsx rename to packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditor.tsx index 1240a6d6025e..543c4772e97e 100644 --- a/packages/twenty-front/src/modules/ui/input/code-editor/components/CodeEditor.tsx +++ b/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditor.tsx @@ -1,43 +1,36 @@ -import Editor, { Monaco, EditorProps } from '@monaco-editor/react'; +import { SettingsServerlessFunctionCodeEditorContainer } from '@/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditorContainer'; +import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages'; +import { EditorProps, Monaco } from '@monaco-editor/react'; import dotenv from 'dotenv'; -import { AutoTypings } from 'monaco-editor-auto-typings'; import { editor, MarkerSeverity } from 'monaco-editor'; -import { codeEditorTheme } from '@/ui/input/code-editor/theme/CodeEditorTheme'; -import { useTheme } from '@emotion/react'; -import styled from '@emotion/styled'; -import { useGetAvailablePackages } from '@/settings/serverless-functions/hooks/useGetAvailablePackages'; +import { AutoTypings } from 'monaco-editor-auto-typings'; +import { CodeEditor } from 'twenty-ui'; import { isDefined } from '~/utils/isDefined'; -const StyledEditor = styled(Editor)` - border: 1px solid ${({ theme }) => theme.border.color.medium}; - border-top: none; - border-radius: 0 0 ${({ theme }) => theme.border.radius.sm} - ${({ theme }) => theme.border.radius.sm}; -`; - export type File = { language: string; content: string; path: string; }; -type CodeEditorProps = Omit & { +type SettingsServerlessFunctionCodeEditorProps = Omit< + EditorProps, + 'onChange' +> & { currentFilePath: string; files: File[]; - onChange?: (value: string) => void; - setIsCodeValid?: (isCodeValid: boolean) => void; + onChange: (value: string) => void; + setIsCodeValid: (isCodeValid: boolean) => void; }; -export const CodeEditor = ({ +export const SettingsServerlessFunctionCodeEditor = ({ currentFilePath, files, onChange, setIsCodeValid, height = 450, options = undefined, -}: CodeEditorProps) => { - const theme = useTheme(); - +}: SettingsServerlessFunctionCodeEditorProps) => { const { availablePackages } = useGetAvailablePackages(); const currentFile = files.find((file) => file.path === currentFilePath); @@ -47,9 +40,6 @@ export const CodeEditor = ({ editor: editor.IStandaloneCodeEditor, monaco: Monaco, ) => { - monaco.editor.defineTheme('codeEditorTheme', codeEditorTheme(theme)); - monaco.editor.setTheme('codeEditorTheme'); - if (files.length > 1) { files.forEach((file) => { const model = monaco.editor.getModel(monaco.Uri.file(file.path)); @@ -82,23 +72,25 @@ export const CodeEditor = ({ ); const environmentDefinition = ` - declare namespace NodeJS { - interface ProcessEnv { - ${Object.keys(environmentVariables) - .map((key) => `${key}: string;`) - .join('\n')} + declare namespace NodeJS { + interface ProcessEnv { + ${Object.keys(environmentVariables) + .map((key) => `${key}: string;`) + .join('\n')} + } } - } - - declare const process: { - env: NodeJS.ProcessEnv; - }; - `; - - monaco.languages.typescript.typescriptDefaults.addExtraLib( - environmentDefinition, - 'ts:process-env.d.ts', - ); + + declare const process: { + env: NodeJS.ProcessEnv; + }; + `; + + monaco.languages.typescript.typescriptDefaults.setExtraLibs([ + { + content: environmentDefinition, + filePath: 'ts:process-env.d.ts', + }, + ]); } await AutoTypings.create(editor, { @@ -124,25 +116,17 @@ export const CodeEditor = ({ return ( isDefined(currentFile) && isDefined(availablePackages) && ( - value && onChange?.(value)} - onValidate={handleEditorValidation} - options={{ - ...options, - overviewRulerLanes: 0, - scrollbar: { - vertical: 'hidden', - horizontal: 'hidden', - }, - minimap: { - enabled: false, - }, - }} - /> + + + ) ); }; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditorContainer.tsx b/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditorContainer.tsx new file mode 100644 index 000000000000..4ad8afaee743 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditorContainer.tsx @@ -0,0 +1,11 @@ +import styled from '@emotion/styled'; + +const StyledEditorContainer = styled.div` + border: 1px solid ${({ theme }) => theme.border.color.medium}; + border-top: none; + border-radius: 0 0 ${({ theme }) => theme.border.radius.sm} + ${({ theme }) => theme.border.radius.sm}; +`; + +export const SettingsServerlessFunctionCodeEditorContainer = + StyledEditorContainer; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionNewForm.tsx b/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionNewForm.tsx index 46803ca08534..b50b4834c0f4 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionNewForm.tsx +++ b/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionNewForm.tsx @@ -1,9 +1,8 @@ import { ServerlessFunctionNewFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState'; import { TextArea } from '@/ui/input/components/TextArea'; import { TextInput } from '@/ui/input/components/TextInput'; -import { Section } from '@/ui/layout/section/components/Section'; import styled from '@emotion/styled'; -import { H2Title } from 'twenty-ui'; +import { H2Title, Section } from 'twenty-ui'; const StyledInputsContainer = styled.div` display: flex; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionsTableEmpty.tsx b/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionsTableEmpty.tsx index 4f5744019f25..a67d4d35820c 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionsTableEmpty.tsx +++ b/packages/twenty-front/src/modules/settings/serverless-functions/components/SettingsServerlessFunctionsTableEmpty.tsx @@ -1,16 +1,16 @@ +import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; +import { SettingsPath } from '@/types/SettingsPath'; +import styled from '@emotion/styled'; import { + AnimatedPlaceholder, AnimatedPlaceholderEmptyContainer, AnimatedPlaceholderEmptySubTitle, AnimatedPlaceholderEmptyTextContainer, AnimatedPlaceholderEmptyTitle, + Button, EMPTY_PLACEHOLDER_TRANSITION_PROPS, -} from '@/ui/layout/animated-placeholder/components/EmptyPlaceholderStyled'; -import AnimatedPlaceholder from '@/ui/layout/animated-placeholder/components/AnimatedPlaceholder'; -import { IconPlus } from 'twenty-ui'; -import { Button } from '@/ui/input/button/components/Button'; -import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; -import { SettingsPath } from '@/types/SettingsPath'; -import styled from '@emotion/styled'; + IconPlus, +} from 'twenty-ui'; const StyledEmptyFunctionsContainer = styled.div` height: 60vh; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionCodeEditorTab.tsx b/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionCodeEditorTab.tsx index 5f8886871359..e9b948366808 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionCodeEditorTab.tsx +++ b/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionCodeEditorTab.tsx @@ -1,20 +1,28 @@ +import { + File, + SettingsServerlessFunctionCodeEditor, +} from '@/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditor'; +import { SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/settings/serverless-functions/constants/SettingsServerlessFunctionTabListComponentId'; import { SettingsServerlessFunctionHotkeyScope } from '@/settings/serverless-functions/types/SettingsServerlessFunctionHotKeyScope'; import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; import { SettingsPath } from '@/types/SettingsPath'; -import { Button } from '@/ui/input/button/components/Button'; -import { CodeEditor, File } from '@/ui/input/code-editor/components/CodeEditor'; -import { CoreEditorHeader } from '@/ui/input/code-editor/components/CodeEditorHeader'; -import { Section } from '@/ui/layout/section/components/Section'; import { TabList } from '@/ui/layout/tab/components/TabList'; +import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import styled from '@emotion/styled'; import { useNavigate } from 'react-router-dom'; +import { useRecoilValue } from 'recoil'; import { Key } from 'ts-key-enum'; -import { H2Title, IconGitCommit, IconPlayerPlay, IconRestore } from 'twenty-ui'; +import { + Button, + CoreEditorHeader, + H2Title, + IconGitCommit, + IconPlayerPlay, + IconRestore, + Section, +} from 'twenty-ui'; import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount'; -import { SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID } from '@/settings/serverless-functions/constants/SettingsServerlessFunctionTabListComponentId'; -import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; -import { useRecoilValue } from 'recoil'; const StyledTabList = styled(TabList)` border-bottom: none; @@ -76,10 +84,12 @@ export const SettingsServerlessFunctionCodeEditorTab = ({ const HeaderTabList = ( { - return { id: file.path, title: file.path.split('/').at(-1) || '' }; - })} + tabListInstanceId={SETTINGS_SERVERLESS_FUNCTION_TAB_LIST_COMPONENT_ID} + tabs={files + .filter((file) => file.path !== '.env') + .map((file) => { + return { id: file.path, title: file.path.split('/').at(-1) || '' }; + })} /> ); @@ -107,7 +117,7 @@ export const SettingsServerlessFunctionCodeEditorTab = ({ rightNodes={[ResetButton, PublishButton, TestButton]} /> {activeTabId && ( - onChange(activeTabId, newCodeValue)} diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionMonitoringTab.tsx b/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionMonitoringTab.tsx new file mode 100644 index 000000000000..9aa800298ea1 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionMonitoringTab.tsx @@ -0,0 +1,75 @@ +import { AnalyticsActivityGraph } from '@/analytics/components/AnalyticsActivityGraph'; +import { AnalyticsGraphEffect } from '@/analytics/components/AnalyticsGraphEffect'; +import { AnalyticsGraphDataInstanceContext } from '@/analytics/states/contexts/AnalyticsGraphDataInstanceContext'; +import { SettingsServerlessFunctionHotkeyScope } from '@/settings/serverless-functions/types/SettingsServerlessFunctionHotKeyScope'; +import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; +import { SettingsPath } from '@/types/SettingsPath'; +import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; +import { useNavigate } from 'react-router-dom'; +import { Key } from 'ts-key-enum'; +import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount'; + +export const SettingsServerlessFunctionMonitoringTab = ({ + serverlessFunctionId, +}: { + serverlessFunctionId: string; +}) => { + const navigate = useNavigate(); + + useHotkeyScopeOnMount( + SettingsServerlessFunctionHotkeyScope.ServerlessFunctionSettingsTab, + ); + + useScopedHotkeys( + [Key.Escape], + () => { + navigate(getSettingsPagePath(SettingsPath.ServerlessFunctions)); + }, + SettingsServerlessFunctionHotkeyScope.ServerlessFunctionSettingsTab, + ); + + return ( + <> + + + + + + + + + + + + + + + + ); +}; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionSettingsTab.tsx b/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionSettingsTab.tsx index a59a56384826..efcf01eca165 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionSettingsTab.tsx +++ b/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionSettingsTab.tsx @@ -1,27 +1,28 @@ import { SettingsServerlessFunctionNewForm } from '@/settings/serverless-functions/components/SettingsServerlessFunctionNewForm'; +import { SettingsServerlessFunctionTabEnvironmentVariablesSection } from '@/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTabEnvironmentVariablesSection'; import { useDeleteOneServerlessFunction } from '@/settings/serverless-functions/hooks/useDeleteOneServerlessFunction'; import { ServerlessFunctionFormValues } from '@/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState'; import { SettingsServerlessFunctionHotkeyScope } from '@/settings/serverless-functions/types/SettingsServerlessFunctionHotKeyScope'; import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; import { SettingsPath } from '@/types/SettingsPath'; -import { Button } from '@/ui/input/button/components/Button'; import { ConfirmationModal } from '@/ui/layout/modal/components/ConfirmationModal'; -import { Section } from '@/ui/layout/section/components/Section'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { Key } from 'ts-key-enum'; -import { H2Title } from 'twenty-ui'; +import { Button, H2Title, Section } from 'twenty-ui'; import { useHotkeyScopeOnMount } from '~/hooks/useHotkeyScopeOnMount'; export const SettingsServerlessFunctionSettingsTab = ({ formValues, serverlessFunctionId, onChange, + onCodeChange, }: { formValues: ServerlessFunctionFormValues; serverlessFunctionId: string; onChange: (key: string) => (value: string) => void; + onCodeChange: (filePath: string, value: string) => void; }) => { const navigate = useNavigate(); const [isDeleteFunctionModalOpen, setIsDeleteFunctionModalOpen] = @@ -58,6 +59,10 @@ export const SettingsServerlessFunctionSettingsTab = ({ formValues={formValues} onChange={onChange} /> +
+ ); +}; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTab.tsx b/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTab.tsx index b2d54cbc03f9..863483c3fde4 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTab.tsx +++ b/packages/twenty-front/src/modules/settings/serverless-functions/components/tabs/SettingsServerlessFunctionTestTab.tsx @@ -1,7 +1,14 @@ -import { Section } from '@/ui/layout/section/components/Section'; -import { H2Title, IconPlayerPlay } from 'twenty-ui'; +import { + Button, + CodeEditor, + CoreEditorHeader, + H2Title, + IconPlayerPlay, + Section, +} from 'twenty-ui'; import { LightCopyIconButton } from '@/object-record/record-field/components/LightCopyIconButton'; +import { SettingsServerlessFunctionCodeEditorContainer } from '@/settings/serverless-functions/components/SettingsServerlessFunctionCodeEditorContainer'; import { SettingsServerlessFunctionsOutputMetadataInfo } from '@/settings/serverless-functions/components/SettingsServerlessFunctionsOutputMetadataInfo'; import { settingsServerlessFunctionCodeEditorOutputParamsState } from '@/settings/serverless-functions/states/settingsServerlessFunctionCodeEditorOutputParamsState'; import { settingsServerlessFunctionInputState } from '@/settings/serverless-functions/states/settingsServerlessFunctionInputState'; @@ -9,9 +16,6 @@ import { settingsServerlessFunctionOutputState } from '@/settings/serverless-fun import { SettingsServerlessFunctionHotkeyScope } from '@/settings/serverless-functions/types/SettingsServerlessFunctionHotKeyScope'; import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; import { SettingsPath } from '@/types/SettingsPath'; -import { Button } from '@/ui/input/button/components/Button'; -import { CodeEditor } from '@/ui/input/code-editor/components/CodeEditor'; -import { CoreEditorHeader } from '@/ui/input/code-editor/components/CodeEditorHeader'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; import styled from '@emotion/styled'; import { useNavigate } from 'react-router-dom'; @@ -78,37 +82,30 @@ export const SettingsServerlessFunctionTestTab = ({ />, ]} /> - + + +
]} rightNodes={[]} /> - + + +
diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/graphql/fragments/serverlessFunctionFragment.ts b/packages/twenty-front/src/modules/settings/serverless-functions/graphql/fragments/serverlessFunctionFragment.ts index bbcad2f1c31f..a0b688badb3d 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/graphql/fragments/serverlessFunctionFragment.ts +++ b/packages/twenty-front/src/modules/settings/serverless-functions/graphql/fragments/serverlessFunctionFragment.ts @@ -8,6 +8,8 @@ export const SERVERLESS_FUNCTION_FRAGMENT = gql` runtime syncStatus latestVersion + latestVersionInputSchema + publishedVersions createdAt updatedAt } diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/graphql/mutations/deleteOneServerlessFunction.ts b/packages/twenty-front/src/modules/settings/serverless-functions/graphql/mutations/deleteOneServerlessFunction.ts index 5b2b0325b080..b5b568f7ea85 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/graphql/mutations/deleteOneServerlessFunction.ts +++ b/packages/twenty-front/src/modules/settings/serverless-functions/graphql/mutations/deleteOneServerlessFunction.ts @@ -3,7 +3,7 @@ import { SERVERLESS_FUNCTION_FRAGMENT } from '@/settings/serverless-functions/gr export const DELETE_ONE_SERVERLESS_FUNCTION = gql` ${SERVERLESS_FUNCTION_FRAGMENT} - mutation DeleteOneServerlessFunction($input: DeleteServerlessFunctionInput!) { + mutation DeleteOneServerlessFunction($input: ServerlessFunctionIdInput!) { deleteOneServerlessFunction(input: $input) { ...ServerlessFunctionFields } diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/graphql/queries/findManyServerlessFunctions.ts b/packages/twenty-front/src/modules/settings/serverless-functions/graphql/queries/findManyServerlessFunctions.ts index dfa0ca155893..37aeccc6db68 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/graphql/queries/findManyServerlessFunctions.ts +++ b/packages/twenty-front/src/modules/settings/serverless-functions/graphql/queries/findManyServerlessFunctions.ts @@ -4,12 +4,8 @@ import { SERVERLESS_FUNCTION_FRAGMENT } from '@/settings/serverless-functions/gr export const FIND_MANY_SERVERLESS_FUNCTIONS = gql` ${SERVERLESS_FUNCTION_FRAGMENT} query GetManyServerlessFunctions { - serverlessFunctions(paging: { first: 100 }) { - edges { - node { - ...ServerlessFunctionFields - } - } + findManyServerlessFunctions { + ...ServerlessFunctionFields } } `; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/graphql/queries/findOneServerlessFunction.ts b/packages/twenty-front/src/modules/settings/serverless-functions/graphql/queries/findOneServerlessFunction.ts index f5ed4d3f273d..142c8276315c 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/graphql/queries/findOneServerlessFunction.ts +++ b/packages/twenty-front/src/modules/settings/serverless-functions/graphql/queries/findOneServerlessFunction.ts @@ -3,8 +3,8 @@ import { SERVERLESS_FUNCTION_FRAGMENT } from '@/settings/serverless-functions/gr export const FIND_ONE_SERVERLESS_FUNCTION = gql` ${SERVERLESS_FUNCTION_FRAGMENT} - query GetOneServerlessFunction($id: UUID!) { - serverlessFunction(id: $id) { + query GetOneServerlessFunction($input: ServerlessFunctionIdInput!) { + findOneServerlessFunction(input: $input) { ...ServerlessFunctionFields } } diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useCreateOneServerlessFunction.ts b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useCreateOneServerlessFunction.ts index 427f98466e35..5627feea7640 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useCreateOneServerlessFunction.ts +++ b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useCreateOneServerlessFunction.ts @@ -1,14 +1,14 @@ -import { ApolloClient, useMutation } from '@apollo/client'; +import { useMutation } from '@apollo/client'; import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient'; +import { CREATE_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/createOneServerlessFunction'; +import { FIND_MANY_SERVERLESS_FUNCTIONS } from '@/settings/serverless-functions/graphql/queries/findManyServerlessFunctions'; +import { getOperationName } from '@apollo/client/utilities'; import { - CreateServerlessFunctionInput, CreateOneServerlessFunctionItemMutation, CreateOneServerlessFunctionItemMutationVariables, + CreateServerlessFunctionInput, } from '~/generated-metadata/graphql'; -import { getOperationName } from '@apollo/client/utilities'; -import { FIND_MANY_SERVERLESS_FUNCTIONS } from '@/settings/serverless-functions/graphql/queries/findManyServerlessFunctions'; -import { CREATE_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/createOneServerlessFunction'; export const useCreateOneServerlessFunction = () => { const apolloMetadataClient = useApolloMetadataClient(); @@ -16,7 +16,7 @@ export const useCreateOneServerlessFunction = () => { CreateOneServerlessFunctionItemMutation, CreateOneServerlessFunctionItemMutationVariables >(CREATE_ONE_SERVERLESS_FUNCTION, { - client: apolloMetadataClient ?? ({} as ApolloClient), + client: apolloMetadataClient, }); const createOneServerlessFunction = async ( diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useDeleteOneServerlessFunction.ts b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useDeleteOneServerlessFunction.ts index db1c3f5a68f3..1c3afaf03384 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useDeleteOneServerlessFunction.ts +++ b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useDeleteOneServerlessFunction.ts @@ -1,13 +1,13 @@ import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient'; -import { ApolloClient, useMutation } from '@apollo/client'; -import { getOperationName } from '@apollo/client/utilities'; import { DELETE_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/deleteOneServerlessFunction'; +import { FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE } from '@/settings/serverless-functions/graphql/queries/findOneServerlessFunctionSourceCode'; +import { useMutation } from '@apollo/client'; +import { getOperationName } from '@apollo/client/utilities'; import { - DeleteServerlessFunctionInput, DeleteOneServerlessFunctionMutation, DeleteOneServerlessFunctionMutationVariables, + ServerlessFunctionIdInput, } from '~/generated-metadata/graphql'; -import { FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE } from '@/settings/serverless-functions/graphql/queries/findOneServerlessFunctionSourceCode'; export const useDeleteOneServerlessFunction = () => { const apolloMetadataClient = useApolloMetadataClient(); @@ -15,11 +15,11 @@ export const useDeleteOneServerlessFunction = () => { DeleteOneServerlessFunctionMutation, DeleteOneServerlessFunctionMutationVariables >(DELETE_ONE_SERVERLESS_FUNCTION, { - client: apolloMetadataClient ?? ({} as ApolloClient), + client: apolloMetadataClient, }); const deleteOneServerlessFunction = async ( - input: DeleteServerlessFunctionInput, + input: ServerlessFunctionIdInput, ) => { return await mutate({ variables: { diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useExecuteOneServerlessFunction.ts b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useExecuteOneServerlessFunction.ts index 8825a68d9255..4b69a953e96e 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useExecuteOneServerlessFunction.ts +++ b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useExecuteOneServerlessFunction.ts @@ -1,10 +1,10 @@ import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient'; -import { ApolloClient, useMutation } from '@apollo/client'; import { EXECUTE_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/executeOneServerlessFunction'; +import { useMutation } from '@apollo/client'; import { - ExecuteServerlessFunctionInput, ExecuteOneServerlessFunctionMutation, ExecuteOneServerlessFunctionMutationVariables, + ExecuteServerlessFunctionInput, } from '~/generated-metadata/graphql'; export const useExecuteOneServerlessFunction = () => { @@ -13,7 +13,7 @@ export const useExecuteOneServerlessFunction = () => { ExecuteOneServerlessFunctionMutation, ExecuteOneServerlessFunctionMutationVariables >(EXECUTE_ONE_SERVERLESS_FUNCTION, { - client: apolloMetadataClient ?? ({} as ApolloClient), + client: apolloMetadataClient, }); const executeOneServerlessFunction = async ( diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useGetManyServerlessFunctions.ts b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useGetManyServerlessFunctions.ts index eff46a628ed8..4023745907fc 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useGetManyServerlessFunctions.ts +++ b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useGetManyServerlessFunctions.ts @@ -15,7 +15,6 @@ export const useGetManyServerlessFunctions = () => { client: apolloMetadataClient ?? undefined, }); return { - serverlessFunctions: - data?.serverlessFunctions?.edges.map(({ node }) => node) || [], + serverlessFunctions: data?.findManyServerlessFunctions || [], }; }; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useGetOneServerlessFunction.ts b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useGetOneServerlessFunction.ts index 8d22a217455d..1aeda466d589 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useGetOneServerlessFunction.ts +++ b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useGetOneServerlessFunction.ts @@ -2,11 +2,14 @@ import { useQuery } from '@apollo/client'; import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient'; import { FIND_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/queries/findOneServerlessFunction'; import { + ServerlessFunctionIdInput, GetOneServerlessFunctionQuery, GetOneServerlessFunctionQueryVariables, } from '~/generated-metadata/graphql'; -export const useGetOneServerlessFunction = (id: string) => { +export const useGetOneServerlessFunction = ( + input: ServerlessFunctionIdInput, +) => { const apolloMetadataClient = useApolloMetadataClient(); const { data } = useQuery< GetOneServerlessFunctionQuery, @@ -14,10 +17,10 @@ export const useGetOneServerlessFunction = (id: string) => { >(FIND_ONE_SERVERLESS_FUNCTION, { client: apolloMetadataClient ?? undefined, variables: { - id, + input, }, }); return { - serverlessFunction: data?.serverlessFunction || null, + serverlessFunction: data?.findOneServerlessFunction || null, }; }; diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/usePublishOneServerlessFunction.ts b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/usePublishOneServerlessFunction.ts index 02b8a3e1fece..364eefd75acc 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/usePublishOneServerlessFunction.ts +++ b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/usePublishOneServerlessFunction.ts @@ -1,13 +1,13 @@ -import { ApolloClient, useMutation } from '@apollo/client'; import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient'; import { PUBLISH_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/publishOneServerlessFunction'; +import { FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE } from '@/settings/serverless-functions/graphql/queries/findOneServerlessFunctionSourceCode'; +import { useMutation } from '@apollo/client'; +import { getOperationName } from '@apollo/client/utilities'; import { - PublishServerlessFunctionInput, PublishOneServerlessFunctionMutation, PublishOneServerlessFunctionMutationVariables, + PublishServerlessFunctionInput, } from '~/generated-metadata/graphql'; -import { getOperationName } from '@apollo/client/utilities'; -import { FIND_ONE_SERVERLESS_FUNCTION_SOURCE_CODE } from '@/settings/serverless-functions/graphql/queries/findOneServerlessFunctionSourceCode'; export const usePublishOneServerlessFunction = () => { const apolloMetadataClient = useApolloMetadataClient(); @@ -15,7 +15,7 @@ export const usePublishOneServerlessFunction = () => { PublishOneServerlessFunctionMutation, PublishOneServerlessFunctionMutationVariables >(PUBLISH_ONE_SERVERLESS_FUNCTION, { - client: apolloMetadataClient ?? ({} as ApolloClient), + client: apolloMetadataClient, }); const publishOneServerlessFunction = async ( diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState.ts b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState.ts index 9e8a13483810..2d377ac78d8e 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState.ts +++ b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useServerlessFunctionUpdateFormState.ts @@ -29,8 +29,9 @@ export const useServerlessFunctionUpdateFormState = ( code: undefined, }); - const { serverlessFunction } = - useGetOneServerlessFunction(serverlessFunctionId); + const { serverlessFunction } = useGetOneServerlessFunction({ + id: serverlessFunctionId, + }); const { loading } = useGetOneServerlessFunctionSourceCode({ id: serverlessFunctionId, diff --git a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useUpdateOneServerlessFunction.ts b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useUpdateOneServerlessFunction.ts index d75529ebcc97..61c0a52c4499 100644 --- a/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useUpdateOneServerlessFunction.ts +++ b/packages/twenty-front/src/modules/settings/serverless-functions/hooks/useUpdateOneServerlessFunction.ts @@ -1,13 +1,13 @@ -import { ApolloClient, useMutation } from '@apollo/client'; import { useApolloMetadataClient } from '@/object-metadata/hooks/useApolloMetadataClient'; import { UPDATE_ONE_SERVERLESS_FUNCTION } from '@/settings/serverless-functions/graphql/mutations/updateOneServerlessFunction'; +import { FIND_MANY_SERVERLESS_FUNCTIONS } from '@/settings/serverless-functions/graphql/queries/findManyServerlessFunctions'; +import { useMutation } from '@apollo/client'; +import { getOperationName } from '@apollo/client/utilities'; import { - UpdateServerlessFunctionInput, UpdateOneServerlessFunctionMutation, UpdateOneServerlessFunctionMutationVariables, + UpdateServerlessFunctionInput, } from '~/generated-metadata/graphql'; -import { getOperationName } from '@apollo/client/utilities'; -import { FIND_MANY_SERVERLESS_FUNCTIONS } from '@/settings/serverless-functions/graphql/queries/findManyServerlessFunctions'; export const useUpdateOneServerlessFunction = () => { const apolloMetadataClient = useApolloMetadataClient(); @@ -15,7 +15,7 @@ export const useUpdateOneServerlessFunction = () => { UpdateOneServerlessFunctionMutation, UpdateOneServerlessFunctionMutationVariables >(UPDATE_ONE_SERVERLESS_FUNCTION, { - client: apolloMetadataClient ?? ({} as ApolloClient), + client: apolloMetadataClient, }); const updateOneServerlessFunction = async ( diff --git a/packages/twenty-front/src/modules/settings/workspace/components/ToggleImpersonate.tsx b/packages/twenty-front/src/modules/settings/workspace/components/ToggleImpersonate.tsx index 2b700540dd76..f38d318b375d 100644 --- a/packages/twenty-front/src/modules/settings/workspace/components/ToggleImpersonate.tsx +++ b/packages/twenty-front/src/modules/settings/workspace/components/ToggleImpersonate.tsx @@ -3,7 +3,7 @@ import { useRecoilState } from 'recoil'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { Toggle } from '@/ui/input/components/Toggle'; +import { Toggle } from 'twenty-ui'; import { useUpdateWorkspaceMutation } from '~/generated/graphql'; export const ToggleImpersonate = () => { diff --git a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx index 1ce2f5affe9c..ae4a08c67d15 100644 --- a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx +++ b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockContainer.tsx @@ -1,11 +1,13 @@ import styled from '@emotion/styled'; -import { RecordIndexOptionsDropdown } from '@/object-record/record-index/options/components/RecordIndexOptionsDropdown'; +import { ActionMenuComponentInstanceContext } from '@/action-menu/states/contexts/ActionMenuComponentInstanceContext'; +import { ContextStoreComponentInstanceContext } from '@/context-store/states/contexts/ContextStoreComponentInstanceContext'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; import { RecordTableWithWrappers } from '@/object-record/record-table/components/RecordTableWithWrappers'; import { SignInBackgroundMockContainerEffect } from '@/sign-in-background-mock/components/SignInBackgroundMockContainerEffect'; import { ViewBar } from '@/views/components/ViewBar'; import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; -import { ViewType } from '@/views/types/ViewType'; const StyledContainer = styled.div` display: flex; @@ -20,32 +22,54 @@ export const SignInBackgroundMockContainer = () => { const recordIndexId = 'sign-up-mock-record-table-id'; const viewBarId = 'companies-mock'; + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular, + }); + return ( - - {}} - optionsDropdownButton={ - - } - /> - - {}} - /> - + {}, + indexIdentifierUrl: () => '', + onCreateRecord: () => {}, + }} + > + + + + {}} + optionsDropdownButton={<>} + /> + + {}} + /> + + + + ); }; diff --git a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockPage.tsx b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockPage.tsx index 2b17a655549d..9b36b1541abd 100644 --- a/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockPage.tsx +++ b/packages/twenty-front/src/modules/sign-in-background-mock/components/SignInBackgroundMockPage.tsx @@ -3,11 +3,11 @@ import { IconBuildingSkyscraper } from 'twenty-ui'; import { RecordFieldValueSelectorContextProvider } from '@/object-record/record-store/contexts/RecordFieldValueSelectorContext'; import { SignInBackgroundMockContainer } from '@/sign-in-background-mock/components/SignInBackgroundMockContainer'; -import { PageAddButton } from '@/ui/layout/page/PageAddButton'; -import { PageBody } from '@/ui/layout/page/PageBody'; -import { PageContainer } from '@/ui/layout/page/PageContainer'; -import { PageHeader } from '@/ui/layout/page/PageHeader'; -import { PageHotkeysEffect } from '@/ui/layout/page/PageHotkeysEffect'; +import { PageAddButton } from '@/ui/layout/page/components/PageAddButton'; +import { PageBody } from '@/ui/layout/page/components/PageBody'; +import { PageContainer } from '@/ui/layout/page/components/PageContainer'; +import { PageHeader } from '@/ui/layout/page/components/PageHeader'; +import { PageHotkeysEffect } from '@/ui/layout/page/components/PageHotkeysEffect'; const StyledTableContainer = styled.div` display: flex; diff --git a/packages/twenty-front/src/modules/sign-in-background-mock/constants/SignInBackgroundMockCompanies.ts b/packages/twenty-front/src/modules/sign-in-background-mock/constants/SignInBackgroundMockCompanies.ts index 085e9a829d10..2023b4d0e100 100644 --- a/packages/twenty-front/src/modules/sign-in-background-mock/constants/SignInBackgroundMockCompanies.ts +++ b/packages/twenty-front/src/modules/sign-in-background-mock/constants/SignInBackgroundMockCompanies.ts @@ -88,35 +88,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: null, __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [ - { - __typename: 'ActivityTargetEdge', - node: { - __typename: 'ActivityTarget', - id: '97114d7e-2a80-4401-af58-36c88e13e852', - activityId: '737a6c31-610a-457b-b087-791ac700fa46', - createdAt: '2023-11-24T13:15:03.523Z', - updatedAt: '2023-11-24T13:15:03.523Z', - companyId: '04b2e9f5-0713-40a5-8216-82802401d33e', - personId: null, - }, - }, - { - __typename: 'ActivityTargetEdge', - node: { - __typename: 'ActivityTarget', - id: 'cb29d37a-3d5e-4efb-afa3-38f4bff69912', - activityId: '3c6ea4a3-f71d-4c31-9dfa-f868a5de4091', - createdAt: '2023-11-24T13:14:57.628Z', - updatedAt: '2023-11-24T13:14:57.628Z', - companyId: '04b2e9f5-0713-40a5-8216-82802401d33e', - personId: null, - }, - }, - ], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -339,10 +310,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -489,10 +456,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -554,10 +517,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -755,10 +714,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -820,10 +775,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -885,10 +836,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -950,10 +897,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -1048,10 +991,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -1146,10 +1085,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -1211,10 +1146,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -1276,10 +1207,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, @@ -1426,10 +1353,6 @@ export const SIGN_IN_BACKGROUND_MOCK_COMPANIES = [ secondaryLinks: [], __typename: 'Links', }, - activityTargets: { - __typename: 'ActivityTargetConnection', - edges: [], - }, annualRecurringRevenue: { __typename: 'Currency', amountMicros: null, diff --git a/packages/twenty-front/src/modules/spreadsheet-import/tests/mockRsiValues.ts b/packages/twenty-front/src/modules/spreadsheet-import/__mocks__/mockRsiValues.ts similarity index 100% rename from packages/twenty-front/src/modules/spreadsheet-import/tests/mockRsiValues.ts rename to packages/twenty-front/src/modules/spreadsheet-import/__mocks__/mockRsiValues.ts diff --git a/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelect.tsx b/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelect.tsx index 9908d66a94e9..217ab31d54f6 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelect.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/components/MatchColumnSelect.tsx @@ -1,5 +1,3 @@ -import React, { useCallback, useRef, useState } from 'react'; -import { createPortal } from 'react-dom'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { @@ -9,7 +7,9 @@ import { size, useFloating, } from '@floating-ui/react'; -import { AppTooltip } from 'twenty-ui'; +import React, { useCallback, useRef, useState } from 'react'; +import { createPortal } from 'react-dom'; +import { AppTooltip, MenuItem, MenuItemSelect } from 'twenty-ui'; import { ReadonlyDeep } from 'type-fest'; import { useDebouncedCallback } from 'use-debounce'; @@ -18,8 +18,6 @@ import { DropdownMenu } from '@/ui/layout/dropdown/components/DropdownMenu'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuSearchInput } from '@/ui/layout/dropdown/components/DropdownMenuSearchInput'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; -import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; -import { MenuItemSelect } from '@/ui/navigation/menu-item/components/MenuItemSelect'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { useUpdateEffect } from '~/hooks/useUpdateEffect'; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/components/ModalCloseButton.tsx b/packages/twenty-front/src/modules/spreadsheet-import/components/ModalCloseButton.tsx index 5c03dc535678..a04d344a9d78 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/components/ModalCloseButton.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/components/ModalCloseButton.tsx @@ -1,10 +1,9 @@ import styled from '@emotion/styled'; -import { IconX } from 'twenty-ui'; +import { IconButton, IconX } from 'twenty-ui'; import { useSpreadsheetImportInitialStep } from '@/spreadsheet-import/hooks/useSpreadsheetImportInitialStep'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; import { useDialogManager } from '@/ui/feedback/dialog-manager/hooks/useDialogManager'; -import { IconButton } from '@/ui/input/button/components/IconButton'; import { useStepBar } from '@/ui/navigation/step-bar/hooks/useStepBar'; const StyledCloseButtonContainer = styled.div` diff --git a/packages/twenty-front/src/modules/spreadsheet-import/components/StepNavigationButton.tsx b/packages/twenty-front/src/modules/spreadsheet-import/components/StepNavigationButton.tsx index 6462fcd8c666..9064cf9bfccb 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/components/StepNavigationButton.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/components/StepNavigationButton.tsx @@ -1,7 +1,6 @@ import styled from '@emotion/styled'; -import { CircularProgressBar } from '@/ui/feedback/progress-bar/components/CircularProgressBar'; -import { MainButton } from '@/ui/input/button/components/MainButton'; +import { CircularProgressBar, MainButton } from 'twenty-ui'; import { Modal } from '@/ui/layout/modal/components/Modal'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImport.test.tsx b/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImport.test.tsx index dc1d67124a72..bec306a77ddb 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImport.test.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/hooks/__tests__/useSpreadsheetImport.test.tsx @@ -1,4 +1,4 @@ -import { act, renderHook } from '@testing-library/react'; +import { renderHook } from '@testing-library/react'; import { RecoilRoot, useRecoilState } from 'recoil'; import { useOpenSpreadsheetImportDialog } from '@/spreadsheet-import/hooks/useOpenSpreadsheetImportDialog'; @@ -8,6 +8,7 @@ import { ImportedRow, SpreadsheetImportDialogOptions, } from '@/spreadsheet-import/types'; +import { act } from 'react'; const Wrapper = ({ children }: { children: React.ReactNode }) => ( {children} diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/components/SelectColumn.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/components/SelectColumn.tsx index da8761b370f2..59e8d9e6fbf6 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/components/SelectColumn.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectHeaderStep/components/SelectColumn.tsx @@ -2,7 +2,7 @@ import { Column, FormatterProps, useRowSelection } from 'react-data-grid'; import { ImportedRow } from '@/spreadsheet-import/types'; -import { Radio } from '@/ui/input/components/Radio'; +import { Radio } from 'twenty-ui'; const SELECT_COLUMN_KEY = 'select-row'; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectSheetStep/SelectSheetStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectSheetStep/SelectSheetStep.tsx index a6d5165f3cad..7b9c635c0d23 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectSheetStep/SelectSheetStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SelectSheetStep/SelectSheetStep.tsx @@ -8,8 +8,7 @@ import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/Spreadsh import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; import { exceedsMaxRecords } from '@/spreadsheet-import/utils/exceedsMaxRecords'; import { mapWorkbook } from '@/spreadsheet-import/utils/mapWorkbook'; -import { Radio } from '@/ui/input/components/Radio'; -import { RadioGroup } from '@/ui/input/components/RadioGroup'; +import { Radio, RadioGroup } from 'twenty-ui'; import { Modal } from '@/ui/layout/modal/components/Modal'; import { WorkBook } from 'xlsx-ugnis'; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepper.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepper.tsx index c3bdf3a5d9d9..e8c3d52df053 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepper.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/SpreadsheetImportStepper.tsx @@ -3,10 +3,10 @@ import styled from '@emotion/styled'; import { useCallback, useState } from 'react'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; -import { CircularProgressBar } from '@/ui/feedback/progress-bar/components/CircularProgressBar'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; import { Modal } from '@/ui/layout/modal/components/Modal'; +import { CircularProgressBar } from 'twenty-ui'; import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep'; import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/DropZone.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/DropZone.tsx index 534deb1e0b8a..e0d7ac6a2255 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/DropZone.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/UploadStep/components/DropZone.tsx @@ -7,7 +7,7 @@ import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpre import { readFileAsync } from '@/spreadsheet-import/utils/readFilesAsync'; import { SnackBarVariant } from '@/ui/feedback/snack-bar-manager/components/SnackBar'; import { useSnackBar } from '@/ui/feedback/snack-bar-manager/hooks/useSnackBar'; -import { MainButton } from '@/ui/input/button/components/MainButton'; +import { MainButton } from 'twenty-ui'; const StyledContainer = styled.div` align-items: center; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx index 21b6d034923c..12e89fd55021 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/ValidationStep.tsx @@ -1,36 +1,32 @@ -import styled from '@emotion/styled'; -import { - Dispatch, - SetStateAction, - useCallback, - useMemo, - useState, -} from 'react'; -// @ts-expect-error Todo: remove usage of react-data-grid` -import { RowsChangeData } from 'react-data-grid'; -import { IconTrash } from 'twenty-ui'; - import { Heading } from '@/spreadsheet-import/components/Heading'; import { SpreadsheetImportTable } from '@/spreadsheet-import/components/SpreadsheetImportTable'; import { StepNavigationButton } from '@/spreadsheet-import/components/StepNavigationButton'; import { useSpreadsheetImportInternal } from '@/spreadsheet-import/hooks/useSpreadsheetImportInternal'; import { - Columns, ColumnType, + Columns, } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep'; +import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep'; +import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; import { - ImportedStructuredRow, ImportValidationResult, + ImportedStructuredRow, } from '@/spreadsheet-import/types'; import { addErrorsAndRunHooks } from '@/spreadsheet-import/utils/dataMutations'; import { useDialogManager } from '@/ui/feedback/dialog-manager/hooks/useDialogManager'; -import { Button } from '@/ui/input/button/components/Button'; -import { Toggle } from '@/ui/input/components/Toggle'; -import { isDefined } from '~/utils/isDefined'; - -import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep'; -import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; import { Modal } from '@/ui/layout/modal/components/Modal'; +import styled from '@emotion/styled'; +import { + Dispatch, + SetStateAction, + useCallback, + useMemo, + useState, +} from 'react'; +// @ts-expect-error Todo: remove usage of react-data-grid` +import { RowsChangeData } from 'react-data-grid'; +import { Button, IconTrash, Toggle } from 'twenty-ui'; +import { isDefined } from '~/utils/isDefined'; import { generateColumns } from './components/columns'; import { ImportedStructuredRowMetadata } from './types'; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/components/columns.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/components/columns.tsx index f2aa7983f410..cc2f45072523 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/components/columns.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/ValidationStep/components/columns.tsx @@ -2,13 +2,11 @@ import styled from '@emotion/styled'; // @ts-expect-error // Todo: remove usage of react-data-grid import { Column, useRowSelection } from 'react-data-grid'; import { createPortal } from 'react-dom'; -import { AppTooltip } from 'twenty-ui'; +import { AppTooltip, Checkbox, CheckboxVariant, Toggle } from 'twenty-ui'; import { MatchColumnSelect } from '@/spreadsheet-import/components/MatchColumnSelect'; import { Fields, ImportedStructuredRow } from '@/spreadsheet-import/types'; -import { Checkbox, CheckboxVariant } from '@/ui/input/components/Checkbox'; import { TextInput } from '@/ui/input/components/TextInput'; -import { Toggle } from '@/ui/input/components/Toggle'; import { isDefined } from '~/utils/isDefined'; import { ImportedStructuredRowMetadata } from '../types'; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/MatchColumns.stories.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/MatchColumns.stories.tsx index 47cb4e54d0b8..ac1a4da5acb8 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/MatchColumns.stories.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/MatchColumns.stories.tsx @@ -1,10 +1,10 @@ import { Meta } from '@storybook/react'; +import { mockRsiValues } from '@/spreadsheet-import/__mocks__/mockRsiValues'; import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper'; import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider'; import { MatchColumnsStep } from '@/spreadsheet-import/steps/components/MatchColumnsStep/MatchColumnsStep'; import { SpreadsheetImportStep } from '@/spreadsheet-import/steps/types/SpreadsheetImportStep'; -import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues'; import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectHeader.stories.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectHeader.stories.tsx index a87f0ce4226c..ad33171faa3f 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectHeader.stories.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectHeader.stories.tsx @@ -1,13 +1,13 @@ import { Meta } from '@storybook/react'; +import { + headerSelectionTableFields, + mockRsiValues, +} from '@/spreadsheet-import/__mocks__/mockRsiValues'; import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper'; import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider'; import { SelectHeaderStep } from '@/spreadsheet-import/steps/components/SelectHeaderStep/SelectHeaderStep'; import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; -import { - headerSelectionTableFields, - mockRsiValues, -} from '@/spreadsheet-import/tests/mockRsiValues'; import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope'; const meta: Meta = { diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectSheet.stories.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectSheet.stories.tsx index e48542f1a089..57b5162793cb 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectSheet.stories.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/SelectSheet.stories.tsx @@ -1,10 +1,10 @@ import { Meta } from '@storybook/react'; +import { mockRsiValues } from '@/spreadsheet-import/__mocks__/mockRsiValues'; import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper'; import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider'; import { SelectSheetStep } from '@/spreadsheet-import/steps/components/SelectSheetStep/SelectSheetStep'; import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; -import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues'; import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope'; const meta: Meta = { diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Upload.stories.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Upload.stories.tsx index 7f2b295fb545..0757b7e619d4 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Upload.stories.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Upload.stories.tsx @@ -1,10 +1,10 @@ import { Meta } from '@storybook/react'; +import { mockRsiValues } from '@/spreadsheet-import/__mocks__/mockRsiValues'; import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper'; import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider'; import { UploadStep } from '@/spreadsheet-import/steps/components/UploadStep/UploadStep'; import { SpreadsheetImportStepType } from '@/spreadsheet-import/steps/types/SpreadsheetImportStepType'; -import { mockRsiValues } from '@/spreadsheet-import/tests/mockRsiValues'; import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; diff --git a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Validation.stories.tsx b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Validation.stories.tsx index 9126371d1dc2..58894c817ce0 100644 --- a/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Validation.stories.tsx +++ b/packages/twenty-front/src/modules/spreadsheet-import/steps/components/__stories__/Validation.stories.tsx @@ -1,13 +1,13 @@ import { Meta } from '@storybook/react'; -import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper'; -import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider'; -import { ValidationStep } from '@/spreadsheet-import/steps/components/ValidationStep/ValidationStep'; import { editableTableInitialData, importedColums, mockRsiValues, -} from '@/spreadsheet-import/tests/mockRsiValues'; +} from '@/spreadsheet-import/__mocks__/mockRsiValues'; +import { ModalWrapper } from '@/spreadsheet-import/components/ModalWrapper'; +import { ReactSpreadsheetImportContextProvider } from '@/spreadsheet-import/components/ReactSpreadsheetImportContextProvider'; +import { ValidationStep } from '@/spreadsheet-import/steps/components/ValidationStep/ValidationStep'; import { DialogManagerScope } from '@/ui/feedback/dialog-manager/scopes/DialogManagerScope'; const meta: Meta = { diff --git a/packages/twenty-front/src/modules/support/components/SupportButton.tsx b/packages/twenty-front/src/modules/support/components/SupportButton.tsx index 9229232b6c4e..2c38dccdcf3c 100644 --- a/packages/twenty-front/src/modules/support/components/SupportButton.tsx +++ b/packages/twenty-front/src/modules/support/components/SupportButton.tsx @@ -1,9 +1,10 @@ import styled from '@emotion/styled'; -import { IconHelpCircle } from 'twenty-ui'; +import { Button, IconHelpCircle, LightIconButton } from 'twenty-ui'; import { SupportButtonSkeletonLoader } from '@/support/components/SupportButtonSkeletonLoader'; import { useSupportChat } from '@/support/hooks/useSupportChat'; -import { Button } from '@/ui/input/button/components/Button'; +import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded'; +import { useRecoilValue } from 'recoil'; const StyledButtonContainer = styled.div` display: flex; @@ -12,11 +13,18 @@ const StyledButtonContainer = styled.div` export const SupportButton = () => { const { loading, isFrontChatLoaded } = useSupportChat(); + const isNavigationDrawerExpanded = useRecoilValue( + isNavigationDrawerExpandedState, + ); if (loading) { return ; } - return isFrontChatLoaded ? ( + if (!isFrontChatLoaded) { + return; + } + + return isNavigationDrawerExpanded ? ( - - )} - - ); -}; diff --git a/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSubContainer.tsx b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSubContainer.tsx new file mode 100644 index 000000000000..beb0961bb10a --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/show-page/components/ShowPageSubContainer.tsx @@ -0,0 +1,152 @@ +import { RecordShowRightDrawerActionMenu } from '@/action-menu/components/RecordShowRightDrawerActionMenu'; +import { ActivityTargetableObject } from '@/activities/types/ActivityTargetableEntity'; +import { isNewViewableRecordLoadingState } from '@/object-record/record-right-drawer/states/isNewViewableRecordLoading'; +import { CardComponents } from '@/object-record/record-show/components/CardComponents'; +import { FieldsCard } from '@/object-record/record-show/components/FieldsCard'; +import { SummaryCard } from '@/object-record/record-show/components/SummaryCard'; +import { recordStoreFamilyState } from '@/object-record/record-store/states/recordStoreFamilyState'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { ShowPageLeftContainer } from '@/ui/layout/show-page/components/ShowPageLeftContainer'; +import { SingleTabProps, TabList } from '@/ui/layout/tab/components/TabList'; +import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; +import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; +import styled from '@emotion/styled'; +import { useRecoilState, useRecoilValue } from 'recoil'; + +const StyledShowPageRightContainer = styled.div<{ isMobile: boolean }>` + display: flex; + flex-direction: column; + height: 100%; + justify-content: start; + width: 100%; + height: 100%; + overflow: auto; +`; + +const StyledTabListContainer = styled.div` + align-items: center; + padding-left: ${({ theme }) => theme.spacing(2)}; + border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`}; + box-sizing: border-box; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; + height: 40px; +`; + +const StyledButtonContainer = styled.div` + align-items: center; + background: ${({ theme }) => theme.background.secondary}; + border-top: 1px solid ${({ theme }) => theme.border.color.light}; + bottom: 0; + box-sizing: border-box; + display: flex; + justify-content: flex-end; + padding: ${({ theme }) => theme.spacing(2)}; + position: absolute; + width: 100%; +`; + +const StyledContentContainer = styled.div<{ isInRightDrawer: boolean }>` + flex: 1; + overflow-y: auto; + padding-bottom: ${({ theme, isInRightDrawer }) => + isInRightDrawer ? theme.spacing(16) : 0}; +`; + +export const TAB_LIST_COMPONENT_ID = 'show-page-right-tab-list'; + +type ShowPageSubContainerProps = { + tabs: SingleTabProps[]; + targetableObject: Pick< + ActivityTargetableObject, + 'targetObjectNameSingular' | 'id' + >; + isInRightDrawer?: boolean; + loading: boolean; + isNewRightDrawerItemLoading?: boolean; +}; + +export const ShowPageSubContainer = ({ + tabs, + targetableObject, + loading, + isInRightDrawer = false, + isNewRightDrawerItemLoading = false, +}: ShowPageSubContainerProps) => { + const { activeTabIdState } = useTabList( + `${TAB_LIST_COMPONENT_ID}-${isInRightDrawer}`, + ); + const activeTabId = useRecoilValue(activeTabIdState); + + const isMobile = useIsMobile(); + + const isNewViewableRecordLoading = useRecoilValue( + isNewViewableRecordLoadingState, + ); + + const summaryCard = ( + + ); + + const fieldsCard = ( + + ); + + const renderActiveTabContent = () => { + const activeTab = tabs.find((tab) => tab.id === activeTabId); + if (!activeTab?.cards?.length) return null; + + return activeTab.cards.map((card, index) => { + const CardComponent = CardComponents[card.type]; + return CardComponent ? ( + + ) : null; + }); + }; + + const [recordFromStore] = useRecoilState( + recordStoreFamilyState(targetableObject.id), + ); + + return ( + <> + {!isMobile && !isInRightDrawer && ( + + {summaryCard} + {fieldsCard} + + )} + + + + + {(isMobile || isInRightDrawer) && summaryCard} + + {renderActiveTabContent()} + + {isInRightDrawer && recordFromStore && !recordFromStore.deletedAt && ( + + + + )} + + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/layout/tab/components/Tab.tsx b/packages/twenty-front/src/modules/ui/layout/tab/components/Tab.tsx index e723f0ae84ad..987eea1706bf 100644 --- a/packages/twenty-front/src/modules/ui/layout/tab/components/Tab.tsx +++ b/packages/twenty-front/src/modules/ui/layout/tab/components/Tab.tsx @@ -1,5 +1,7 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; +import { ReactElement } from 'react'; +import { Link } from 'react-router-dom'; import { IconComponent, Pill } from 'twenty-ui'; type TabProps = { @@ -10,10 +12,15 @@ type TabProps = { className?: string; onClick?: () => void; disabled?: boolean; - pill?: string; + pill?: string | ReactElement; + to?: string; }; -const StyledTab = styled.div<{ active?: boolean; disabled?: boolean }>` +const StyledTab = styled.button<{ + active?: boolean; + disabled?: boolean; + to?: string; +}>` align-items: center; border-bottom: 1px solid ${({ theme }) => theme.border.color.light}; border-color: ${({ theme, active }) => @@ -25,6 +32,10 @@ const StyledTab = styled.div<{ active?: boolean; disabled?: boolean }>` ? theme.font.color.light : theme.font.color.secondary}; cursor: pointer; + background-color: transparent; + border-left: none; + border-right: none; + border-top: none; display: flex; gap: ${({ theme }) => theme.spacing(1)}; @@ -32,6 +43,7 @@ const StyledTab = styled.div<{ active?: boolean; disabled?: boolean }>` margin-bottom: 0; padding: ${({ theme }) => theme.spacing(2) + ' 0'}; pointer-events: ${({ disabled }) => (disabled ? 'none' : '')}; + text-decoration: none; `; const StyledHover = styled.span` @@ -60,6 +72,7 @@ export const Tab = ({ className, disabled, pill, + to, }: TabProps) => { const theme = useTheme(); return ( @@ -69,11 +82,13 @@ export const Tab = ({ className={className} disabled={disabled} data-testid={'tab-' + id} + as={to ? Link : 'button'} + to={to} > {Icon && } {title} - {pill && } + {pill && typeof pill === 'string' ? : pill} ); diff --git a/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx b/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx index 349c9cfe3edd..7d724d67e918 100644 --- a/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx +++ b/packages/twenty-front/src/modules/ui/layout/tab/components/TabList.tsx @@ -7,22 +7,26 @@ import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; import { TabListScope } from '@/ui/layout/tab/scopes/TabListScope'; import { ScrollWrapper } from '@/ui/utilities/scroll/components/ScrollWrapper'; +import { TabListFromUrlOptionalEffect } from '@/ui/layout/tab/components/TabListFromUrlOptionalEffect'; +import { LayoutCard } from '@/ui/layout/tab/types/LayoutCard'; import { Tab } from './Tab'; -type SingleTabProps = { +export type SingleTabProps = { title: string; Icon?: IconComponent; id: string; hide?: boolean; disabled?: boolean; - pill?: string; + pill?: string | React.ReactElement; + cards?: LayoutCard[]; }; type TabListProps = { - tabListId: string; + tabListInstanceId: string; tabs: SingleTabProps[]; loading?: boolean; className?: string; + behaveAsLinks?: boolean; }; const StyledContainer = styled.div` @@ -31,19 +35,19 @@ const StyledContainer = styled.div` display: flex; gap: ${({ theme }) => theme.spacing(2)}; height: 40px; - padding-left: ${({ theme }) => theme.spacing(2)}; user-select: none; `; export const TabList = ({ tabs, - tabListId, + tabListInstanceId, loading, className, + behaveAsLinks = true, }: TabListProps) => { const initialActiveTabId = tabs.find((tab) => !tab.hide)?.id || ''; - const { activeTabIdState, setActiveTabId } = useTabList(tabListId); + const { activeTabIdState, setActiveTabId } = useTabList(tabListInstanceId); const activeTabId = useRecoilValue(activeTabIdState); @@ -52,8 +56,12 @@ export const TabList = ({ }, [initialActiveTabId, setActiveTabId]); return ( - - + + tab.id)} + /> + {tabs .filter((tab) => !tab.hide) @@ -64,11 +72,14 @@ export const TabList = ({ title={tab.title} Icon={tab.Icon} active={tab.id === activeTabId} - onClick={() => { - setActiveTabId(tab.id); - }} disabled={tab.disabled ?? loading} pill={tab.pill} + to={behaveAsLinks ? `#${tab.id}` : undefined} + onClick={() => { + if (!behaveAsLinks) { + setActiveTabId(tab.id); + } + }} /> ))} diff --git a/packages/twenty-front/src/modules/ui/layout/tab/components/TabListFromUrlOptionalEffect.tsx b/packages/twenty-front/src/modules/ui/layout/tab/components/TabListFromUrlOptionalEffect.tsx new file mode 100644 index 000000000000..8abbe61b70f7 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/tab/components/TabListFromUrlOptionalEffect.tsx @@ -0,0 +1,33 @@ +import { useTabList } from '@/ui/layout/tab/hooks/useTabList'; +import { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import { useRecoilValue } from 'recoil'; + +type TabListFromUrlOptionalEffectProps = { + componentInstanceId: string; + tabListIds: string[]; +}; + +export const TabListFromUrlOptionalEffect = ({ + componentInstanceId, + tabListIds, +}: TabListFromUrlOptionalEffectProps) => { + const location = useLocation(); + const { activeTabIdState } = useTabList(componentInstanceId); + const { setActiveTabId } = useTabList(componentInstanceId); + + const hash = location.hash.replace('#', ''); + const activeTabId = useRecoilValue(activeTabIdState); + + useEffect(() => { + if (hash === activeTabId) { + return; + } + + if (tabListIds.includes(hash)) { + setActiveTabId(hash); + } + }, [hash, activeTabId, setActiveTabId, tabListIds]); + + return <>; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/tab/components/__stories__/Tablist.stories.tsx b/packages/twenty-front/src/modules/ui/layout/tab/components/__stories__/Tablist.stories.tsx index 7c4c63256e31..d2c2cdf4781c 100644 --- a/packages/twenty-front/src/modules/ui/layout/tab/components/__stories__/Tablist.stories.tsx +++ b/packages/twenty-front/src/modules/ui/layout/tab/components/__stories__/Tablist.stories.tsx @@ -1,6 +1,6 @@ import { Meta, StoryObj } from '@storybook/react'; import { expect, within } from '@storybook/test'; -import { ComponentDecorator, IconCheckbox } from 'twenty-ui'; +import { ComponentWithRouterDecorator, IconCheckbox } from 'twenty-ui'; import { RecoilScope } from '@/ui/utilities/recoil-scope/components/RecoilScope'; @@ -39,7 +39,7 @@ const meta: Meta = { title: 'UI/Layout/Tab/TabList', component: TabList, args: { - tabListId: 'tab-list-id', + tabListInstanceId: 'tab-list-id', tabs: tabs, }, decorators: [ @@ -48,7 +48,7 @@ const meta: Meta = { ), - ComponentDecorator, + ComponentWithRouterDecorator, ], }; diff --git a/packages/twenty-front/src/modules/ui/layout/tab/types/LayoutCard.ts b/packages/twenty-front/src/modules/ui/layout/tab/types/LayoutCard.ts new file mode 100644 index 000000000000..d37120cefb60 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/tab/types/LayoutCard.ts @@ -0,0 +1,5 @@ +import { CardType } from '@/object-record/record-show/types/CardType'; + +export type LayoutCard = { + type: CardType; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/tab/types/RecordLayoutTab.ts b/packages/twenty-front/src/modules/ui/layout/tab/types/RecordLayoutTab.ts new file mode 100644 index 000000000000..efdf02ae2121 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/tab/types/RecordLayoutTab.ts @@ -0,0 +1,11 @@ +import { LayoutCard } from '@/ui/layout/tab/types/LayoutCard'; +import { TabVisibilityConfig } from '@/ui/layout/tab/types/TabVisibilityConfig'; +import { IconComponent } from 'twenty-ui'; + +export type RecordLayoutTab = { + title: string; + position: number; + Icon: IconComponent; + hide: TabVisibilityConfig; + cards: LayoutCard[]; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/tab/types/TabVisibilityConfig.ts b/packages/twenty-front/src/modules/ui/layout/tab/types/TabVisibilityConfig.ts new file mode 100644 index 000000000000..ddca84235551 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/layout/tab/types/TabVisibilityConfig.ts @@ -0,0 +1,11 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { FeatureFlagKey } from '@/workspace/types/FeatureFlagKey'; + +export type TabVisibilityConfig = { + ifMobile: boolean; + ifDesktop: boolean; + ifInRightDrawer: boolean; + ifFeaturesDisabled: FeatureFlagKey[]; + ifRequiredObjectsInactive: CoreObjectNameSingular[]; + ifRelationsMissing: string[]; +}; diff --git a/packages/twenty-front/src/modules/ui/layout/top-bar/TopBar.tsx b/packages/twenty-front/src/modules/ui/layout/top-bar/components/TopBar.tsx similarity index 80% rename from packages/twenty-front/src/modules/ui/layout/top-bar/TopBar.tsx rename to packages/twenty-front/src/modules/ui/layout/top-bar/components/TopBar.tsx index f19e3efaf685..004236cc337f 100644 --- a/packages/twenty-front/src/modules/ui/layout/top-bar/TopBar.tsx +++ b/packages/twenty-front/src/modules/ui/layout/top-bar/components/TopBar.tsx @@ -1,5 +1,5 @@ -import { ReactNode } from 'react'; import styled from '@emotion/styled'; +import { ReactNode } from 'react'; type TopBarProps = { className?: string; @@ -10,14 +10,16 @@ type TopBarProps = { }; const StyledContainer = styled.div` + border-bottom: ${({ theme }) => `1px solid ${theme.border.color.light}`}; display: flex; + margin-left: ${({ theme }) => theme.spacing(2)}; + flex-direction: column; `; -const StyledTopBar = styled.div<{ displayBottomBorder: boolean }>` +const StyledTopBar = styled.div` align-items: center; - border-bottom: ${({ displayBottomBorder, theme }) => - displayBottomBorder ? `1px solid ${theme.border.color.light}` : 'none'}; + box-sizing: border-box; color: ${({ theme }) => theme.font.color.secondary}; display: flex; @@ -26,6 +28,7 @@ const StyledTopBar = styled.div<{ displayBottomBorder: boolean }>` height: 39px; justify-content: space-between; padding-right: ${({ theme }) => theme.spacing(2)}; + z-index: 7; `; @@ -44,10 +47,9 @@ export const TopBar = ({ leftComponent, rightComponent, bottomComponent, - displayBottomBorder = true, }: TopBarProps) => ( - + {leftComponent} {rightComponent} diff --git a/packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/Breadcrumb.tsx b/packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/Breadcrumb.tsx index a43ee3175fc2..7440c42c6bda 100644 --- a/packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/Breadcrumb.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/Breadcrumb.tsx @@ -1,3 +1,5 @@ +import { MobileBreadcrumb } from '@/ui/navigation/bread-crumb/components/MobileBreadcrumb'; +import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import styled from '@emotion/styled'; import { Fragment, ReactNode } from 'react'; import { Link } from 'react-router-dom'; @@ -39,6 +41,12 @@ const StyledDivider = styled.span` `; export const Breadcrumb = ({ className, links }: BreadcrumbProps) => { + const isMobile = useIsMobile(); + + if (isMobile && links.length > 0) { + return ; + } + return ( {links.map((link, index) => { diff --git a/packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx b/packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx new file mode 100644 index 000000000000..5df3d09cacb0 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/MobileBreadcrumb.tsx @@ -0,0 +1,81 @@ +import { useOpenSettingsMenu } from '@/navigation/hooks/useOpenSettings'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { isNonEmptyString } from '@sniptt/guards'; +import { ReactNode } from 'react'; +import { Link } from 'react-router-dom'; +import { IconChevronLeft } from 'twenty-ui'; + +export type MobileBreadcrumbProps = { + className?: string; + links: { children: string | ReactNode; href?: string }[]; +}; + +const StyledWrapper = styled.nav` + align-items: center; + color: ${({ theme }) => theme.font.color.tertiary}; + display: grid; + font-size: ${({ theme }) => theme.font.size.md}; + grid-auto-flow: column; + grid-column-gap: ${({ theme }) => theme.spacing(1)}; + max-width: 100%; + min-width: 0; + height: ${({ theme }) => theme.spacing(8)}; +`; + +const StyledLink = styled(Link)` + color: inherit; + text-decoration: none; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const StyledText = styled.span` + color: inherit; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +export const MobileBreadcrumb = ({ + className, + links, +}: MobileBreadcrumbProps) => { + const theme = useTheme(); + + const { openSettingsMenu } = useOpenSettingsMenu(); + + const handleBackToSettingsClick = () => { + openSettingsMenu(); + }; + + const previousLink = links[links.length - 2]; + const shouldRedirectToSettings = links.length === 2; + + const text = isNonEmptyString(previousLink.children) + ? previousLink.children + : ''; + + return ( + + {shouldRedirectToSettings ? ( + <> + + + Back to Settings + + + ) : previousLink?.href ? ( + <> + + + Back to {previousLink.children} + + + ) : ( + {previousLink?.children} + )} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/__stories__/Breadcrumb.stories.tsx b/packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/__stories__/Breadcrumb.stories.tsx index a655fc6b6fd3..cdc389a0a426 100644 --- a/packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/__stories__/Breadcrumb.stories.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/bread-crumb/components/__stories__/Breadcrumb.stories.tsx @@ -1,10 +1,9 @@ import { Meta, StoryObj } from '@storybook/react'; import { ComponentDecorator } from 'twenty-ui'; +import { Breadcrumb } from '../Breadcrumb'; import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator'; -import { Breadcrumb } from '../Breadcrumb'; - const meta: Meta = { title: 'UI/Navigation/Breadcrumb/Breadcrumb', component: Breadcrumb, diff --git a/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemWithOptionDropdown.tsx b/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemWithOptionDropdown.tsx new file mode 100644 index 000000000000..d1196392f170 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/menu-item/components/MenuItemWithOptionDropdown.tsx @@ -0,0 +1,102 @@ +import { SelectHotkeyScope } from '@/ui/input/types/SelectHotkeyScope'; +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; +import { useTheme } from '@emotion/react'; +import { FunctionComponent, MouseEvent, ReactElement, ReactNode } from 'react'; +import { + IconChevronRight, + IconComponent, + IconDotsVertical, + LightIconButton, + LightIconButtonProps, + MenuItemAccent, + MenuItemLeftContent, + StyledHoverableMenuItemBase, + StyledMenuItemLeftContent, +} from 'twenty-ui'; + +export type MenuItemIconButton = { + Wrapper?: FunctionComponent<{ iconButton: ReactElement }>; + Icon: IconComponent; + accent?: LightIconButtonProps['accent']; + onClick?: (event: MouseEvent) => void; +}; + +export type MenuItemWithOptionDropdownProps = { + accent?: MenuItemAccent; + className?: string; + dropdownContent: ReactNode; + dropdownId: string; + isIconDisplayedOnHoverOnly?: boolean; + isTooltipOpen?: boolean; + LeftIcon?: IconComponent | null; + RightIcon?: IconComponent | null; + onClick?: (event: MouseEvent) => void; + onMouseEnter?: (event: MouseEvent) => void; + onMouseLeave?: (event: MouseEvent) => void; + testId?: string; + text: ReactNode; + hasSubMenu?: boolean; +}; + +// TODO: refactor this +export const MenuItemWithOptionDropdown = ({ + accent = 'default', + className, + isIconDisplayedOnHoverOnly = true, + dropdownContent, + dropdownId, + LeftIcon, + RightIcon, + onClick, + onMouseEnter, + onMouseLeave, + testId, + text, + hasSubMenu = false, +}: MenuItemWithOptionDropdownProps) => { + const theme = useTheme(); + + const handleMenuItemClick = (event: MouseEvent) => { + if (!onClick) return; + event.preventDefault(); + event.stopPropagation(); + + onClick?.(event); + }; + + return ( + + + + +
+ + } + dropdownComponents={dropdownContent} + dropdownId={dropdownId} + dropdownHotkeyScope={{ scope: SelectHotkeyScope.Select }} + disableBlur + /> +
+ {hasSubMenu && ( + + )} +
+ ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-bar/components/__stories__/NavigationBar.stories.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-bar/components/__stories__/NavigationBar.stories.tsx index 2dba91b685c8..ee7c61264102 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-bar/components/__stories__/NavigationBar.stories.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-bar/components/__stories__/NavigationBar.stories.tsx @@ -5,12 +5,11 @@ import { IconList, IconSearch, IconSettings, + NavigationBar, } from 'twenty-ui'; import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator'; -import { NavigationBar } from '../NavigationBar'; - const meta: Meta = { title: 'UI/Navigation/NavigationBar/NavigationBar', component: NavigationBar, diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx index 25d934045851..3a03e32f7def 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/MultiWorkspaceDropdownButton.tsx @@ -1,19 +1,19 @@ -import { useState } from 'react'; -import { useTheme } from '@emotion/react'; -import styled from '@emotion/styled'; -import { useRecoilValue } from 'recoil'; -import { IconChevronDown } from 'twenty-ui'; - import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { Workspaces } from '@/auth/states/workspaces'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; -import { MenuItemSelectAvatar } from '@/ui/navigation/menu-item/components/MenuItemSelectAvatar'; +import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper'; import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceLogo'; import { MULTI_WORKSPACE_DROPDOWN_ID } from '@/ui/navigation/navigation-drawer/constants/MulitWorkspaceDropdownId'; import { useWorkspaceSwitching } from '@/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching'; import { NavigationDrawerHotKeyScope } from '@/ui/navigation/navigation-drawer/types/NavigationDrawerHotKeyScope'; +import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { useState } from 'react'; +import { useRecoilState, useRecoilValue } from 'recoil'; +import { IconChevronDown, MenuItemSelectAvatar } from 'twenty-ui'; import { getImageAbsoluteURI } from '~/utils/image/getImageAbsoluteURI'; const StyledLogo = styled.div<{ logo: string }>` @@ -25,7 +25,7 @@ const StyledLogo = styled.div<{ logo: string }>` width: 16px; `; -const StyledContainer = styled.div` +const StyledContainer = styled.div<{ isNavigationDrawerExpanded: boolean }>` align-items: center; cursor: pointer; color: ${({ theme }) => theme.font.color.primary}; @@ -33,12 +33,13 @@ const StyledContainer = styled.div` border: 1px solid transparent; display: flex; justify-content: space-between; - height: ${({ theme }) => theme.spacing(5)}; + height: ${({ theme, isNavigationDrawerExpanded }) => + isNavigationDrawerExpanded ? theme.spacing(5) : theme.spacing(4)}; padding: calc(${({ theme }) => theme.spacing(1)} - 1px); - width: 100%; - - gap: ${({ theme }) => theme.spacing(1)}; - + width: ${({ isNavigationDrawerExpanded }) => + isNavigationDrawerExpanded ? '100%' : 'auto'}; + gap: ${({ theme, isNavigationDrawerExpanded }) => + isNavigationDrawerExpanded ? theme.spacing(1) : '0'}; &:hover { background-color: ${({ theme }) => theme.background.transparent.lighter}; border: 1px solid ${({ theme }) => theme.border.color.medium}; @@ -51,8 +52,10 @@ const StyledLabel = styled.div` `; const StyledIconChevronDown = styled(IconChevronDown)<{ disabled?: boolean }>` + align-items: center; color: ${({ disabled, theme }) => disabled ? theme.font.color.extraLight : theme.font.color.tertiary}; + display: flex; `; type MultiWorkspaceDropdownButtonProps = { @@ -77,6 +80,9 @@ export const MultiWorkspaceDropdownButton = ({ closeDropdown(); await switchWorkspace(workspaceId); }; + const [isNavigationDrawerExpanded] = useRecoilState( + isNavigationDrawerExpandedState, + ); return ( + - {currentWorkspace?.displayName ?? ''} - + + {currentWorkspace?.displayName ?? ''} + + + + } dropdownComponents={ diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx index 997441038d29..ded8a5772cc7 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawer.tsx @@ -1,16 +1,16 @@ -import { css, useTheme } from '@emotion/react'; +import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; import { motion } from 'framer-motion'; import { ReactNode, useState } from 'react'; import { useRecoilValue } from 'recoil'; import { MOBILE_VIEWPORT } from 'twenty-ui'; -import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage'; -import { isNavigationDrawerOpenState } from '@/ui/navigation/states/isNavigationDrawerOpenState'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; -import { DESKTOP_NAV_DRAWER_WIDTHS } from '../constants/DesktopNavDrawerWidths'; +import { NAV_DRAWER_WIDTHS } from '@/ui/navigation/navigation-drawer/constants/NavDrawerWidths'; +import { useIsSettingsDrawer } from '@/navigation/hooks/useIsSettingsDrawer'; +import { isNavigationDrawerExpandedState } from '../../states/isNavigationDrawerExpanded'; import { NavigationDrawerBackButton } from './NavigationDrawerBackButton'; import { NavigationDrawerHeader } from './NavigationDrawerHeader'; @@ -18,32 +18,31 @@ export type NavigationDrawerProps = { children: ReactNode; className?: string; footer?: ReactNode; - isSubMenu?: boolean; logo?: string; title?: string; }; -const StyledAnimatedContainer = styled(motion.div)` - display: flex; - justify-content: end; +const StyledAnimatedContainer = styled(motion.div)<{ isSettings?: boolean }>` + max-height: 100vh; + overflow: ${({ isSettings }) => (isSettings ? 'visible' : 'hidden')}; `; -const StyledContainer = styled.div<{ isSubMenu?: boolean }>` +const StyledContainer = styled.div<{ + isSettings?: boolean; + isMobile?: boolean; +}>` box-sizing: border-box; display: flex; flex-direction: column; + width: ${NAV_DRAWER_WIDTHS.menu.desktop.expanded}px; gap: ${({ theme }) => theme.spacing(3)}; height: 100%; - min-width: ${DESKTOP_NAV_DRAWER_WIDTHS.menu}px; - padding: ${({ theme }) => theme.spacing(3, 2, 4)}; - - ${({ isSubMenu, theme }) => - isSubMenu - ? css` - padding-left: ${theme.spacing(0)}; - padding-right: ${theme.spacing(8)}; - ` - : ''} + padding: ${({ theme, isSettings, isMobile }) => + isSettings + ? isMobile + ? theme.spacing(3, 8) + : theme.spacing(3, 8, 4, 0) + : theme.spacing(3, 2, 4)}; @media (max-width: ${MOBILE_VIEWPORT}px) { width: 100%; @@ -51,25 +50,29 @@ const StyledContainer = styled.div<{ isSubMenu?: boolean }>` padding-right: 20px; } `; -const StyledItemsContainer = styled.div` + +const StyledItemsContainer = styled.div<{ isSettings?: boolean }>` display: flex; flex-direction: column; margin-bottom: auto; + overflow: ${({ isSettings }) => (isSettings ? 'visible' : 'hidden')}; + flex: 1; `; export const NavigationDrawer = ({ children, className, footer, - isSubMenu, logo, title, }: NavigationDrawerProps) => { const [isHovered, setIsHovered] = useState(false); const isMobile = useIsMobile(); + const isSettingsDrawer = useIsSettingsDrawer(); const theme = useTheme(); - const isNavigationDrawerOpen = useRecoilValue(isNavigationDrawerOpenState); - const isSettingsPage = useIsSettingsPage(); + const isNavigationDrawerExpanded = useRecoilValue( + isNavigationDrawerExpandedState, + ); const handleHover = () => { setIsHovered(true); @@ -79,30 +82,36 @@ export const NavigationDrawer = ({ setIsHovered(false); }; - const desktopWidth = !isNavigationDrawerOpen - ? 12 - : DESKTOP_NAV_DRAWER_WIDTHS.menu; + const desktopWidth = isNavigationDrawerExpanded + ? NAV_DRAWER_WIDTHS.menu.desktop.expanded + : NAV_DRAWER_WIDTHS.menu.desktop.collapsed; - const mobileWidth = isNavigationDrawerOpen ? '100%' : 0; + const mobileWidth = isNavigationDrawerExpanded + ? NAV_DRAWER_WIDTHS.menu.mobile.expanded + : NAV_DRAWER_WIDTHS.menu.mobile.collapsed; + + const navigationDrawerAnimate = { + width: isMobile ? mobileWidth : desktopWidth, + opacity: isNavigationDrawerExpanded || !isSettingsDrawer ? 1 : 0, + }; return ( - {isSubMenu && title ? ( + {isSettingsDrawer && title ? ( !isMobile && ) : ( )} - {children} + + {children} + {footer} diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper.tsx new file mode 100644 index 000000000000..732da1418997 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper.tsx @@ -0,0 +1,53 @@ +import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage'; +import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded'; +import { useTheme } from '@emotion/react'; +import styled from '@emotion/styled'; +import { AnimationControls, motion, TargetAndTransition } from 'framer-motion'; +import { useRecoilValue } from 'recoil'; + +const StyledAnimatedContainer = styled(motion.span)` + display: block; +`; + +export const NavigationDrawerAnimatedCollapseWrapper = ({ + children, +}: { + children: React.ReactNode; +}) => { + const theme = useTheme(); + const isSettingsPage = useIsSettingsPage(); + const isNavigationDrawerExpanded = useRecoilValue( + isNavigationDrawerExpandedState, + ); + + if (isSettingsPage) { + return children; + } + + const animate: AnimationControls | TargetAndTransition = + isNavigationDrawerExpanded + ? { + opacity: 1, + width: 'auto', + height: 'auto', + pointerEvents: 'auto', + } + : { + opacity: 0, + width: 0, + height: 0, + pointerEvents: 'none', + }; + + return ( + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerBackButton.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerBackButton.tsx index 96490d7f0a7e..ec0c5bcfe061 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerBackButton.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerBackButton.tsx @@ -1,9 +1,10 @@ import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { useRecoilValue } from 'recoil'; -import { IconX } from 'twenty-ui'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { IconX, UndecoratedLink } from 'twenty-ui'; -import { UndecoratedLink } from '@/ui/navigation/link/components/UndecoratedLink'; +import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded'; +import { navigationDrawerExpandedMemorizedState } from '@/ui/navigation/states/navigationDrawerExpandedMemorizedState'; import { navigationMemorizedUrlState } from '@/ui/navigation/states/navigationMemorizedUrlState'; type NavigationDrawerBackButtonProps = { @@ -43,9 +44,22 @@ export const NavigationDrawerBackButton = ({ const theme = useTheme(); const navigationMemorizedUrl = useRecoilValue(navigationMemorizedUrlState); + const setIsNavigationDrawerExpanded = useSetRecoilState( + isNavigationDrawerExpandedState, + ); + const navigationDrawerExpandedMemorized = useRecoilValue( + navigationDrawerExpandedMemorizedState, + ); + return ( - + + setIsNavigationDrawerExpanded(navigationDrawerExpandedMemorized) + } + > theme.border.radius.md}; color: ${({ theme }) => theme.font.color.light}; cursor: pointer; display: flex; - height: ${({ theme }) => theme.spacing(5)}; justify-content: center; user-select: none; - width: ${({ theme }) => theme.spacing(6)}; - - &:hover { - background: ${({ theme }) => theme.background.quaternary}; - } `; type NavigationDrawerCollapseButtonProps = { @@ -33,24 +26,26 @@ export const NavigationDrawerCollapseButton = ({ className, direction = 'left', }: NavigationDrawerCollapseButtonProps) => { - const setIsNavigationDrawerOpen = useSetRecoilState( - isNavigationDrawerOpenState, + const setIsNavigationDrawerExpanded = useSetRecoilState( + isNavigationDrawerExpandedState, ); return ( - setIsNavigationDrawerOpen((previousIsOpen) => !previousIsOpen) + setIsNavigationDrawerExpanded( + (previousIsExpanded) => !previousIsExpanded, + ) } > - diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx index 03ae1fcd0ada..52477ff39f93 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerHeader.tsx @@ -7,17 +7,21 @@ import { DEFAULT_WORKSPACE_LOGO } from '@/ui/navigation/navigation-drawer/consta import { DEFAULT_WORKSPACE_NAME } from '@/ui/navigation/navigation-drawer/constants/DefaultWorkspaceName'; import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; +import { NavigationDrawerAnimatedCollapseWrapper } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerAnimatedCollapseWrapper'; +import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded'; import { isNonEmptyString } from '@sniptt/guards'; import { NavigationDrawerCollapseButton } from './NavigationDrawerCollapseButton'; -const StyledContainer = styled.div<{ isMultiWorkspace: boolean }>` +const StyledContainer = styled.div` align-items: center; display: flex; - gap: ${({ theme, isMultiWorkspace }) => - !isMultiWorkspace ? theme.spacing(2) : null}; height: ${({ theme }) => theme.spacing(8)}; user-select: none; `; +const StyledSingleWorkspaceContainer = styled(StyledContainer)` + gap: ${({ theme }) => theme.spacing(2)}; + padding: ${({ theme }) => theme.spacing(1)}; +`; const StyledLogo = styled.div<{ logo: string }>` background: url(${({ logo }) => logo}); @@ -57,21 +61,25 @@ export const NavigationDrawerHeader = ({ const isMobile = useIsMobile(); const workspaces = useRecoilValue(workspacesState); const isMultiWorkspace = workspaces !== null && workspaces.length > 1; + const isNavigationDrawerExpanded = useRecoilValue( + isNavigationDrawerExpandedState, + ); return ( - + {isMultiWorkspace ? ( ) : ( - <> + - {name} - + + {name} + + )} - - {!isMobile && ( + {!isMobile && isNavigationDrawerExpanded && ( ; +> & { isNavigationDrawerExpanded: boolean }; -const StyledItem = styled('div', { +const StyledItem = styled('button', { shouldForwardProp: (prop) => !['active', 'danger', 'soon'].includes(prop) && isPropValid(prop), })` + box-sizing: content-box; align-items: center; background: ${(props) => props.active ? props.theme.background.transparent.light : 'inherit'}; @@ -65,9 +69,8 @@ const StyledItem = styled('div', { }}; cursor: ${(props) => (props.soon ? 'default' : 'pointer')}; display: flex; - font-family: 'Inter'; + font-family: ${({ theme }) => theme.font.family}; font-size: ${({ theme }) => theme.font.size.md}; - gap: ${({ theme }) => theme.spacing(2)}; padding-bottom: ${({ theme }) => theme.spacing(1)}; padding-left: ${({ theme }) => theme.spacing(1)}; @@ -78,7 +81,12 @@ const StyledItem = styled('div', { indentationLevel === 2 ? '2px' : '0'}; pointer-events: ${(props) => (props.soon ? 'none' : 'auto')}; - width: 100%; + + width: ${(props) => + !props.isNavigationDrawerExpanded + ? `${NAV_DRAWER_WIDTHS.menu.desktop.collapsed - 24}px` + : '100%'}; + :hover { background: ${({ theme }) => theme.background.transparent.light}; color: ${(props) => @@ -96,14 +104,20 @@ const StyledItem = styled('div', { } `; -const StyledItemLabel = styled.div` +const StyledItemElementsContainer = styled.span` + align-items: center; + display: flex; + gap: ${({ theme }) => theme.spacing(2)}; + width: 100%; +`; + +const StyledItemLabel = styled.span` font-weight: ${({ theme }) => theme.font.weight.medium}; - overflow: hidden; text-overflow: ellipsis; white-space: nowrap; `; -const StyledItemCount = styled.div` +const StyledItemCount = styled.span` align-items: center; background-color: ${({ theme }) => theme.color.blue}; border-radius: ${({ theme }) => theme.border.radius.rounded}; @@ -111,14 +125,13 @@ const StyledItemCount = styled.div` display: flex; font-size: ${({ theme }) => theme.font.size.xs}; font-weight: ${({ theme }) => theme.font.weight.semiBold}; - height: 16px; justify-content: center; margin-left: auto; width: 16px; `; -const StyledKeyBoardShortcut = styled.div` +const StyledKeyBoardShortcut = styled.span` align-items: center; border-radius: 4px; color: ${({ theme }) => theme.font.color.light}; @@ -129,12 +142,15 @@ const StyledKeyBoardShortcut = styled.div` visibility: hidden; `; -const StyledNavigationDrawerItemContainer = styled.div` +const StyledNavigationDrawerItemContainer = styled.span` display: flex; - flex-grow: 1; width: 100%; `; +const StyledSpacer = styled.span` + flex-grow: 1; +`; + export const NavigationDrawerItem = ({ className, label, @@ -151,16 +167,15 @@ export const NavigationDrawerItem = ({ }: NavigationDrawerItemProps) => { const theme = useTheme(); const isMobile = useIsMobile(); + const isSettingsPage = useIsSettingsPage(); const navigate = useNavigate(); - const setIsNavigationDrawerOpen = useSetRecoilState( - isNavigationDrawerOpenState, - ); - + const [isNavigationDrawerExpanded, setIsNavigationDrawerExpanded] = + useRecoilState(isNavigationDrawerExpandedState); const showBreadcrumb = indentationLevel === 2; const handleItemClick = () => { if (isMobile) { - setIsNavigationDrawerOpen(false); + setIsNavigationDrawerExpanded(false); } if (isDefined(onClick)) { @@ -182,28 +197,56 @@ export const NavigationDrawerItem = ({ aria-selected={active} danger={danger} soon={soon} - as={to ? Link : 'div'} + as={to ? Link : undefined} to={to ? to : undefined} indentationLevel={indentationLevel} + isNavigationDrawerExpanded={isNavigationDrawerExpanded} > {showBreadcrumb && ( - - )} - {Icon && ( - - )} - {label} - {soon && } - {!!count && {count}} - {keyboard && ( - - {keyboard} - + + + )} + + {Icon && ( + + )} + + + {label} + + + + + {soon && ( + + + + )} + + {!!count && ( + + {count} + + )} + + {keyboard && ( + + + {keyboard} + + + )} + ); diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItemBreadcrumb.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItemBreadcrumb.tsx index 47e8bd120864..6c3998544111 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItemBreadcrumb.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItemBreadcrumb.tsx @@ -6,9 +6,10 @@ export type NavigationDrawerItemBreadcrumbProps = { }; const StyledNavigationDrawerItemBreadcrumbContainer = styled.div` - margin-left: 7.5px; - height: 28px; + + margin-left: 7.5px; + margin-right: ${({ theme }) => theme.spacing(2)}; width: 9px; `; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsedContainer.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsedContainer.tsx new file mode 100644 index 000000000000..550793621c0b --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerItemsCollapsedContainer.tsx @@ -0,0 +1,52 @@ +import styled from '@emotion/styled'; +import { ReactNode } from 'react'; +import { useRecoilValue } from 'recoil'; +import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded'; +import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage'; +import { AnimationControls, motion, TargetAndTransition } from 'framer-motion'; +import { useTheme } from '@emotion/react'; + +const StyledAnimationGroupContainer = styled(motion.div)``; + +type NavigationDrawerItemsCollapsedContainerProps = { + isGroup?: boolean; + children: ReactNode; +}; + +export const NavigationDrawerItemsCollapsedContainer = ({ + isGroup = false, + children, +}: NavigationDrawerItemsCollapsedContainerProps) => { + const theme = useTheme(); + const isSettingsPage = useIsSettingsPage(); + const isNavigationDrawerExpanded = useRecoilValue( + isNavigationDrawerExpandedState, + ); + const isExpanded = isNavigationDrawerExpanded || isSettingsPage; + let animate: AnimationControls | TargetAndTransition = { + width: 'auto', + backgroundColor: 'transparent', + border: 'none', + }; + if (!isExpanded) { + animate = { width: 24 }; + if (isGroup) { + animate = { + width: 24, + backgroundColor: theme.background.transparent.lighter, + border: `1px solid ${theme.background.transparent.lighter}`, + borderRadius: theme.border.radius.sm, + }; + } + } + + return ( + + {children} + + ); +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSection.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSection.tsx index 2ba98503329b..afc7fe803744 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSection.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSection.tsx @@ -6,6 +6,8 @@ const StyledSection = styled.div` gap: ${({ theme }) => theme.betweenSiblingsGap}; width: 100%; margin-bottom: ${({ theme }) => theme.spacing(3)}; + flex-shrink: 1; + overflow: hidden; `; export { StyledSection as NavigationDrawerSection }; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle.tsx index d208dccf111a..aeeffa88d343 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitle.tsx @@ -1,8 +1,10 @@ import styled from '@emotion/styled'; import { currentUserState } from '@/auth/states/currentUserState'; +import { useIsSettingsPage } from '@/navigation/hooks/useIsSettingsPage'; import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading'; import { NavigationDrawerSectionTitleSkeletonLoader } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSectionTitleSkeletonLoader'; +import { isNavigationDrawerExpandedState } from '@/ui/navigation/states/isNavigationDrawerExpanded'; import { useRecoilValue } from 'recoil'; import { isDefined } from 'twenty-ui'; import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; @@ -37,9 +39,22 @@ export const NavigationDrawerSectionTitle = ({ }: NavigationDrawerSectionTitleProps) => { const currentUser = useRecoilValue(currentUserState); const loading = useIsPrefetchLoading(); + const isNavigationDrawerExpanded = useRecoilValue( + isNavigationDrawerExpandedState, + ); + + const isSettingsPage = useIsSettingsPage(); if (loading && isDefined(currentUser)) { return ; } - return {label}; + return ( + + {label} + + ); }; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/__stories__/NavigationDrawer.stories.tsx b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/__stories__/NavigationDrawer.stories.tsx index dfbe324297a4..93db01f85838 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/__stories__/NavigationDrawer.stories.tsx +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/components/__stories__/NavigationDrawer.stories.tsx @@ -1,5 +1,6 @@ import { Meta, StoryObj } from '@storybook/react'; import { + GithubVersionLink, IconAt, IconBell, IconBuildingSkyscraper, @@ -18,18 +19,17 @@ import { import { getSettingsPagePath } from '@/settings/utils/getSettingsPagePath'; import { SettingsPath } from '@/types/SettingsPath'; -import { GithubVersionLink } from '@/ui/navigation/link/components/GithubVersionLink'; import { ComponentWithRouterDecorator } from '~/testing/decorators/ComponentWithRouterDecorator'; import { SnackBarDecorator } from '~/testing/decorators/SnackBarDecorator'; import { CurrentWorkspaceMemberFavorites } from '@/favorites/components/CurrentWorkspaceMemberFavorites'; import { NavigationDrawerSubItem } from '@/ui/navigation/navigation-drawer/components/NavigationDrawerSubItem'; +import jsonPage from '../../../../../../../package.json'; import { NavigationDrawer } from '../NavigationDrawer'; import { NavigationDrawerItem } from '../NavigationDrawerItem'; import { NavigationDrawerItemGroup } from '../NavigationDrawerItemGroup'; import { NavigationDrawerSection } from '../NavigationDrawerSection'; import { NavigationDrawerSectionTitle } from '../NavigationDrawerSectionTitle'; - const meta: Meta = { title: 'UI/Navigation/NavigationDrawer/NavigationDrawer', component: NavigationDrawer, @@ -53,6 +53,11 @@ export const Default: Story = { Icon={IconBell} soon={true} /> + @@ -144,6 +148,6 @@ export const Submenu: Story = { ), - footer: , + footer: , }, }; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/constants/DesktopNavDrawerWidths.ts b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/constants/DesktopNavDrawerWidths.ts deleted file mode 100644 index fe5462310dce..000000000000 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/constants/DesktopNavDrawerWidths.ts +++ /dev/null @@ -1,3 +0,0 @@ -export const DESKTOP_NAV_DRAWER_WIDTHS = { - menu: 220, -}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/constants/NavDrawerWidths.ts b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/constants/NavDrawerWidths.ts new file mode 100644 index 000000000000..3abe70079807 --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/constants/NavDrawerWidths.ts @@ -0,0 +1,12 @@ +export const NAV_DRAWER_WIDTHS = { + menu: { + mobile: { + collapsed: 0, + expanded: '100%', + }, + desktop: { + collapsed: 40, + expanded: 220, + }, + }, +}; diff --git a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts index 7a04238d1e53..b7e7abf9f325 100644 --- a/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts +++ b/packages/twenty-front/src/modules/ui/navigation/navigation-drawer/hooks/useWorkspaceSwitching.ts @@ -1,6 +1,13 @@ import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { useAuth } from '@/auth/hooks/useAuth'; +import { useSSO } from '@/auth/sign-in-up/hooks/useSSO'; +import { availableSSOIdentityProvidersState } from '@/auth/states/availableWorkspacesForSSO'; import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; +import { + SignInUpStep, + signInUpStepState, +} from '@/auth/states/signInUpStepState'; import { tokenPairState } from '@/auth/states/tokenPairState'; import { AppPath } from '@/types/AppPath'; import { useGenerateJwtMutation } from '~/generated/graphql'; @@ -10,7 +17,13 @@ import { sleep } from '~/utils/sleep'; export const useWorkspaceSwitching = () => { const setTokenPair = useSetRecoilState(tokenPairState); const [generateJWT] = useGenerateJwtMutation(); + const { redirectToSSOLoginPage } = useSSO(); const currentWorkspace = useRecoilValue(currentWorkspaceState); + const setAvailableWorkspacesForSSOState = useSetRecoilState( + availableSSOIdentityProvidersState, + ); + const setSignInUpStep = useSetRecoilState(signInUpStepState); + const { signOut } = useAuth(); const switchWorkspace = async (workspaceId: string) => { if (currentWorkspace?.id === workspaceId) return; @@ -28,10 +41,34 @@ export const useWorkspaceSwitching = () => { throw new Error('could not create token'); } - const { tokens } = jwt.data.generateJWT; - setTokenPair(tokens); - await sleep(0); // This hacky workaround is necessary to ensure the tokens stored in the cookie are updated correctly. - window.location.href = AppPath.Index; + if ( + jwt.data.generateJWT.reason === 'WORKSPACE_USE_SSO_AUTH' && + 'availableSSOIDPs' in jwt.data.generateJWT + ) { + if (jwt.data.generateJWT.availableSSOIDPs.length === 1) { + redirectToSSOLoginPage(jwt.data.generateJWT.availableSSOIDPs[0].id); + } + + if (jwt.data.generateJWT.availableSSOIDPs.length > 1) { + await signOut(); + setAvailableWorkspacesForSSOState( + jwt.data.generateJWT.availableSSOIDPs, + ); + setSignInUpStep(SignInUpStep.SSOWorkspaceSelection); + } + + return; + } + + if ( + jwt.data.generateJWT.reason !== 'WORKSPACE_USE_SSO_AUTH' && + 'authTokens' in jwt.data.generateJWT + ) { + const { tokens } = jwt.data.generateJWT.authTokens; + setTokenPair(tokens); + await sleep(0); // This hacky workaround is necessary to ensure the tokens stored in the cookie are updated correctly. + window.location.href = AppPath.Index; + } }; return { switchWorkspace }; diff --git a/packages/twenty-front/src/modules/ui/navigation/states/isNavigationDrawerOpenState.ts b/packages/twenty-front/src/modules/ui/navigation/states/isNavigationDrawerExpanded.ts similarity index 63% rename from packages/twenty-front/src/modules/ui/navigation/states/isNavigationDrawerOpenState.ts rename to packages/twenty-front/src/modules/ui/navigation/states/isNavigationDrawerExpanded.ts index b5496b4a57ac..7155d06078de 100644 --- a/packages/twenty-front/src/modules/ui/navigation/states/isNavigationDrawerOpenState.ts +++ b/packages/twenty-front/src/modules/ui/navigation/states/isNavigationDrawerExpanded.ts @@ -3,7 +3,7 @@ import { MOBILE_VIEWPORT } from 'twenty-ui'; const isMobile = window.innerWidth <= MOBILE_VIEWPORT; -export const isNavigationDrawerOpenState = atom({ - key: 'isNavigationDrawerOpen', +export const isNavigationDrawerExpandedState = atom({ + key: 'isNavigationDrawerExpanded', default: !isMobile, }); diff --git a/packages/twenty-front/src/modules/ui/navigation/states/navigationDrawerExpandedMemorizedState.ts b/packages/twenty-front/src/modules/ui/navigation/states/navigationDrawerExpandedMemorizedState.ts new file mode 100644 index 000000000000..cdeff16e74ac --- /dev/null +++ b/packages/twenty-front/src/modules/ui/navigation/states/navigationDrawerExpandedMemorizedState.ts @@ -0,0 +1,9 @@ +import { atom } from 'recoil'; +import { MOBILE_VIEWPORT } from 'twenty-ui'; + +const isMobile = window.innerWidth <= MOBILE_VIEWPORT; + +export const navigationDrawerExpandedMemorizedState = atom({ + key: 'navigationDrawerExpandedMemorized', + default: !isMobile, +}); diff --git a/packages/twenty-front/src/modules/ui/utilities/drag-select/components/DragSelect.tsx b/packages/twenty-front/src/modules/ui/utilities/drag-select/components/DragSelect.tsx index a9dca6c5e625..c298b3e8e1bf 100644 --- a/packages/twenty-front/src/modules/ui/utilities/drag-select/components/DragSelect.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/drag-select/components/DragSelect.tsx @@ -1,9 +1,9 @@ -import { RefObject } from 'react'; import { boxesIntersect, useSelectionContainer, } from '@air/react-drag-to-select'; import { useTheme } from '@emotion/react'; +import { RefObject } from 'react'; import { RGBA } from 'twenty-ui'; import { useDragSelect } from '../hooks/useDragSelect'; @@ -11,13 +11,15 @@ import { useDragSelect } from '../hooks/useDragSelect'; type DragSelectProps = { dragSelectable: RefObject; onDragSelectionChange: (id: string, selected: boolean) => void; - onDragSelectionStart?: () => void; + onDragSelectionStart?: (event: MouseEvent) => void; + onDragSelectionEnd?: (event: MouseEvent) => void; }; export const DragSelect = ({ dragSelectable, onDragSelectionChange, onDragSelectionStart, + onDragSelectionEnd, }: DragSelectProps) => { const theme = useTheme(); const { isDragSelectionStartEnabled } = useDragSelect(); @@ -37,6 +39,7 @@ export const DragSelect = ({ return true; }, onSelectionStart: onDragSelectionStart, + onSelectionEnd: onDragSelectionEnd, onSelectionChange: (box) => { const scrollAwareBox = { ...box, diff --git a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/__tests__/useGoToHotkeys.test.tsx b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/__tests__/useGoToHotkeys.test.tsx index 1ed5ddb4ca21..f7e478d2c7b8 100644 --- a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/__tests__/useGoToHotkeys.test.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/__tests__/useGoToHotkeys.test.tsx @@ -1,6 +1,6 @@ +import { fireEvent, renderHook } from '@testing-library/react'; import { act } from 'react-dom/test-utils'; import { MemoryRouter, useLocation } from 'react-router-dom'; -import { fireEvent, renderHook } from '@testing-library/react'; import { RecoilRoot } from 'recoil'; import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; @@ -23,7 +23,7 @@ const renderHookConfig = { describe('useGoToHotkeys', () => { it('should navigate on hotkey trigger', () => { const { result } = renderHook(() => { - useGoToHotkeys('a', '/three'); + useGoToHotkeys({ key: 'a', location: '/three' }); const setHotkeyScope = useSetHotkeyScope(); diff --git a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGoToHotkeys.ts b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGoToHotkeys.ts index aeb485b4c02f..d8e62312cfeb 100644 --- a/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGoToHotkeys.ts +++ b/packages/twenty-front/src/modules/ui/utilities/hotkey/hooks/useGoToHotkeys.ts @@ -5,13 +5,24 @@ import { AppHotkeyScope } from '../types/AppHotkeyScope'; import { useSequenceHotkeys } from './useSequenceScopedHotkeys'; -export const useGoToHotkeys = (key: Keys, location: string) => { +type GoToHotkeysProps = { + key: Keys; + location: string; + preNavigateFunction?: () => void; +}; + +export const useGoToHotkeys = ({ + key, + location, + preNavigateFunction, +}: GoToHotkeysProps) => { const navigate = useNavigate(); useSequenceHotkeys( 'g', key, () => { + preNavigateFunction?.(); navigate(location); }, AppHotkeyScope.Goto, diff --git a/packages/twenty-front/src/modules/ui/utilities/page-title/PageTitle.tsx b/packages/twenty-front/src/modules/ui/utilities/page-title/components/PageTitle.tsx similarity index 100% rename from packages/twenty-front/src/modules/ui/utilities/page-title/PageTitle.tsx rename to packages/twenty-front/src/modules/ui/utilities/page-title/components/PageTitle.tsx diff --git a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/__tests__/useListenClickOutsideArrayOfRef.test.tsx b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/__tests__/useListenClickOutsideArrayOfRef.test.tsx index f236b78840f9..2f9530aeb661 100644 --- a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/__tests__/useListenClickOutsideArrayOfRef.test.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/__tests__/useListenClickOutsideArrayOfRef.test.tsx @@ -1,13 +1,12 @@ +import { fireEvent, renderHook } from '@testing-library/react'; import React from 'react'; import { act } from 'react-dom/test-utils'; -import { fireEvent, render, renderHook } from '@testing-library/react'; import { isDefined } from '~/utils/isDefined'; import { ClickOutsideMode, useListenClickOutside, - useListenClickOutsideByClassName, } from '../useListenClickOutside'; const containerRef = React.createRef(); @@ -77,59 +76,3 @@ describe('useListenClickOutside', () => { expect(callback).toHaveBeenCalled(); }); }); - -describe('useListenClickOutsideByClassName', () => { - it('should trigger the callback when clicking outside the specified class names', () => { - const callback = jest.fn(); - const { container } = render( -
-
Inside
-
Outside
-
, - ); - - renderHook(() => - useListenClickOutsideByClassName({ - classNames: ['wont-trigger'], - callback, - }), - ); - - act(() => { - const notClickableElement = container.querySelector('.will-trigger'); - if (isDefined(notClickableElement)) { - fireEvent.mouseDown(notClickableElement); - fireEvent.click(notClickableElement); - } - }); - - expect(callback).toHaveBeenCalled(); - }); - - it('should not trigger the callback when clicking inside the specified class names', () => { - const callback = jest.fn(); - const { container } = render( -
-
Inside
-
Outside
-
, - ); - - renderHook(() => - useListenClickOutsideByClassName({ - classNames: ['wont-trigger'], - callback, - }), - ); - - act(() => { - const notClickableElement = container.querySelector('.wont-trigger'); - if (isDefined(notClickableElement)) { - fireEvent.mouseDown(notClickableElement); - fireEvent.click(notClickableElement); - } - }); - - expect(callback).not.toHaveBeenCalled(); - }); -}); diff --git a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useClickOustideListenerStates.ts b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useClickOustideListenerStates.ts index dc2675440399..26e7ebca3ae9 100644 --- a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useClickOustideListenerStates.ts +++ b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useClickOustideListenerStates.ts @@ -1,7 +1,7 @@ import { clickOutsideListenerCallbacksComponentState } from '@/ui/utilities/pointer-event/states/clickOutsideListenerCallbacksComponentState'; import { clickOutsideListenerIsActivatedComponentState } from '@/ui/utilities/pointer-event/states/clickOutsideListenerIsActivatedComponentState'; import { clickOutsideListenerIsMouseDownInsideComponentState } from '@/ui/utilities/pointer-event/states/clickOutsideListenerIsMouseDownInsideComponentState'; -import { lockedListenerIdState } from '@/ui/utilities/pointer-event/states/lockedListenerIdState'; +import { clickOutsideListenerMouseDownHappenedComponentState } from '@/ui/utilities/pointer-event/states/clickOutsideListenerMouseDownHappenedComponentState'; import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; import { extractComponentState } from '@/ui/utilities/state/component-state/utils/extractComponentState'; @@ -22,6 +22,9 @@ export const useClickOustideListenerStates = (componentId: string) => { clickOutsideListenerIsActivatedComponentState, scopeId, ), - lockedListenerIdState, + getClickOutsideListenerMouseDownHappenedState: extractComponentState( + clickOutsideListenerMouseDownHappenedComponentState, + scopeId, + ), }; }; diff --git a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useClickOutsideListener.ts b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useClickOutsideListener.ts index b69e06f8b5ab..5a693e1f7842 100644 --- a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useClickOutsideListener.ts +++ b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useClickOutsideListener.ts @@ -7,17 +7,14 @@ import { useListenClickOutsideV2, } from '@/ui/utilities/pointer-event/hooks/useListenClickOutsideV2'; import { ClickOutsideListenerCallback } from '@/ui/utilities/pointer-event/types/ClickOutsideListenerCallback'; -import { getScopeIdFromComponentId } from '@/ui/utilities/recoil-scope/utils/getScopeIdFromComponentId'; import { toSpliced } from '~/utils/array/toSpliced'; import { isDefined } from '~/utils/isDefined'; export const useClickOutsideListener = (componentId: string) => { - // TODO: improve typing - const scopeId = getScopeIdFromComponentId(componentId) ?? ''; - const { getClickOutsideListenerIsActivatedState, getClickOutsideListenerCallbacksState, + getClickOutsideListenerMouseDownHappenedState, } = useClickOustideListenerStates(componentId); const useListenClickOutside = ({ @@ -53,8 +50,15 @@ export const useClickOutsideListener = (componentId: string) => { ({ set }) => (activated: boolean) => { set(getClickOutsideListenerIsActivatedState, activated); + + if (!activated) { + set(getClickOutsideListenerMouseDownHappenedState, false); + } }, - [getClickOutsideListenerIsActivatedState], + [ + getClickOutsideListenerIsActivatedState, + getClickOutsideListenerMouseDownHappenedState, + ], ); const registerOnClickOutsideCallback = useRecoilCallback( @@ -148,7 +152,6 @@ export const useClickOutsideListener = (componentId: string) => { }; return { - scopeId, useListenClickOutside, toggleClickOutsideListener, useRegisterClickOutsideListenerCallback, diff --git a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useListenClickOutside.ts b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useListenClickOutside.ts index b3a879216f25..613590ed5e12 100644 --- a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useListenClickOutside.ts +++ b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useListenClickOutside.ts @@ -138,58 +138,3 @@ export const useListenClickOutside = ({ } }, [refs, callback, mode, enabled, isMouseDownInside]); }; - -export const useListenClickOutsideByClassName = ({ - classNames, - excludeClassNames, - callback, -}: { - classNames: string[]; - excludeClassNames?: string[]; - callback: () => void; -}) => { - useEffect(() => { - const handleClickOutside = (event: MouseEvent | TouchEvent) => { - if (!(event.target instanceof Node)) return; - - const clickedElement = event.target as HTMLElement; - let isClickedInside = false; - let isClickedOnExcluded = false; - let currentElement: HTMLElement | null = clickedElement; - - while (currentElement) { - const currentClassList = currentElement.classList; - - isClickedInside = classNames.some((className) => - currentClassList.contains(className), - ); - isClickedOnExcluded = - excludeClassNames?.some((className) => - currentClassList.contains(className), - ) ?? false; - - if (isClickedInside || isClickedOnExcluded) { - break; - } - - currentElement = currentElement.parentElement; - } - - if (!isClickedInside && !isClickedOnExcluded) { - callback(); - } - }; - - document.addEventListener('mousedown', handleClickOutside); - document.addEventListener('touchend', handleClickOutside, { - capture: true, - }); - - return () => { - document.removeEventListener('mousedown', handleClickOutside); - document.removeEventListener('touchend', handleClickOutside, { - capture: true, - }); - }; - }, [callback, classNames, excludeClassNames]); -}; diff --git a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useListenClickOutsideV2.ts b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useListenClickOutsideV2.ts index 287d6b0f8e6d..0d2f84c058eb 100644 --- a/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useListenClickOutsideV2.ts +++ b/packages/twenty-front/src/modules/ui/utilities/pointer-event/hooks/useListenClickOutsideV2.ts @@ -10,6 +10,7 @@ export enum ClickOutsideMode { export type ClickOutsideListenerProps = { refs: Array>; + excludeClassNames?: string[]; callback: (event: MouseEvent | TouchEvent) => void; mode?: ClickOutsideMode; listenerId: string; @@ -18,6 +19,7 @@ export type ClickOutsideListenerProps = { export const useListenClickOutsideV2 = ({ refs, + excludeClassNames, callback, mode = ClickOutsideMode.compareHTMLRef, listenerId, @@ -26,6 +28,7 @@ export const useListenClickOutsideV2 = ({ const { getClickOutsideListenerIsMouseDownInsideState, getClickOutsideListenerIsActivatedState, + getClickOutsideListenerMouseDownHappenedState, } = useClickOustideListenerStates(listenerId); const handleMouseDown = useRecoilCallback( @@ -35,6 +38,8 @@ export const useListenClickOutsideV2 = ({ .getLoadable(getClickOutsideListenerIsActivatedState) .getValue(); + set(getClickOutsideListenerMouseDownHappenedState, true); + const isListening = clickOutsideListenerIsActivated && enabled; if (!isListening) { @@ -90,27 +95,63 @@ export const useListenClickOutsideV2 = ({ } }, [ + getClickOutsideListenerIsActivatedState, + enabled, mode, refs, getClickOutsideListenerIsMouseDownInsideState, - enabled, - getClickOutsideListenerIsActivatedState, + getClickOutsideListenerMouseDownHappenedState, ], ); const handleClickOutside = useRecoilCallback( ({ snapshot }) => (event: MouseEvent | TouchEvent) => { + const clickOutsideListenerIsActivated = snapshot + .getLoadable(getClickOutsideListenerIsActivatedState) + .getValue(); + + const isListening = clickOutsideListenerIsActivated && enabled; + const isMouseDownInside = snapshot .getLoadable(getClickOutsideListenerIsMouseDownInsideState) .getValue(); + const hasMouseDownHappened = snapshot + .getLoadable(getClickOutsideListenerMouseDownHappenedState) + .getValue(); + if (mode === ClickOutsideMode.compareHTMLRef) { + const clickedElement = event.target as HTMLElement; + let isClickedOnExcluded = false; + let currentElement: HTMLElement | null = clickedElement; + + while (currentElement) { + const currentClassList = currentElement.classList; + + isClickedOnExcluded = + excludeClassNames?.some((className) => + currentClassList.contains(className), + ) ?? false; + + if (isClickedOnExcluded) { + break; + } + + currentElement = currentElement.parentElement; + } + const clickedOnAtLeastOneRef = refs .filter((ref) => !!ref.current) .some((ref) => ref.current?.contains(event.target as Node)); - if (!clickedOnAtLeastOneRef && !isMouseDownInside) { + if ( + isListening && + hasMouseDownHappened && + !clickedOnAtLeastOneRef && + !isMouseDownInside && + !isClickedOnExcluded + ) { callback(event); } } @@ -146,12 +187,26 @@ export const useListenClickOutsideV2 = ({ return true; }); - if (!clickedOnAtLeastOneRef && !isMouseDownInside) { + if ( + !clickedOnAtLeastOneRef && + !isMouseDownInside && + isListening && + hasMouseDownHappened + ) { callback(event); } } }, - [mode, refs, callback, getClickOutsideListenerIsMouseDownInsideState], + [ + getClickOutsideListenerIsActivatedState, + enabled, + getClickOutsideListenerIsMouseDownInsideState, + getClickOutsideListenerMouseDownHappenedState, + mode, + refs, + excludeClassNames, + callback, + ], ); useEffect(() => { diff --git a/packages/twenty-front/src/modules/ui/utilities/pointer-event/states/clickOutsideListenerMouseDownHappenedComponentState.ts b/packages/twenty-front/src/modules/ui/utilities/pointer-event/states/clickOutsideListenerMouseDownHappenedComponentState.ts new file mode 100644 index 000000000000..c9c9298c9cde --- /dev/null +++ b/packages/twenty-front/src/modules/ui/utilities/pointer-event/states/clickOutsideListenerMouseDownHappenedComponentState.ts @@ -0,0 +1,7 @@ +import { createComponentState } from '@/ui/utilities/state/component-state/utils/createComponentState'; + +export const clickOutsideListenerMouseDownHappenedComponentState = + createComponentState({ + key: 'clickOutsideListenerMouseDownHappenedComponentState', + defaultValue: false, + }); diff --git a/packages/twenty-front/src/modules/ui/utilities/pointer-event/states/lockedListenerIdState.ts b/packages/twenty-front/src/modules/ui/utilities/pointer-event/states/lockedListenerIdState.ts deleted file mode 100644 index c91421dae3e8..000000000000 --- a/packages/twenty-front/src/modules/ui/utilities/pointer-event/states/lockedListenerIdState.ts +++ /dev/null @@ -1,6 +0,0 @@ -import { createState } from 'twenty-ui'; - -export const lockedListenerIdState = createState({ - key: 'lockedListenerIdState', - defaultValue: null, -}); diff --git a/packages/twenty-front/src/modules/ui/utilities/recoil-scope/scopes-internal/hooks/__tests__/useAvailableScopeId.test.tsx b/packages/twenty-front/src/modules/ui/utilities/recoil-scope/scopes-internal/hooks/__tests__/useAvailableScopeId.test.tsx index afb281b9a759..05a638e71727 100644 --- a/packages/twenty-front/src/modules/ui/utilities/recoil-scope/scopes-internal/hooks/__tests__/useAvailableScopeId.test.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/recoil-scope/scopes-internal/hooks/__tests__/useAvailableScopeId.test.tsx @@ -1,5 +1,5 @@ -import React from 'react'; import { renderHook } from '@testing-library/react'; +import React from 'react'; import { useAvailableScopeIdOrThrow } from '@/ui/utilities/recoil-scope/scopes-internal/hooks/useAvailableScopeId'; import { createScopeInternalContext } from '@/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext'; diff --git a/packages/twenty-front/src/modules/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext.ts b/packages/twenty-front/src/modules/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext.ts index abf1b33900b4..02d447a7fd32 100644 --- a/packages/twenty-front/src/modules/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext.ts +++ b/packages/twenty-front/src/modules/ui/utilities/recoil-scope/scopes-internal/utils/createScopeInternalContext.ts @@ -1,6 +1,5 @@ -import { Context, createContext } from 'react'; - import { RecoilComponentStateKey } from '@/ui/utilities/state/component-state/types/RecoilComponentStateKey'; +import { Context, createContext } from 'react'; type ScopeInternalContext = Context; diff --git a/packages/twenty-front/src/modules/ui/utilities/responsive/hooks/__tests__/isMobile.test.tsx b/packages/twenty-front/src/modules/ui/utilities/responsive/hooks/__tests__/useIsMobile.test.tsx similarity index 100% rename from packages/twenty-front/src/modules/ui/utilities/responsive/hooks/__tests__/isMobile.test.tsx rename to packages/twenty-front/src/modules/ui/utilities/responsive/hooks/__tests__/useIsMobile.test.tsx diff --git a/packages/twenty-front/src/modules/ui/utilities/scroll/components/ScrollWrapper.tsx b/packages/twenty-front/src/modules/ui/utilities/scroll/components/ScrollWrapper.tsx index d4e5c2e79f78..97d4560694fd 100644 --- a/packages/twenty-front/src/modules/ui/utilities/scroll/components/ScrollWrapper.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/scroll/components/ScrollWrapper.tsx @@ -26,16 +26,16 @@ const StyledScrollWrapper = styled.div` export type ScrollWrapperProps = { children: React.ReactNode; className?: string; - hideY?: boolean; - hideX?: boolean; + enableXScroll?: boolean; + enableYScroll?: boolean; contextProviderName: ContextProviderName; }; export const ScrollWrapper = ({ children, className, - hideX, - hideY, + enableXScroll = true, + enableYScroll = true, contextProviderName, }: ScrollWrapperProps) => { const scrollableRef = useRef(null); @@ -58,8 +58,8 @@ export const ScrollWrapper = ({ options: { scrollbars: { autoHide: 'scroll' }, overflow: { - y: hideY ? 'hidden' : undefined, - x: hideX ? 'hidden' : undefined, + x: enableXScroll ? undefined : 'hidden', + y: enableYScroll ? undefined : 'hidden', }, }, events: { diff --git a/packages/twenty-front/src/modules/ui/utilities/scroll/contexts/ScrollWrapperContexts.tsx b/packages/twenty-front/src/modules/ui/utilities/scroll/contexts/ScrollWrapperContexts.tsx index b6f7d2103a60..1d83d1ddd94b 100644 --- a/packages/twenty-front/src/modules/ui/utilities/scroll/contexts/ScrollWrapperContexts.tsx +++ b/packages/twenty-front/src/modules/ui/utilities/scroll/contexts/ScrollWrapperContexts.tsx @@ -17,7 +17,8 @@ export type ContextProviderName = | 'tabList' | 'releases' | 'test' - | 'showPageActivityContainer'; + | 'showPageActivityContainer' + | 'navigationDrawer'; const createScrollWrapperContext = (id: string) => createContext({ @@ -47,6 +48,8 @@ export const ReleasesScrollWrapperContext = createScrollWrapperContext('releases'); export const ShowPageActivityContainerScrollWrapperContext = createScrollWrapperContext('showPageActivityContainer'); +export const NavigationDrawerScrollWrapperContext = + createScrollWrapperContext('navigationDrawer'); export const TestScrollWrapperContext = createScrollWrapperContext('test'); export const getContextByProviderName = ( @@ -77,6 +80,8 @@ export const getContextByProviderName = ( return TestScrollWrapperContext; case 'showPageActivityContainer': return ShowPageActivityContainerScrollWrapperContext; + case 'navigationDrawer': + return NavigationDrawerScrollWrapperContext; default: throw new Error('Context Provider not available'); } diff --git a/packages/twenty-front/src/modules/ui/utilities/state/component-state/utils/createComponentSelectorV2.ts b/packages/twenty-front/src/modules/ui/utilities/state/component-state/utils/createComponentSelectorV2.ts index 352bff92ff47..8f08559d7e25 100644 --- a/packages/twenty-front/src/modules/ui/utilities/state/component-state/utils/createComponentSelectorV2.ts +++ b/packages/twenty-front/src/modules/ui/utilities/state/component-state/utils/createComponentSelectorV2.ts @@ -1,3 +1,5 @@ +/* eslint-disable no-redeclare */ +/* eslint-disable prefer-arrow/prefer-arrow-functions */ import { selectorFamily } from 'recoil'; import { ComponentInstanceStateContext } from '@/ui/utilities/state/component-state/types/ComponentInstanceStateContext'; @@ -9,19 +11,32 @@ import { SelectorGetter } from '@/ui/utilities/state/types/SelectorGetter'; import { SelectorSetter } from '@/ui/utilities/state/types/SelectorSetter'; import { isDefined } from 'twenty-ui'; -export const createComponentSelectorV2 = ({ +export function createComponentSelectorV2(options: { + key: string; + get: SelectorGetter; + componentInstanceContext: ComponentInstanceStateContext | null; +}): ComponentReadOnlySelectorV2; + +export function createComponentSelectorV2(options: { + key: string; + get: SelectorGetter; + set: SelectorSetter; + componentInstanceContext: ComponentInstanceStateContext | null; +}): ComponentSelectorV2; + +export function createComponentSelectorV2({ key, get, set, - instanceContext, + componentInstanceContext, }: { key: string; get: SelectorGetter; set?: SelectorSetter; - instanceContext: ComponentInstanceStateContext | null; -}): ComponentSelectorV2 | ComponentReadOnlySelectorV2 => { - if (isDefined(instanceContext)) { - globalComponentInstanceContextMap.set(key, instanceContext); + componentInstanceContext: ComponentInstanceStateContext | null; +}): ComponentSelectorV2 | ComponentReadOnlySelectorV2 { + if (isDefined(componentInstanceContext)) { + globalComponentInstanceContextMap.set(key, componentInstanceContext); } if (isDefined(set)) { @@ -44,4 +59,4 @@ export const createComponentSelectorV2 = ({ }), } satisfies ComponentReadOnlySelectorV2; } -}; +} diff --git a/packages/twenty-front/src/modules/ui/utilities/state/component-state/utils/createComponentStateV2_alpha.ts b/packages/twenty-front/src/modules/ui/utilities/state/component-state/utils/createComponentStateV2Alpha.ts similarity index 100% rename from packages/twenty-front/src/modules/ui/utilities/state/component-state/utils/createComponentStateV2_alpha.ts rename to packages/twenty-front/src/modules/ui/utilities/state/component-state/utils/createComponentStateV2Alpha.ts diff --git a/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx b/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx index 4c51e6da446d..c41b7ff7e6cd 100644 --- a/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx +++ b/packages/twenty-front/src/modules/users/components/UserProviderEffect.tsx @@ -7,6 +7,8 @@ import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMembe import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { isCurrentUserLoadedState } from '@/auth/states/isCurrentUserLoadingState'; import { workspacesState } from '@/auth/states/workspaces'; +import { DateFormat } from '@/localization/constants/DateFormat'; +import { TimeFormat } from '@/localization/constants/TimeFormat'; import { dateTimeFormatState } from '@/localization/states/dateTimeFormatState'; import { detectDateFormat } from '@/localization/utils/detectDateFormat'; import { detectTimeFormat } from '@/localization/utils/detectTimeFormat'; @@ -81,10 +83,10 @@ export const UserProviderEffect = () => { : detectTimeZone(), dateFormat: isDefined(workspaceMember.dateFormat) ? getDateFormatFromWorkspaceDateFormat(workspaceMember.dateFormat) - : detectDateFormat(), + : DateFormat[detectDateFormat()], timeFormat: isDefined(workspaceMember.timeFormat) ? getTimeFormatFromWorkspaceTimeFormat(workspaceMember.timeFormat) - : detectTimeFormat(), + : TimeFormat[detectTimeFormat()], }); } diff --git a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts index 76e5400c34e9..9930b6d24f3a 100644 --- a/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts +++ b/packages/twenty-front/src/modules/users/graphql/fragments/userQueryFragment.ts @@ -8,6 +8,14 @@ export const USER_QUERY_FRAGMENT = gql` email canImpersonate supportUserHash + analyticsTinybirdJwts { + getWebhookAnalytics + getPageviewsAnalytics + getUsersAnalytics + getServerlessFunctionDuration + getServerlessFunctionSuccessRate + getServerlessFunctionErrorCount + } onboardingStatus workspaceMember { ...WorkspaceMemberQueryFragment @@ -23,6 +31,8 @@ export const USER_QUERY_FRAGMENT = gql` inviteHash allowImpersonation activationStatus + isPublicInviteLinkEnabled + hasValidEntrepriseKey featureFlags { id key diff --git a/packages/twenty-front/src/modules/views/components/AdvancedFilterChip.tsx b/packages/twenty-front/src/modules/views/components/AdvancedFilterChip.tsx new file mode 100644 index 000000000000..733084aca421 --- /dev/null +++ b/packages/twenty-front/src/modules/views/components/AdvancedFilterChip.tsx @@ -0,0 +1,27 @@ +import { IconFilterCog } from 'twenty-ui'; + +import { SortOrFilterChip } from '@/views/components/SortOrFilterChip'; +import { ADVANCED_FILTER_DROPDOWN_ID } from '@/views/constants/AdvancedFilterDropdownId'; +import { plural } from 'pluralize'; + +type AdvancedFilterChipProps = { + onRemove: () => void; + advancedFilterCount?: number; +}; + +export const AdvancedFilterChip = ({ + onRemove, + advancedFilterCount, +}: AdvancedFilterChipProps) => { + const labelText = 'advanced rule'; + const chipLabel = `${advancedFilterCount ?? 0} ${advancedFilterCount === 1 ? labelText : plural(labelText)}`; + return ( + + ); +}; diff --git a/packages/twenty-front/src/modules/views/components/AdvancedFilterDropdownButton.tsx b/packages/twenty-front/src/modules/views/components/AdvancedFilterDropdownButton.tsx new file mode 100644 index 000000000000..351f3c3d1c0e --- /dev/null +++ b/packages/twenty-front/src/modules/views/components/AdvancedFilterDropdownButton.tsx @@ -0,0 +1,83 @@ +import { useCallback } from 'react'; + +import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; + +import { AdvancedFilterRootLevelViewFilterGroup } from '@/object-record/advanced-filter/components/AdvancedFilterRootLevelViewFilterGroup'; +import { useDeleteCombinedViewFilterGroup } from '@/object-record/advanced-filter/hooks/useDeleteCombinedViewFilterGroup'; +import { AdvancedFilterChip } from '@/views/components/AdvancedFilterChip'; +import { ADVANCED_FILTER_DROPDOWN_ID } from '@/views/constants/AdvancedFilterDropdownId'; +import { useDeleteCombinedViewFilters } from '@/views/hooks/useDeleteCombinedViewFilters'; +import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; +import { isDefined } from 'twenty-ui'; + +export const AdvancedFilterDropdownButton = () => { + const { deleteCombinedViewFilter } = useDeleteCombinedViewFilters(); + const { deleteCombinedViewFilterGroup } = useDeleteCombinedViewFilterGroup(); + + const { currentViewWithCombinedFiltersAndSorts } = useGetCurrentView(); + + const advancedViewFilterIds = + currentViewWithCombinedFiltersAndSorts?.viewFilters + .filter((viewFilter) => isDefined(viewFilter.viewFilterGroupId)) + .map((viewFilter) => viewFilter.id); + + const handleDropdownClickOutside = useCallback(() => {}, []); + + const handleDropdownClose = () => {}; + + const removeAdvancedFilter = useCallback(async () => { + if (!advancedViewFilterIds) { + throw new Error('No advanced view filters to remove'); + } + + const viewFilterGroupIds = + currentViewWithCombinedFiltersAndSorts?.viewFilterGroups?.map( + (viewFilter) => viewFilter.id, + ) ?? []; + + for (const viewFilterGroupId of viewFilterGroupIds) { + await deleteCombinedViewFilterGroup(viewFilterGroupId); + } + + for (const viewFilterId of advancedViewFilterIds) { + await deleteCombinedViewFilter(viewFilterId); + } + }, [ + advancedViewFilterIds, + deleteCombinedViewFilter, + deleteCombinedViewFilterGroup, + currentViewWithCombinedFiltersAndSorts?.viewFilterGroups, + ]); + + const outermostViewFilterGroupId = + currentViewWithCombinedFiltersAndSorts?.viewFilterGroups.find( + (viewFilterGroup) => !viewFilterGroup.parentViewFilterGroupId, + )?.id; + + if (!outermostViewFilterGroupId) { + return null; + } + + return ( + + } + dropdownComponents={ + + } + dropdownHotkeyScope={{ scope: ADVANCED_FILTER_DROPDOWN_ID }} + dropdownOffset={{ y: 8, x: 0 }} + dropdownPlacement="bottom-start" + dropdownMenuWidth={800} + onClickOutside={handleDropdownClickOutside} + onClose={handleDropdownClose} + /> + ); +}; diff --git a/packages/twenty-front/src/modules/views/components/EditableFilterDropdownButton.tsx b/packages/twenty-front/src/modules/views/components/EditableFilterDropdownButton.tsx index b8b9fe56ffa4..67d068a16138 100644 --- a/packages/twenty-front/src/modules/views/components/EditableFilterDropdownButton.tsx +++ b/packages/twenty-front/src/modules/views/components/EditableFilterDropdownButton.tsx @@ -1,6 +1,5 @@ import { useCallback, useEffect } from 'react'; -import { MultipleFiltersDropdownContent } from '@/object-record/object-filter-dropdown/components/MultipleFiltersDropdownContent'; import { useFilterDropdown } from '@/object-record/object-filter-dropdown/hooks/useFilterDropdown'; import { Filter } from '@/object-record/object-filter-dropdown/types/Filter'; import { FilterOperand } from '@/object-record/object-filter-dropdown/types/FilterOperand'; @@ -11,6 +10,7 @@ import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/ import { EditableFilterChip } from '@/views/components/EditableFilterChip'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; +import { ObjectFilterOperandSelectAndInput } from '@/object-record/object-filter-dropdown/components/ObjectFilterOperandSelectAndInput'; import { useDeleteCombinedViewFilters } from '@/views/hooks/useDeleteCombinedViewFilters'; import { availableFilterDefinitionsComponentState } from '@/views/states/availableFilterDefinitionsComponentState'; import { isDefined } from '~/utils/isDefined'; @@ -98,7 +98,7 @@ export const EditableFilterDropdownButton = ({ } dropdownComponents={ - } diff --git a/packages/twenty-front/src/modules/views/components/QueryParamsViewIdEffect.tsx b/packages/twenty-front/src/modules/views/components/QueryParamsViewIdEffect.tsx index f306f29bd7d5..ced095e33adb 100644 --- a/packages/twenty-front/src/modules/views/components/QueryParamsViewIdEffect.tsx +++ b/packages/twenty-front/src/modules/views/components/QueryParamsViewIdEffect.tsx @@ -1,14 +1,14 @@ -import { contextStoreCurrentViewIdState } from '@/context-store/states/contextStoreCurrentViewIdState'; +import { contextStoreCurrentViewIdComponentState } from '@/context-store/states/contextStoreCurrentViewIdComponentState'; import { useLastVisitedObjectMetadataItem } from '@/navigation/hooks/useLastVisitedObjectMetadataItem'; import { useLastVisitedView } from '@/navigation/hooks/useLastVisitedView'; import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; import { useRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentStateV2'; +import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { useViewFromQueryParams } from '@/views/hooks/internal/useViewFromQueryParams'; import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState'; import { isUndefined } from '@sniptt/guards'; import { useEffect } from 'react'; -import { useSetRecoilState } from 'recoil'; import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; import { isDefined } from '~/utils/isDefined'; @@ -39,8 +39,8 @@ export const QueryParamsViewIdEffect = () => { objectMetadataItemId?.id, lastVisitedObjectMetadataItemId, ); - const setContextStoreCurrentViewId = useSetRecoilState( - contextStoreCurrentViewIdState, + const setContextStoreCurrentViewId = useSetRecoilComponentStateV2( + contextStoreCurrentViewIdComponentState, ); // // TODO: scope view bar per view id if possible diff --git a/packages/twenty-front/src/modules/views/components/SortOrFilterChip.tsx b/packages/twenty-front/src/modules/views/components/SortOrFilterChip.tsx index 1b55e6a32790..55c2a77f3c59 100644 --- a/packages/twenty-front/src/modules/views/components/SortOrFilterChip.tsx +++ b/packages/twenty-front/src/modules/views/components/SortOrFilterChip.tsx @@ -40,6 +40,7 @@ const StyledChip = styled.div<{ variant: SortOrFitlerChipVariant }>` font-size: ${({ theme }) => theme.font.size.sm}; font-weight: ${({ theme }) => theme.font.weight.medium}; padding: ${({ theme }) => theme.spacing(0.5) + ' ' + theme.spacing(2)}; + margin-left: ${({ theme }) => theme.spacing(2)}; user-select: none; white-space: nowrap; diff --git a/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx b/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx index ffe89f6b6b60..e9001faf7508 100644 --- a/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx +++ b/packages/twenty-front/src/modules/views/components/UpdateViewButtonGroup.tsx @@ -1,12 +1,15 @@ import styled from '@emotion/styled'; -import { IconChevronDown, IconPlus } from 'twenty-ui'; +import { + Button, + ButtonGroup, + IconChevronDown, + IconPlus, + MenuItem, +} from 'twenty-ui'; -import { Button } from '@/ui/input/button/components/Button'; -import { ButtonGroup } from '@/ui/input/button/components/ButtonGroup'; import { Dropdown } from '@/ui/layout/dropdown/components/Dropdown'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; -import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { HotkeyScope } from '@/ui/utilities/hotkey/types/HotkeyScope'; import { useRecoilComponentFamilyValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentFamilyValueV2'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; diff --git a/packages/twenty-front/src/modules/views/components/ViewBar.tsx b/packages/twenty-front/src/modules/views/components/ViewBar.tsx index 724b80e461c3..59642d58a301 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBar.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBar.tsx @@ -5,7 +5,7 @@ import { ObjectFilterDropdownButton } from '@/object-record/object-filter-dropdo import { FiltersHotkeyScope } from '@/object-record/object-filter-dropdown/types/FiltersHotkeyScope'; import { ObjectSortDropdownButton } from '@/object-record/object-sort-dropdown/components/ObjectSortDropdownButton'; import { useIsPrefetchLoading } from '@/prefetch/hooks/useIsPrefetchLoading'; -import { TopBar } from '@/ui/layout/top-bar/TopBar'; +import { TopBar } from '@/ui/layout/top-bar/components/TopBar'; import { QueryParamsFiltersEffect } from '@/views/components/QueryParamsFiltersEffect'; import { QueryParamsViewIdEffect } from '@/views/components/QueryParamsViewIdEffect'; import { ViewBarEffect } from '@/views/components/ViewBarEffect'; @@ -60,7 +60,6 @@ export const ViewBar = ({ leftComponent={ loading ? : } - displayBottomBorder={false} rightComponent={ <> viewFilter.variant && viewFilter.variant !== 'default', + (viewFilter) => + viewFilter.variant && + viewFilter.variant !== 'default' && + !viewFilter.viewFilterGroupId, ); const defaultViewFilters = currentViewWithCombinedFiltersAndSorts.viewFilters.filter( - (viewFilter) => !viewFilter.variant || viewFilter.variant === 'default', + (viewFilter) => + (!viewFilter.variant || viewFilter.variant === 'default') && + !viewFilter.viewFilterGroupId, ); return { @@ -166,6 +172,10 @@ export const ViewBarDetails = ({ return null; } + const showAdvancedFilterDropdownButton = + currentViewWithCombinedFiltersAndSorts?.viewFilterGroups && + currentViewWithCombinedFiltersAndSorts?.viewFilterGroups.length > 0; + return ( @@ -199,6 +209,7 @@ export const ViewBarDetails = ({ )} + {showAdvancedFilterDropdownButton && } {mapViewFiltersToFilters( defaultViewFilters, availableFilterDefinitions, diff --git a/packages/twenty-front/src/modules/views/components/ViewBarPageTitle.tsx b/packages/twenty-front/src/modules/views/components/ViewBarPageTitle.tsx index c7ab13c549b5..758a83b733be 100644 --- a/packages/twenty-front/src/modules/views/components/ViewBarPageTitle.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewBarPageTitle.tsx @@ -1,6 +1,6 @@ import { useParams } from 'react-router-dom'; -import { PageTitle } from '@/ui/utilities/page-title/PageTitle'; +import { PageTitle } from '@/ui/utilities/page-title/components/PageTitle'; import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; import { capitalize } from '~/utils/string/capitalize'; diff --git a/packages/twenty-front/src/modules/views/components/ViewFieldsVisibilityDropdownSection.tsx b/packages/twenty-front/src/modules/views/components/ViewFieldsVisibilityDropdownSection.tsx index d8ba50b090b7..15f3caa60b54 100644 --- a/packages/twenty-front/src/modules/views/components/ViewFieldsVisibilityDropdownSection.tsx +++ b/packages/twenty-front/src/modules/views/components/ViewFieldsVisibilityDropdownSection.tsx @@ -10,6 +10,7 @@ import { IconEye, IconEyeOff, IconInfoCircle, + MenuItemDraggable, useIcons, } from 'twenty-ui'; @@ -19,7 +20,6 @@ import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableIt import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { StyledDropdownMenuSubheader } from '@/ui/layout/dropdown/components/StyledDropdownMenuSubheader'; -import { MenuItemDraggable } from '@/ui/navigation/menu-item/components/MenuItemDraggable'; import { useListenClickOutside } from '@/ui/utilities/pointer-event/hooks/useListenClickOutside'; import { groupArrayItemsBy } from '~/utils/array/groupArrayItemsBy'; import { isDefined } from '~/utils/isDefined'; diff --git a/packages/twenty-front/src/modules/views/components/ViewGroupsVisibilityDropdownSection.tsx b/packages/twenty-front/src/modules/views/components/ViewGroupsVisibilityDropdownSection.tsx new file mode 100644 index 000000000000..795639f51c43 --- /dev/null +++ b/packages/twenty-front/src/modules/views/components/ViewGroupsVisibilityDropdownSection.tsx @@ -0,0 +1,191 @@ +import { + DropResult, + OnDragEndResponder, + ResponderProvided, +} from '@hello-pangea/dnd'; +import { useRef } from 'react'; +import { IconEye, IconEyeOff, MenuItemDraggable, Tag } from 'twenty-ui'; + +import { + RecordGroupDefinition, + RecordGroupDefinitionType, +} from '@/object-record/record-group/types/RecordGroupDefinition'; +import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; +import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; +import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; +import { StyledDropdownMenuSubheader } from '@/ui/layout/dropdown/components/StyledDropdownMenuSubheader'; +import { isDefined } from '~/utils/isDefined'; + +type ViewGroupsVisibilityDropdownSectionProps = { + viewGroups: RecordGroupDefinition[]; + isDraggable: boolean; + onDragEnd?: OnDragEndResponder; + onVisibilityChange: (viewGroup: RecordGroupDefinition) => void; + title: string; + showSubheader: boolean; + showDragGrip: boolean; +}; + +export const ViewGroupsVisibilityDropdownSection = ({ + viewGroups, + isDraggable, + onDragEnd, + onVisibilityChange, + title, + showSubheader = true, + showDragGrip, +}: ViewGroupsVisibilityDropdownSectionProps) => { + const handleOnDrag = (result: DropResult, provided: ResponderProvided) => { + onDragEnd?.(result, provided); + }; + + const getIconButtons = (index: number, viewGroup: RecordGroupDefinition) => { + const iconButtons = [ + { + Icon: viewGroup.isVisible ? IconEyeOff : IconEye, + onClick: () => onVisibilityChange(viewGroup), + }, + ].filter(isDefined); + + return iconButtons.length ? iconButtons : undefined; + }; + + const noValueViewGroups = + viewGroups.filter( + (viewGroup) => viewGroup.type === RecordGroupDefinitionType.NoValue, + ) ?? []; + + const viewGroupsWithoutNoValueGroups = viewGroups.filter( + (viewGroup) => viewGroup.type !== RecordGroupDefinitionType.NoValue, + ); + + const ref = useRef(null); + + return ( +
+ {showSubheader && ( + {title} + )} + + {!!viewGroups.length && ( + <> + {!isDraggable ? ( + viewGroupsWithoutNoValueGroups.map( + (viewGroup, viewGroupIndex) => ( + + } + iconButtons={getIconButtons(viewGroupIndex, viewGroup)} + accent={showDragGrip ? 'placeholder' : 'default'} + showGrip={showDragGrip} + isDragDisabled={!isDraggable} + /> + ), + ) + ) : ( + + {viewGroupsWithoutNoValueGroups.map( + (viewGroup, viewGroupIndex) => ( + + } + iconButtons={getIconButtons( + viewGroupIndex, + viewGroup, + )} + accent={showDragGrip ? 'placeholder' : 'default'} + showGrip={showDragGrip} + isDragDisabled={!isDraggable} + /> + } + /> + ), + )} + + } + /> + )} + {noValueViewGroups.map((viewGroup) => ( + + } + accent={showDragGrip ? 'placeholder' : 'default'} + showGrip={true} + isDragDisabled={true} + isHoverDisabled + /> + ))} + + )} + +
+ ); +}; diff --git a/packages/twenty-front/src/modules/views/constants/AdvancedFilterDropdownId.ts b/packages/twenty-front/src/modules/views/constants/AdvancedFilterDropdownId.ts new file mode 100644 index 000000000000..133be7b8d303 --- /dev/null +++ b/packages/twenty-front/src/modules/views/constants/AdvancedFilterDropdownId.ts @@ -0,0 +1 @@ +export const ADVANCED_FILTER_DROPDOWN_ID = 'advanced-filter'; diff --git a/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewFieldRecords.ts b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewFieldRecords.ts index 15327a303579..fce290183f7c 100644 --- a/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewFieldRecords.ts +++ b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewFieldRecords.ts @@ -1,5 +1,5 @@ -import { useCallback } from 'react'; import { useApolloClient } from '@apollo/client'; +import { useCallback } from 'react'; import { v4 } from 'uuid'; import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect'; diff --git a/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewFilterGroupRecords.ts b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewFilterGroupRecords.ts new file mode 100644 index 000000000000..dad022492c0c --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewFilterGroupRecords.ts @@ -0,0 +1,214 @@ +import { useApolloClient } from '@apollo/client'; +import { useCallback } from 'react'; + +import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect'; +import { triggerDestroyRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerDestroyRecordsOptimisticEffect'; +import { triggerUpdateRecordOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerUpdateRecordOptimisticEffect'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useGetRecordFromCache } from '@/object-record/cache/hooks/useGetRecordFromCache'; +import { useCreateOneRecordMutation } from '@/object-record/hooks/useCreateOneRecordMutation'; +import { useDestroyOneRecordMutation } from '@/object-record/hooks/useDestroyOneRecordMutation'; +import { useUpdateOneRecordMutation } from '@/object-record/hooks/useUpdateOneRecordMutation'; +import { ObjectRecord } from '@/object-record/types/ObjectRecord'; +import { GraphQLView } from '@/views/types/GraphQLView'; +import { ViewFilterGroup } from '@/views/types/ViewFilterGroup'; +import { isDefined } from 'twenty-ui'; + +export const usePersistViewFilterGroupRecords = () => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: CoreObjectNameSingular.ViewFilterGroup, + }); + + const getRecordFromCache = useGetRecordFromCache({ + objectNameSingular: CoreObjectNameSingular.ViewFilterGroup, + }); + + const { destroyOneRecordMutation } = useDestroyOneRecordMutation({ + objectNameSingular: CoreObjectNameSingular.ViewFilterGroup, + }); + + const { createOneRecordMutation } = useCreateOneRecordMutation({ + objectNameSingular: CoreObjectNameSingular.ViewFilterGroup, + }); + + const { updateOneRecordMutation } = useUpdateOneRecordMutation({ + objectNameSingular: CoreObjectNameSingular.ViewFilterGroup, + }); + + const { objectMetadataItems } = useObjectMetadataItems(); + + const apolloClient = useApolloClient(); + + const createViewFilterGroupRecord = useCallback( + async (viewFilterGroup: ViewFilterGroup, view: GraphQLView) => { + const result = await apolloClient.mutate<{ + createViewFilterGroup: ViewFilterGroup; + }>({ + mutation: createOneRecordMutation, + variables: { + input: { + id: viewFilterGroup.id, + viewId: view.id, + parentViewFilterGroupId: viewFilterGroup.parentViewFilterGroupId, + logicalOperator: viewFilterGroup.logicalOperator, + positionInViewFilterGroup: + viewFilterGroup.positionInViewFilterGroup, + }, + }, + update: (cache, { data }) => { + const record = data?.createViewFilterGroup; + if (!record) return; + + triggerCreateRecordsOptimisticEffect({ + cache, + objectMetadataItem, + recordsToCreate: [record], + objectMetadataItems, + }); + }, + }); + + if (!result.data) { + throw new Error('Failed to create view filter group'); + } + + return { newRecordId: result.data.createViewFilterGroup.id }; + }, + [ + apolloClient, + createOneRecordMutation, + objectMetadataItem, + objectMetadataItems, + ], + ); + + const createViewFilterGroupRecords = useCallback( + async (viewFilterGroupsToCreate: ViewFilterGroup[], view: GraphQLView) => { + if (!viewFilterGroupsToCreate.length) return []; + + const oldToNewId = new Map(); + + for (const viewFilterGroupToCreate of viewFilterGroupsToCreate) { + const newParentViewFilterGroupId = isDefined( + viewFilterGroupToCreate.parentViewFilterGroupId, + ) + ? (oldToNewId.get(viewFilterGroupToCreate.parentViewFilterGroupId) ?? + viewFilterGroupToCreate.parentViewFilterGroupId) + : undefined; + + const { newRecordId } = await createViewFilterGroupRecord( + { + ...viewFilterGroupToCreate, + parentViewFilterGroupId: newParentViewFilterGroupId, + }, + view, + ); + + oldToNewId.set(viewFilterGroupToCreate.id, newRecordId); + } + + const newRecordIds = viewFilterGroupsToCreate.map((viewFilterGroup) => { + const newId = oldToNewId.get(viewFilterGroup.id); + if (!newId) { + throw new Error('Failed to create view filter group'); + } + return newId; + }); + + return newRecordIds; + }, + [createViewFilterGroupRecord], + ); + + const updateViewFilterGroupRecords = useCallback( + (viewFilterGroupsToUpdate: ViewFilterGroup[]) => { + if (!viewFilterGroupsToUpdate.length) return; + return Promise.all( + viewFilterGroupsToUpdate.map((viewFilterGroup) => + apolloClient.mutate<{ updateViewFilterGroup: ViewFilterGroup }>({ + mutation: updateOneRecordMutation, + variables: { + idToUpdate: viewFilterGroup.id, + input: { + parentViewFilterGroupId: + viewFilterGroup.parentViewFilterGroupId, + logicalOperator: viewFilterGroup.logicalOperator, + positionInViewFilterGroup: + viewFilterGroup.positionInViewFilterGroup, + }, + }, + update: (cache, { data }) => { + const record = data?.updateViewFilterGroup; + if (!record) return; + const cachedRecord = getRecordFromCache(record.id); + + if (!cachedRecord) return; + + triggerUpdateRecordOptimisticEffect({ + cache, + objectMetadataItem, + currentRecord: cachedRecord, + updatedRecord: record, + objectMetadataItems, + }); + }, + }), + ), + ); + }, + [ + apolloClient, + getRecordFromCache, + objectMetadataItem, + objectMetadataItems, + updateOneRecordMutation, + ], + ); + + const deleteViewFilterGroupRecords = useCallback( + (viewFilterGroupIdsToDelete: string[]) => { + if (!viewFilterGroupIdsToDelete.length) return; + return Promise.all( + viewFilterGroupIdsToDelete.map((viewFilterGroupId) => + apolloClient.mutate<{ destroyViewFilterGroup: ViewFilterGroup }>({ + mutation: destroyOneRecordMutation, + variables: { + idToDestroy: viewFilterGroupId, + }, + update: (cache, { data }) => { + const record = data?.destroyViewFilterGroup; + + if (!record) return; + + const cachedRecord = getRecordFromCache(record.id, cache); + + if (!cachedRecord) return; + + triggerDestroyRecordsOptimisticEffect({ + cache, + objectMetadataItem, + recordsToDestroy: [cachedRecord], + objectMetadataItems, + }); + }, + }), + ), + ); + }, + [ + apolloClient, + destroyOneRecordMutation, + getRecordFromCache, + objectMetadataItem, + objectMetadataItems, + ], + ); + + return { + createViewFilterGroupRecords, + updateViewFilterGroupRecords, + deleteViewFilterGroupRecords, + }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewFilterRecords.ts b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewFilterRecords.ts index 488b33c31bd9..b9067aefe2c6 100644 --- a/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewFilterRecords.ts +++ b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewFilterRecords.ts @@ -50,11 +50,13 @@ export const usePersistViewFilterRecords = () => { mutation: createOneRecordMutation, variables: { input: { + id: viewFilter.id, fieldMetadataId: viewFilter.fieldMetadataId, viewId: view.id, value: viewFilter.value, displayValue: viewFilter.displayValue, operand: viewFilter.operand, + viewFilterGroupId: viewFilter.viewFilterGroupId, }, }, update: (cache, { data }) => { diff --git a/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroupRecords.ts b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroupRecords.ts new file mode 100644 index 000000000000..5582c771f3f9 --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/internal/usePersistViewGroupRecords.ts @@ -0,0 +1,118 @@ +import { useApolloClient } from '@apollo/client'; +import { useCallback } from 'react'; +import { v4 } from 'uuid'; + +import { triggerCreateRecordsOptimisticEffect } from '@/apollo/optimistic-effect/utils/triggerCreateRecordsOptimisticEffect'; +import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem'; +import { useObjectMetadataItems } from '@/object-metadata/hooks/useObjectMetadataItems'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useCreateOneRecordMutation } from '@/object-record/hooks/useCreateOneRecordMutation'; +import { useUpdateOneRecordMutation } from '@/object-record/hooks/useUpdateOneRecordMutation'; +import { GraphQLView } from '@/views/types/GraphQLView'; +import { ViewGroup } from '@/views/types/ViewGroup'; + +export const usePersistViewGroupRecords = () => { + const { objectMetadataItem } = useObjectMetadataItem({ + objectNameSingular: CoreObjectNameSingular.ViewGroup, + }); + + const { createOneRecordMutation } = useCreateOneRecordMutation({ + objectNameSingular: CoreObjectNameSingular.ViewGroup, + }); + + const { updateOneRecordMutation } = useUpdateOneRecordMutation({ + objectNameSingular: CoreObjectNameSingular.ViewGroup, + }); + + const { objectMetadataItems } = useObjectMetadataItems(); + + const apolloClient = useApolloClient(); + + const createViewGroupRecords = useCallback( + (viewGroupsToCreate: ViewGroup[], view: GraphQLView) => { + if (!viewGroupsToCreate.length) return; + + return Promise.all( + viewGroupsToCreate.map((viewGroup) => + apolloClient.mutate({ + mutation: createOneRecordMutation, + variables: { + input: { + fieldMetadataId: viewGroup.fieldMetadataId, + viewId: view.id, + isVisible: viewGroup.isVisible, + position: viewGroup.position, + id: v4(), + fieldValue: viewGroup.fieldValue, + }, + }, + update: (cache, { data }) => { + const record = data?.['createViewGroup']; + if (!record) return; + + triggerCreateRecordsOptimisticEffect({ + cache, + objectMetadataItem, + recordsToCreate: [record], + objectMetadataItems, + }); + }, + }), + ), + ); + }, + [ + apolloClient, + createOneRecordMutation, + objectMetadataItem, + objectMetadataItems, + ], + ); + + const updateViewGroupRecords = useCallback( + async (viewGroupsToUpdate: ViewGroup[]) => { + if (!viewGroupsToUpdate.length) return; + + const mutationPromises = viewGroupsToUpdate.map((viewGroup) => + apolloClient.mutate<{ updateViewGroup: ViewGroup }>({ + mutation: updateOneRecordMutation, + variables: { + idToUpdate: viewGroup.id, + input: { + isVisible: viewGroup.isVisible, + position: viewGroup.position, + }, + }, + // Avoid cache being updated with stale data + fetchPolicy: 'no-cache', + }), + ); + + const mutationResults = await Promise.all(mutationPromises); + + // FixMe: Using triggerCreateRecordsOptimisticEffect is actaully causing multiple records to be created + mutationResults.forEach(({ data }) => { + const record = data?.['updateViewGroup']; + + if (!record) return; + + apolloClient.cache.modify({ + id: apolloClient.cache.identify({ + __typename: 'ViewGroup', + id: record.id, + }), + fields: { + isVisible: () => record.isVisible, + position: () => record.position, + }, + }); + }); + }, + [apolloClient, updateOneRecordMutation], + ); + + return { + createViewGroupRecords, + updateViewGroupRecords, + }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/useCreateViewFiltersAndSorts.ts b/packages/twenty-front/src/modules/views/hooks/useCreateViewFiltersAndSorts.ts deleted file mode 100644 index 4f760b173c98..000000000000 --- a/packages/twenty-front/src/modules/views/hooks/useCreateViewFiltersAndSorts.ts +++ /dev/null @@ -1,34 +0,0 @@ -import { usePersistViewFilterRecords } from '@/views/hooks/internal/usePersistViewFilterRecords'; -import { usePersistViewSortRecords } from '@/views/hooks/internal/usePersistViewSortRecords'; - -import { useGetViewFromCache } from '@/views/hooks/useGetViewFromCache'; -import { ViewFilter } from '@/views/types/ViewFilter'; -import { ViewSort } from '@/views/types/ViewSort'; -import { isDefined } from '~/utils/isDefined'; - -export const useCreateViewFiltersAndSorts = () => { - const { getViewFromCache } = useGetViewFromCache(); - - const { createViewSortRecords } = usePersistViewSortRecords(); - - const { createViewFilterRecords } = usePersistViewFilterRecords(); - - const createViewFiltersAndSorts = async ( - viewIdToCreateOn: string, - filtersToCreate: ViewFilter[], - sortsToCreate: ViewSort[], - ) => { - const view = await getViewFromCache(viewIdToCreateOn); - - if (!isDefined(view)) { - return; - } - - await createViewSortRecords(sortsToCreate, view); - await createViewFilterRecords(filtersToCreate, view); - }; - - return { - createViewFiltersAndSorts, - }; -}; diff --git a/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts b/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts index fcf8a6c81647..c936ab87b37d 100644 --- a/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts +++ b/packages/twenty-front/src/modules/views/hooks/useCreateViewFromCurrentView.ts @@ -1,9 +1,14 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord'; +import { RecordIndexRootPropsContext } from '@/object-record/record-index/contexts/RecordIndexRootPropsContext'; import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; import { usePersistViewFieldRecords } from '@/views/hooks/internal/usePersistViewFieldRecords'; -import { useCreateViewFiltersAndSorts } from '@/views/hooks/useCreateViewFiltersAndSorts'; +import { usePersistViewFilterGroupRecords } from '@/views/hooks/internal/usePersistViewFilterGroupRecords'; +import { usePersistViewFilterRecords } from '@/views/hooks/internal/usePersistViewFilterRecords'; +import { usePersistViewGroupRecords } from '@/views/hooks/internal/usePersistViewGroupRecords'; +import { usePersistViewSortRecords } from '@/views/hooks/internal/usePersistViewSortRecords'; +import { useGetViewFilterGroupsCombined } from '@/views/hooks/useGetCombinedViewFilterGroups'; import { useGetViewFiltersCombined } from '@/views/hooks/useGetCombinedViewFilters'; import { useGetViewSortsCombined } from '@/views/hooks/useGetCombinedViewSorts'; import { useGetViewFromCache } from '@/views/hooks/useGetViewFromCache'; @@ -11,6 +16,9 @@ import { currentViewIdComponentState } from '@/views/states/currentViewIdCompone import { isPersistingViewFieldsComponentState } from '@/views/states/isPersistingViewFieldsComponentState'; import { GraphQLView } from '@/views/types/GraphQLView'; import { View } from '@/views/types/View'; +import { ViewGroup } from '@/views/types/ViewGroup'; +import { ViewType } from '@/views/types/ViewType'; +import { useContext } from 'react'; import { useRecoilCallback } from 'recoil'; import { isDefined } from 'twenty-ui'; import { v4 } from 'uuid'; @@ -35,11 +43,21 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { const { createViewFieldRecords } = usePersistViewFieldRecords(); - const { createViewFiltersAndSorts } = useCreateViewFiltersAndSorts(); - const { getViewSortsCombined } = useGetViewSortsCombined(viewBarComponentId); const { getViewFiltersCombined } = useGetViewFiltersCombined(viewBarComponentId); + const { getViewFilterGroupsCombined } = + useGetViewFilterGroupsCombined(viewBarComponentId); + + const { createViewSortRecords } = usePersistViewSortRecords(); + + const { createViewGroupRecords } = usePersistViewGroupRecords(); + + const { createViewFilterRecords } = usePersistViewFilterRecords(); + + const { createViewFilterGroupRecords } = usePersistViewFilterGroupRecords(); + + const { objectMetadataItem } = useContext(RecordIndexRootPropsContext); const createViewFromCurrentView = useRecoilCallback( ({ snapshot, set }) => @@ -68,9 +86,9 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { } // Here we might instead want to get view from unsaved filters ? - const view = await getViewFromCache(currentViewId); + const sourceView = await getViewFromCache(currentViewId); - if (!isDefined(view)) { + if (!isDefined(sourceView)) { return; } @@ -78,43 +96,86 @@ export const useCreateViewFromCurrentView = (viewBarComponentId?: string) => { const newView = await createOneRecord({ id: id ?? v4(), - name: name ?? view.name, - icon: icon ?? view.icon, + name: name ?? sourceView.name, + icon: icon ?? sourceView.icon, key: null, kanbanFieldMetadataId: - kanbanFieldMetadataId ?? view.kanbanFieldMetadataId, - type: type ?? view.type, - objectMetadataId: view.objectMetadataId, + kanbanFieldMetadataId ?? sourceView.kanbanFieldMetadataId, + type: type ?? sourceView.type, + objectMetadataId: sourceView.objectMetadataId, }); if (isUndefinedOrNull(newView)) { throw new Error('Failed to create view'); } - await createViewFieldRecords(view.viewFields, newView); + await createViewFieldRecords(sourceView.viewFields, newView); + + if (type === ViewType.Kanban) { + if (!isDefined(kanbanFieldMetadataId)) { + throw new Error('Kanban view must have a kanban field'); + } + + const viewGroupsToCreate = + objectMetadataItem?.fields + ?.find((field) => field.id === kanbanFieldMetadataId) + ?.options?.map( + (option, index) => + ({ + id: v4(), + __typename: 'ViewGroup', + fieldMetadataId: kanbanFieldMetadataId, + fieldValue: option.value, + isVisible: true, + position: index, + }) satisfies ViewGroup, + ) ?? []; + + viewGroupsToCreate.push({ + __typename: 'ViewGroup', + id: v4(), + fieldValue: '', + position: viewGroupsToCreate.length, + isVisible: true, + fieldMetadataId: kanbanFieldMetadataId, + } satisfies ViewGroup); + + await createViewGroupRecords(viewGroupsToCreate, newView); + } if (shouldCopyFiltersAndSorts === true) { - const sourceViewCombinedFilters = getViewFiltersCombined(view.id); - const sourceViewCombinedSorts = getViewSortsCombined(view.id); + const sourceViewCombinedFilterGroups = getViewFilterGroupsCombined( + sourceView.id, + ); + const sourceViewCombinedFilters = getViewFiltersCombined( + sourceView.id, + ); + const sourceViewCombinedSorts = getViewSortsCombined(sourceView.id); - await createViewFiltersAndSorts( - newView.id, - sourceViewCombinedFilters, - sourceViewCombinedSorts, + await createViewSortRecords(sourceViewCombinedSorts, newView); + await createViewFilterRecords(sourceViewCombinedFilters, newView); + await createViewFilterGroupRecords( + sourceViewCombinedFilterGroups, + newView, ); } set(isPersistingViewFieldsCallbackState, false); }, [ + objectMetadataItem, + createViewSortRecords, + createViewFilterRecords, createOneRecord, createViewFieldRecords, getViewSortsCombined, getViewFiltersCombined, + getViewFilterGroupsCombined, currentViewIdCallbackState, getViewFromCache, isPersistingViewFieldsCallbackState, - createViewFiltersAndSorts, + createViewGroupRecords, + createViewFilterGroupRecords, ], ); diff --git a/packages/twenty-front/src/modules/views/hooks/useGetCombinedViewFilterGroups.ts b/packages/twenty-front/src/modules/views/hooks/useGetCombinedViewFilterGroups.ts new file mode 100644 index 000000000000..e235fd79d04a --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useGetCombinedViewFilterGroups.ts @@ -0,0 +1,67 @@ +import { useRecoilCallback } from 'recoil'; + +import { usePrefetchedData } from '@/prefetch/hooks/usePrefetchedData'; +import { PrefetchKey } from '@/prefetch/types/PrefetchKey'; +import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { unsavedToDeleteViewFilterGroupIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterGroupIdsComponentFamilyState'; +import { unsavedToUpsertViewFilterGroupsComponentFamilyState } from '@/views/states/unsavedToUpsertViewFilterGroupsComponentFamilyState'; +import { View } from '@/views/types/View'; +import { getCombinedViewFilterGroups } from '@/views/utils/getCombinedViewFilterGroups'; +import { isDefined } from '~/utils/isDefined'; + +export const useGetViewFilterGroupsCombined = (viewBarComponentId?: string) => { + const { records: views } = usePrefetchedData(PrefetchKey.AllViews); + + const unsavedToUpsertViewFilterGroupsCallbackState = + useRecoilComponentCallbackStateV2( + unsavedToUpsertViewFilterGroupsComponentFamilyState, + viewBarComponentId, + ); + + const unsavedToDeleteViewFilterGroupIdsCallbackState = + useRecoilComponentCallbackStateV2( + unsavedToDeleteViewFilterGroupIdsComponentFamilyState, + viewBarComponentId, + ); + + const getViewFilterGroupsCombined = useRecoilCallback( + ({ snapshot }) => + (viewId: string) => { + const view = views.find((view) => view.id === viewId); + + if (!isDefined(view)) { + throw new Error( + `Cannot get view with id ${viewId}, because it cannot be found in client cache data.`, + ); + } + + const unsavedToUpsertViewFilterGroups = getSnapshotValue( + snapshot, + unsavedToUpsertViewFilterGroupsCallbackState({ viewId: view.id }), + ); + + const unsavedToDeleteViewFilterGroupIds = getSnapshotValue( + snapshot, + unsavedToDeleteViewFilterGroupIdsCallbackState({ viewId: view.id }), + ); + + const combinedViewFilterGroups = getCombinedViewFilterGroups( + view.viewFilterGroups ?? [], + unsavedToUpsertViewFilterGroups, + unsavedToDeleteViewFilterGroupIds, + ); + + return combinedViewFilterGroups; + }, + [ + views, + unsavedToDeleteViewFilterGroupIdsCallbackState, + unsavedToUpsertViewFilterGroupsCallbackState, + ], + ); + + return { + getViewFilterGroupsCombined, + }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/useGetCurrentView.ts b/packages/twenty-front/src/modules/views/hooks/useGetCurrentView.ts index 1ea911ba9024..80d1d4c7b84f 100644 --- a/packages/twenty-front/src/modules/views/hooks/useGetCurrentView.ts +++ b/packages/twenty-front/src/modules/views/hooks/useGetCurrentView.ts @@ -9,12 +9,15 @@ import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-sta import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState'; import { isCurrentViewKeyIndexComponentState } from '@/views/states/isCurrentViewIndexComponentState'; +import { unsavedToDeleteViewFilterGroupIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterGroupIdsComponentFamilyState'; import { unsavedToDeleteViewFilterIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterIdsComponentFamilyState'; import { unsavedToDeleteViewSortIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewSortIdsComponentFamilyState'; +import { unsavedToUpsertViewFilterGroupsComponentFamilyState } from '@/views/states/unsavedToUpsertViewFilterGroupsComponentFamilyState'; import { unsavedToUpsertViewFiltersComponentFamilyState } from '@/views/states/unsavedToUpsertViewFiltersComponentFamilyState'; import { unsavedToUpsertViewSortsComponentFamilyState } from '@/views/states/unsavedToUpsertViewSortsComponentFamilyState'; import { viewObjectMetadataIdComponentState } from '@/views/states/viewObjectMetadataIdComponentState'; import { View } from '@/views/types/View'; +import { getCombinedViewFilterGroups } from '@/views/utils/getCombinedViewFilterGroups'; import { getCombinedViewFilters } from '@/views/utils/getCombinedViewFilters'; import { getCombinedViewSorts } from '@/views/utils/getCombinedViewSorts'; import { getObjectMetadataItemViews } from '@/views/utils/getObjectMetadataItemViews'; @@ -70,6 +73,12 @@ export const useGetCurrentView = (viewBarInstanceId?: string) => { instanceId, ); + const unsavedToUpsertViewFilterGroups = useRecoilComponentFamilyValueV2( + unsavedToUpsertViewFilterGroupsComponentFamilyState, + { viewId }, + instanceId, + ); + const unsavedToUpsertViewSorts = useRecoilComponentFamilyValueV2( unsavedToUpsertViewSortsComponentFamilyState, { viewId }, @@ -82,6 +91,12 @@ export const useGetCurrentView = (viewBarInstanceId?: string) => { instanceId, ); + const unsavedToDeleteViewFilterGroupIds = useRecoilComponentFamilyValueV2( + unsavedToDeleteViewFilterGroupIdsComponentFamilyState, + { viewId }, + instanceId, + ); + const unsavedToDeleteViewSortIds = useRecoilComponentFamilyValueV2( unsavedToDeleteViewSortIdsComponentFamilyState, { viewId }, @@ -104,6 +119,11 @@ export const useGetCurrentView = (viewBarInstanceId?: string) => { unsavedToUpsertViewFilters, unsavedToDeleteViewFilterIds, ), + viewFilterGroups: getCombinedViewFilterGroups( + currentView.viewFilterGroups ?? [], + unsavedToUpsertViewFilterGroups, + unsavedToDeleteViewFilterGroupIds, + ), viewSorts: getCombinedViewSorts( currentView.viewSorts, unsavedToUpsertViewSorts, diff --git a/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromActiveFieldsOfViewOrDefaultView.ts b/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromActiveFieldsOfViewOrDefaultView.ts index f5431bc45a58..d40afd1064a9 100644 --- a/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromActiveFieldsOfViewOrDefaultView.ts +++ b/packages/twenty-front/src/modules/views/hooks/useQueryVariablesFromActiveFieldsOfViewOrDefaultView.ts @@ -2,6 +2,7 @@ import { useActiveFieldMetadataItems } from '@/object-metadata/hooks/useActiveFi import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { useViewOrDefaultViewFromPrefetchedViews } from '@/views/hooks/useViewOrDefaultViewFromPrefetchedViews'; import { getQueryVariablesFromView } from '@/views/utils/getQueryVariablesFromView'; +import { useIsFeatureEnabled } from '@/workspace/hooks/useIsFeatureEnabled'; export const useQueryVariablesFromActiveFieldsOfViewOrDefaultView = ({ objectMetadataItem, @@ -19,10 +20,15 @@ export const useQueryVariablesFromActiveFieldsOfViewOrDefaultView = ({ objectMetadataItem, }); + const isArrayAndJsonFilterEnabled = useIsFeatureEnabled( + 'IS_ARRAY_AND_JSON_FILTER_ENABLED', + ); + const { filter, orderBy } = getQueryVariablesFromView({ fieldMetadataItems: activeFieldMetadataItems, objectMetadataItem, view, + isArrayAndJsonFilterEnabled, }); return { diff --git a/packages/twenty-front/src/modules/views/hooks/useResetUnsavedViewStates.ts b/packages/twenty-front/src/modules/views/hooks/useResetUnsavedViewStates.ts index f3d3039bec92..e325a98e0df2 100644 --- a/packages/twenty-front/src/modules/views/hooks/useResetUnsavedViewStates.ts +++ b/packages/twenty-front/src/modules/views/hooks/useResetUnsavedViewStates.ts @@ -1,6 +1,8 @@ import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { unsavedToDeleteViewFilterGroupIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterGroupIdsComponentFamilyState'; import { unsavedToDeleteViewFilterIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterIdsComponentFamilyState'; import { unsavedToDeleteViewSortIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewSortIdsComponentFamilyState'; +import { unsavedToUpsertViewFilterGroupsComponentFamilyState } from '@/views/states/unsavedToUpsertViewFilterGroupsComponentFamilyState'; import { unsavedToUpsertViewFiltersComponentFamilyState } from '@/views/states/unsavedToUpsertViewFiltersComponentFamilyState'; import { unsavedToUpsertViewSortsComponentFamilyState } from '@/views/states/unsavedToUpsertViewSortsComponentFamilyState'; import { useRecoilCallback } from 'recoil'; @@ -18,6 +20,12 @@ export const useResetUnsavedViewStates = (viewBarInstanceId?: string) => { viewBarInstanceId, ); + const unsavedToDeleteViewFilterGroupIdsCallbackState = + useRecoilComponentCallbackStateV2( + unsavedToDeleteViewFilterGroupIdsComponentFamilyState, + viewBarInstanceId, + ); + const setUnsavedToUpsertViewFiltersCallbackState = useRecoilComponentCallbackStateV2( unsavedToUpsertViewFiltersComponentFamilyState, @@ -30,19 +38,29 @@ export const useResetUnsavedViewStates = (viewBarInstanceId?: string) => { viewBarInstanceId, ); + const unsavedToUpsertViewFilterGroupsCallbackState = + useRecoilComponentCallbackStateV2( + unsavedToUpsertViewFilterGroupsComponentFamilyState, + viewBarInstanceId, + ); + const resetUnsavedViewStates = useRecoilCallback( ({ set }) => (viewId: string) => { + set(unsavedToDeleteViewFilterGroupIdsCallbackState({ viewId }), []); set(setUnsavedToDeleteViewFilterIdsCallbackState({ viewId }), []); set(setUnsavedToDeleteViewSortIdsCallbackState({ viewId }), []); + set(unsavedToUpsertViewFilterGroupsCallbackState({ viewId }), []); set(setUnsavedToUpsertViewFiltersCallbackState({ viewId }), []); set(unsavedToUpsertViewSortsCallbackState({ viewId }), []); }, [ unsavedToUpsertViewSortsCallbackState, setUnsavedToUpsertViewFiltersCallbackState, + unsavedToUpsertViewFilterGroupsCallbackState, setUnsavedToDeleteViewSortIdsCallbackState, setUnsavedToDeleteViewFilterIdsCallbackState, + unsavedToDeleteViewFilterGroupIdsCallbackState, ], ); diff --git a/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewFiltersAndSorts.ts b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewFiltersAndSorts.ts index 3b7fdeaebdf0..f2f94e240e15 100644 --- a/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewFiltersAndSorts.ts +++ b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewFiltersAndSorts.ts @@ -2,13 +2,16 @@ import { useRecoilCallback } from 'recoil'; import { getSnapshotValue } from '@/ui/utilities/recoil-scope/utils/getSnapshotValue'; import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { usePersistViewFilterGroupRecords } from '@/views/hooks/internal/usePersistViewFilterGroupRecords'; import { usePersistViewFilterRecords } from '@/views/hooks/internal/usePersistViewFilterRecords'; import { usePersistViewSortRecords } from '@/views/hooks/internal/usePersistViewSortRecords'; import { useGetViewFromCache } from '@/views/hooks/useGetViewFromCache'; import { useResetUnsavedViewStates } from '@/views/hooks/useResetUnsavedViewStates'; import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState'; +import { unsavedToDeleteViewFilterGroupIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterGroupIdsComponentFamilyState'; import { unsavedToDeleteViewFilterIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterIdsComponentFamilyState'; import { unsavedToDeleteViewSortIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewSortIdsComponentFamilyState'; +import { unsavedToUpsertViewFilterGroupsComponentFamilyState } from '@/views/states/unsavedToUpsertViewFilterGroupsComponentFamilyState'; import { unsavedToUpsertViewFiltersComponentFamilyState } from '@/views/states/unsavedToUpsertViewFiltersComponentFamilyState'; import { unsavedToUpsertViewSortsComponentFamilyState } from '@/views/states/unsavedToUpsertViewSortsComponentFamilyState'; import { isDefined } from '~/utils/isDefined'; @@ -48,6 +51,18 @@ export const useSaveCurrentViewFiltersAndSorts = ( viewBarComponentId, ); + const unsavedToUpsertViewFilterGroupsCallbackState = + useRecoilComponentCallbackStateV2( + unsavedToUpsertViewFilterGroupsComponentFamilyState, + viewBarComponentId, + ); + + const unsavedToDeleteViewFilterGroupIdsCallbackState = + useRecoilComponentCallbackStateV2( + unsavedToDeleteViewFilterGroupIdsComponentFamilyState, + viewBarComponentId, + ); + const { createViewSortRecords, updateViewSortRecords, @@ -60,6 +75,12 @@ export const useSaveCurrentViewFiltersAndSorts = ( deleteViewFilterRecords, } = usePersistViewFilterRecords(); + const { + createViewFilterGroupRecords, + deleteViewFilterGroupRecords, + updateViewFilterGroupRecords, + } = usePersistViewFilterGroupRecords(); + const { resetUnsavedViewStates } = useResetUnsavedViewStates(viewBarComponentId); @@ -131,14 +152,14 @@ export const useSaveCurrentViewFiltersAndSorts = ( const viewFiltersToCreate = unsavedToUpsertViewFilters.filter( (viewFilter) => !view.viewFilters.some( - (vf) => vf.fieldMetadataId === viewFilter.fieldMetadataId, + (viewFilterToFilter) => viewFilterToFilter.id === viewFilter.id, ), ); const viewFiltersToUpdate = unsavedToUpsertViewFilters.filter( (viewFilter) => view.viewFilters.some( - (vf) => vf.fieldMetadataId === viewFilter.fieldMetadataId, + (viewFilterToFilter) => viewFilterToFilter.id === viewFilter.id, ), ); @@ -156,6 +177,55 @@ export const useSaveCurrentViewFiltersAndSorts = ( ], ); + const saveViewFilterGroups = useRecoilCallback( + ({ snapshot }) => + async (viewId: string) => { + const unsavedToDeleteViewFilterGroupIds = getSnapshotValue( + snapshot, + unsavedToDeleteViewFilterGroupIdsCallbackState({ viewId }), + ); + + const unsavedToUpsertViewFilterGroups = getSnapshotValue( + snapshot, + unsavedToUpsertViewFilterGroupsCallbackState({ viewId }), + ); + + const view = await getViewFromCache(viewId); + + if (isUndefinedOrNull(view)) { + return; + } + + const viewFilterGroupsToCreate = unsavedToUpsertViewFilterGroups.filter( + (viewFilterGroup) => + !view.viewFilterGroups?.some( + (viewFilterGroupToFilter) => + viewFilterGroupToFilter.id === viewFilterGroup.id, + ), + ); + + const viewFilterGroupsToUpdate = unsavedToUpsertViewFilterGroups.filter( + (viewFilterGroup) => + view.viewFilterGroups?.some( + (viewFilterGroupToFilter) => + viewFilterGroupToFilter.id === viewFilterGroup.id, + ), + ); + + await createViewFilterGroupRecords(viewFilterGroupsToCreate, view); + await updateViewFilterGroupRecords(viewFilterGroupsToUpdate); + await deleteViewFilterGroupRecords(unsavedToDeleteViewFilterGroupIds); + }, + [ + getViewFromCache, + createViewFilterGroupRecords, + deleteViewFilterGroupRecords, + unsavedToDeleteViewFilterGroupIdsCallbackState, + unsavedToUpsertViewFilterGroupsCallbackState, + updateViewFilterGroupRecords, + ], + ); + const saveCurrentViewFilterAndSorts = useRecoilCallback( ({ snapshot }) => async (viewIdFromProps?: string) => { @@ -169,6 +239,7 @@ export const useSaveCurrentViewFiltersAndSorts = ( const viewId = viewIdFromProps ?? currentViewId; + await saveViewFilterGroups(viewId); await saveViewFilters(viewId); await saveViewSorts(viewId); @@ -179,6 +250,7 @@ export const useSaveCurrentViewFiltersAndSorts = ( resetUnsavedViewStates, saveViewFilters, saveViewSorts, + saveViewFilterGroups, ], ); diff --git a/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts new file mode 100644 index 000000000000..384b2628414f --- /dev/null +++ b/packages/twenty-front/src/modules/views/hooks/useSaveCurrentViewGroups.ts @@ -0,0 +1,96 @@ +import { useRecoilCallback } from 'recoil'; + +import { useRecoilComponentCallbackStateV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentCallbackStateV2'; +import { usePersistViewGroupRecords } from '@/views/hooks/internal/usePersistViewGroupRecords'; +import { useGetViewFromCache } from '@/views/hooks/useGetViewFromCache'; +import { currentViewIdComponentState } from '@/views/states/currentViewIdComponentState'; +import { ViewGroup } from '@/views/types/ViewGroup'; +import { isDeeplyEqual } from '~/utils/isDeeplyEqual'; +import { isDefined } from '~/utils/isDefined'; +import { isUndefinedOrNull } from '~/utils/isUndefinedOrNull'; + +export const useSaveCurrentViewGroups = (viewBarComponentId?: string) => { + const { createViewGroupRecords, updateViewGroupRecords } = + usePersistViewGroupRecords(); + + const { getViewFromCache } = useGetViewFromCache(); + + const currentViewIdCallbackState = useRecoilComponentCallbackStateV2( + currentViewIdComponentState, + viewBarComponentId, + ); + + const saveViewGroups = useRecoilCallback( + ({ snapshot }) => + async (viewGroupsToSave: ViewGroup[]) => { + const currentViewId = snapshot + .getLoadable(currentViewIdCallbackState) + .getValue(); + + if (!currentViewId) { + return; + } + + const view = await getViewFromCache(currentViewId); + + if (isUndefinedOrNull(view)) { + return; + } + + const currentViewGroups = view.viewGroups; + + const viewGroupsToUpdate = viewGroupsToSave + .map((viewGroupToSave) => { + const existingField = currentViewGroups.find( + (currentViewGroup) => + currentViewGroup.fieldValue === viewGroupToSave.fieldValue, + ); + + if (isUndefinedOrNull(existingField)) { + return undefined; + } + + if ( + isDeeplyEqual( + { + position: existingField.position, + isVisible: existingField.isVisible, + }, + { + position: viewGroupToSave.position, + isVisible: viewGroupToSave.isVisible, + }, + ) + ) { + return undefined; + } + + return { ...viewGroupToSave, id: existingField.id }; + }) + .filter(isDefined); + + const viewGroupsToCreate = viewGroupsToSave.filter( + (viewFieldToSave) => + !currentViewGroups.some( + (currentViewGroup) => + currentViewGroup.fieldValue === viewFieldToSave.fieldValue, + ), + ); + + await Promise.all([ + createViewGroupRecords(viewGroupsToCreate, view), + updateViewGroupRecords(viewGroupsToUpdate), + ]); + }, + [ + createViewGroupRecords, + currentViewIdCallbackState, + getViewFromCache, + updateViewGroupRecords, + ], + ); + + return { + saveViewGroups, + }; +}; diff --git a/packages/twenty-front/src/modules/views/hooks/useUpsertCombinedViewFilters.ts b/packages/twenty-front/src/modules/views/hooks/useUpsertCombinedViewFilters.ts index 7c5e2d99b956..bc4667e5841c 100644 --- a/packages/twenty-front/src/modules/views/hooks/useUpsertCombinedViewFilters.ts +++ b/packages/twenty-front/src/modules/views/hooks/useUpsertCombinedViewFilters.ts @@ -8,6 +8,7 @@ import { currentViewIdComponentState } from '@/views/states/currentViewIdCompone import { unsavedToDeleteViewFilterIdsComponentFamilyState } from '@/views/states/unsavedToDeleteViewFilterIdsComponentFamilyState'; import { unsavedToUpsertViewFiltersComponentFamilyState } from '@/views/states/unsavedToUpsertViewFiltersComponentFamilyState'; import { ViewFilter } from '@/views/types/ViewFilter'; +import { shouldReplaceFilter } from '@/views/utils/shouldReplaceFilter'; import { isDefined } from '~/utils/isDefined'; export const useUpsertCombinedViewFilters = (viewBarComponentId?: string) => { @@ -59,19 +60,16 @@ export const useUpsertCombinedViewFilters = (viewBarComponentId?: string) => { } const matchingFilterInCurrentView = currentView.viewFilters.find( - (viewFilter) => - viewFilter.fieldMetadataId === upsertedFilter.fieldMetadataId, + (viewFilter) => shouldReplaceFilter(viewFilter, upsertedFilter), ); const matchingFilterInUnsavedFilters = unsavedToUpsertViewFilters.find( - (viewFilter) => - viewFilter.fieldMetadataId === upsertedFilter.fieldMetadataId, + (viewFilter) => shouldReplaceFilter(viewFilter, upsertedFilter), ); if (isDefined(matchingFilterInUnsavedFilters)) { const updatedFilters = unsavedToUpsertViewFilters.map((viewFilter) => - viewFilter.fieldMetadataId === - matchingFilterInUnsavedFilters.fieldMetadataId + shouldReplaceFilter(viewFilter, matchingFilterInUnsavedFilters) ? { ...viewFilter, ...upsertedFilter, id: viewFilter.id } : viewFilter, ); diff --git a/packages/twenty-front/src/modules/views/states/unsavedToDeleteViewFilterGroupIdsComponentFamilyState.ts b/packages/twenty-front/src/modules/views/states/unsavedToDeleteViewFilterGroupIdsComponentFamilyState.ts new file mode 100644 index 000000000000..c9609c830915 --- /dev/null +++ b/packages/twenty-front/src/modules/views/states/unsavedToDeleteViewFilterGroupIdsComponentFamilyState.ts @@ -0,0 +1,9 @@ +import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; + +export const unsavedToDeleteViewFilterGroupIdsComponentFamilyState = + createComponentFamilyStateV2({ + key: 'unsavedToDeleteViewFilterGroupIdsComponentFamilyState', + defaultValue: [], + componentInstanceContext: ViewComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/views/states/unsavedToUpsertViewFilterGroupsComponentFamilyState.ts b/packages/twenty-front/src/modules/views/states/unsavedToUpsertViewFilterGroupsComponentFamilyState.ts new file mode 100644 index 000000000000..a43aae2f16c5 --- /dev/null +++ b/packages/twenty-front/src/modules/views/states/unsavedToUpsertViewFilterGroupsComponentFamilyState.ts @@ -0,0 +1,10 @@ +import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2'; +import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; +import { ViewFilterGroup } from '@/views/types/ViewFilterGroup'; + +export const unsavedToUpsertViewFilterGroupsComponentFamilyState = + createComponentFamilyStateV2({ + key: 'unsavedToUpsertViewFilterGroupsComponentFamilyState', + defaultValue: [], + componentInstanceContext: ViewComponentInstanceContext, + }); diff --git a/packages/twenty-front/src/modules/views/states/unsavedToUpsertViewFiltersComponentFamilyState.ts b/packages/twenty-front/src/modules/views/states/unsavedToUpsertViewFiltersComponentFamilyState.ts index bf48b10708cf..f0f8b380ff35 100644 --- a/packages/twenty-front/src/modules/views/states/unsavedToUpsertViewFiltersComponentFamilyState.ts +++ b/packages/twenty-front/src/modules/views/states/unsavedToUpsertViewFiltersComponentFamilyState.ts @@ -1,6 +1,6 @@ import { createComponentFamilyStateV2 } from '@/ui/utilities/state/component-state/utils/createComponentFamilyStateV2'; import { ViewComponentInstanceContext } from '@/views/states/contexts/ViewComponentInstanceContext'; -import { ViewFilter } from '../types/ViewFilter'; +import { ViewFilter } from '@/views/types/ViewFilter'; export const unsavedToUpsertViewFiltersComponentFamilyState = createComponentFamilyStateV2({ diff --git a/packages/twenty-front/src/modules/views/types/GraphQLView.ts b/packages/twenty-front/src/modules/views/types/GraphQLView.ts index c657f83b5749..cb8729f91564 100644 --- a/packages/twenty-front/src/modules/views/types/GraphQLView.ts +++ b/packages/twenty-front/src/modules/views/types/GraphQLView.ts @@ -1,5 +1,7 @@ import { ViewField } from '@/views/types/ViewField'; import { ViewFilter } from '@/views/types/ViewFilter'; +import { ViewFilterGroup } from '@/views/types/ViewFilterGroup'; +import { ViewGroup } from '@/views/types/ViewGroup'; import { ViewKey } from '@/views/types/ViewKey'; import { ViewSort } from '@/views/types/ViewSort'; import { ViewType } from '@/views/types/ViewType'; @@ -14,7 +16,9 @@ export type GraphQLView = { isCompact: boolean; viewFields: ViewField[]; viewFilters: ViewFilter[]; + viewFilterGroups?: ViewFilterGroup[]; viewSorts: ViewSort[]; + viewGroups: ViewGroup[]; position: number; icon: string; }; diff --git a/packages/twenty-front/src/modules/views/types/View.ts b/packages/twenty-front/src/modules/views/types/View.ts index a3c9cac58b83..111c5a5002ed 100644 --- a/packages/twenty-front/src/modules/views/types/View.ts +++ b/packages/twenty-front/src/modules/views/types/View.ts @@ -1,5 +1,7 @@ import { ViewField } from '@/views/types/ViewField'; import { ViewFilter } from '@/views/types/ViewFilter'; +import { ViewFilterGroup } from '@/views/types/ViewFilterGroup'; +import { ViewGroup } from '@/views/types/ViewGroup'; import { ViewKey } from '@/views/types/ViewKey'; import { ViewSort } from '@/views/types/ViewSort'; import { ViewType } from '@/views/types/ViewType'; @@ -12,7 +14,9 @@ export type View = { objectMetadataId: string; isCompact: boolean; viewFields: ViewField[]; + viewGroups: ViewGroup[]; viewFilters: ViewFilter[]; + viewFilterGroups?: ViewFilterGroup[]; viewSorts: ViewSort[]; kanbanFieldMetadataId: string; position: number; diff --git a/packages/twenty-front/src/modules/views/types/ViewFilter.ts b/packages/twenty-front/src/modules/views/types/ViewFilter.ts index f5175cf41433..a44cab7aee67 100644 --- a/packages/twenty-front/src/modules/views/types/ViewFilter.ts +++ b/packages/twenty-front/src/modules/views/types/ViewFilter.ts @@ -12,5 +12,7 @@ export type ViewFilter = { createdAt?: string; updatedAt?: string; viewId?: string; + viewFilterGroupId?: string; + positionInViewFilterGroup?: number | null; definition?: FilterDefinition; }; diff --git a/packages/twenty-front/src/modules/views/types/ViewFilterGroup.ts b/packages/twenty-front/src/modules/views/types/ViewFilterGroup.ts new file mode 100644 index 000000000000..b17719946eef --- /dev/null +++ b/packages/twenty-front/src/modules/views/types/ViewFilterGroup.ts @@ -0,0 +1,10 @@ +import { ViewFilterGroupLogicalOperator } from '@/views/types/ViewFilterGroupLogicalOperator'; + +export type ViewFilterGroup = { + __typename: 'ViewFilterGroup'; + id: string; + viewId: string; + parentViewFilterGroupId?: string | null; + logicalOperator: ViewFilterGroupLogicalOperator; + positionInViewFilterGroup?: number | null; +}; diff --git a/packages/twenty-front/src/modules/views/types/ViewFilterGroupLogicalOperator.ts b/packages/twenty-front/src/modules/views/types/ViewFilterGroupLogicalOperator.ts new file mode 100644 index 000000000000..ad82a363cd13 --- /dev/null +++ b/packages/twenty-front/src/modules/views/types/ViewFilterGroupLogicalOperator.ts @@ -0,0 +1,4 @@ +export enum ViewFilterGroupLogicalOperator { + AND = 'AND', + OR = 'OR', +} diff --git a/packages/twenty-front/src/modules/views/types/ViewGroup.ts b/packages/twenty-front/src/modules/views/types/ViewGroup.ts new file mode 100644 index 000000000000..9f0cd3822298 --- /dev/null +++ b/packages/twenty-front/src/modules/views/types/ViewGroup.ts @@ -0,0 +1,8 @@ +export type ViewGroup = { + __typename: 'ViewGroup'; + id: string; + fieldMetadataId: string; + isVisible: boolean; + fieldValue: string; + position: number; +}; diff --git a/packages/twenty-front/src/modules/views/utils/getCombinedViewFilterGroups.ts b/packages/twenty-front/src/modules/views/utils/getCombinedViewFilterGroups.ts new file mode 100644 index 000000000000..c2ee1af3485b --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/getCombinedViewFilterGroups.ts @@ -0,0 +1,38 @@ +import { ViewFilterGroup } from '@/views/types/ViewFilterGroup'; + +export const getCombinedViewFilterGroups = ( + viewFilterGroups: ViewFilterGroup[], + unsavedToUpsertViewFilterGroups: ViewFilterGroup[], + unsavedToDeleteViewFilterGroupIds: string[], +): ViewFilterGroup[] => { + const toCreateViewFilterGroups = unsavedToUpsertViewFilterGroups.filter( + (toUpsertViewFilterGroup) => + !viewFilterGroups.some( + (viewFilterGroup) => viewFilterGroup.id === toUpsertViewFilterGroup.id, + ), + ); + + const toUpdateViewFilterGroups = unsavedToUpsertViewFilterGroups.filter( + (toUpsertViewFilterGroup) => + viewFilterGroups.some( + (viewFilterGroup) => viewFilterGroup.id === toUpsertViewFilterGroup.id, + ), + ); + + const combinedViewFilterGroups = viewFilterGroups + .filter( + (viewFilterGroup) => + !unsavedToDeleteViewFilterGroupIds.includes(viewFilterGroup.id), + ) + .map((viewFilterGroup) => { + const toUpdateViewFilterGroup = toUpdateViewFilterGroups.find( + (toUpdateViewFilterGroup) => + toUpdateViewFilterGroup.id === viewFilterGroup.id, + ); + + return toUpdateViewFilterGroup ?? viewFilterGroup; + }) + .concat(toCreateViewFilterGroups); + + return combinedViewFilterGroups; +}; diff --git a/packages/twenty-front/src/modules/views/utils/getCombinedViewFilters.ts b/packages/twenty-front/src/modules/views/utils/getCombinedViewFilters.ts index 1e172505dd96..7b81fcb152b4 100644 --- a/packages/twenty-front/src/modules/views/utils/getCombinedViewFilters.ts +++ b/packages/twenty-front/src/modules/views/utils/getCombinedViewFilters.ts @@ -8,24 +8,19 @@ export const getCombinedViewFilters = ( const toCreateViewFilters = toUpsertViewFilters.filter( (toUpsertViewFilter) => !viewFilters.some( - (viewFilter) => - viewFilter.fieldMetadataId === toUpsertViewFilter.fieldMetadataId, + (viewFilter) => viewFilter.id === toUpsertViewFilter.id, ), ); const toUpdateViewFilters = toUpsertViewFilters.filter((toUpsertViewFilter) => - viewFilters.some( - (viewFilter) => - viewFilter.fieldMetadataId === toUpsertViewFilter.fieldMetadataId, - ), + viewFilters.some((viewFilter) => viewFilter.id === toUpsertViewFilter.id), ); const combinedViewFilters = viewFilters .filter((viewFilter) => !toDeleteViewFilterIds.includes(viewFilter.id)) .map((viewFilter) => { const toUpdateViewFilter = toUpdateViewFilters.find( - (toUpdateViewFilter) => - toUpdateViewFilter.fieldMetadataId === viewFilter.fieldMetadataId, + (toUpdateViewFilter) => toUpdateViewFilter.id === viewFilter.id, ); return toUpdateViewFilter ?? viewFilter; diff --git a/packages/twenty-front/src/modules/views/utils/getQueryVariablesFromView.ts b/packages/twenty-front/src/modules/views/utils/getQueryVariablesFromView.ts index 7120068f8421..229929252af1 100644 --- a/packages/twenty-front/src/modules/views/utils/getQueryVariablesFromView.ts +++ b/packages/twenty-front/src/modules/views/utils/getQueryVariablesFromView.ts @@ -3,7 +3,7 @@ import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { formatFieldMetadataItemsAsFilterDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsFilterDefinitions'; import { formatFieldMetadataItemsAsSortDefinitions } from '@/object-metadata/utils/formatFieldMetadataItemsAsSortDefinitions'; import { turnSortsIntoOrderBy } from '@/object-record/object-sort-dropdown/utils/turnSortsIntoOrderBy'; -import { turnObjectDropdownFilterIntoQueryFilter } from '@/object-record/record-filter/utils/turnObjectDropdownFilterIntoQueryFilter'; +import { computeViewRecordGqlOperationFilter } from '@/object-record/record-filter/utils/computeViewRecordGqlOperationFilter'; import { View } from '@/views/types/View'; import { mapViewFiltersToFilters } from '@/views/utils/mapViewFiltersToFilters'; import { mapViewSortsToSorts } from '@/views/utils/mapViewSortsToSorts'; @@ -13,10 +13,12 @@ export const getQueryVariablesFromView = ({ view, fieldMetadataItems, objectMetadataItem, + isArrayAndJsonFilterEnabled, }: { view: View | null | undefined; fieldMetadataItems: FieldMetadataItem[]; objectMetadataItem: ObjectMetadataItem; + isArrayAndJsonFilterEnabled: boolean; }) => { if (!isDefined(view)) { return { @@ -25,19 +27,21 @@ export const getQueryVariablesFromView = ({ }; } - const { viewFilters, viewSorts } = view; + const { viewFilterGroups, viewFilters, viewSorts } = view; const filterDefinitions = formatFieldMetadataItemsAsFilterDefinitions({ fields: fieldMetadataItems, + isArrayAndJsonFilterEnabled, }); const sortDefinitions = formatFieldMetadataItemsAsSortDefinitions({ fields: fieldMetadataItems, }); - const filter = turnObjectDropdownFilterIntoQueryFilter( + const filter = computeViewRecordGqlOperationFilter( mapViewFiltersToFilters(viewFilters, filterDefinitions), objectMetadataItem?.fields ?? [], + viewFilterGroups ?? [], ); const orderBy = turnSortsIntoOrderBy( diff --git a/packages/twenty-front/src/modules/views/utils/mapRecordGroupDefinitionsToViewGroups.ts b/packages/twenty-front/src/modules/views/utils/mapRecordGroupDefinitionsToViewGroups.ts new file mode 100644 index 000000000000..b9251945195c --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/mapRecordGroupDefinitionsToViewGroups.ts @@ -0,0 +1,17 @@ +import { RecordGroupDefinition } from '@/object-record/record-group/types/RecordGroupDefinition'; +import { ViewGroup } from '@/views/types/ViewGroup'; + +export const mapRecordGroupDefinitionsToViewGroups = ( + groupDefinitions: RecordGroupDefinition[], +): ViewGroup[] => { + return groupDefinitions.map( + (groupDefinition): ViewGroup => ({ + __typename: 'ViewGroup', + id: groupDefinition.id, + fieldMetadataId: groupDefinition.fieldMetadataId, + position: groupDefinition.position, + isVisible: groupDefinition.isVisible ?? true, + fieldValue: groupDefinition.value ?? '', + }), + ); +}; diff --git a/packages/twenty-front/src/modules/views/utils/mapViewFiltersToFilters.ts b/packages/twenty-front/src/modules/views/utils/mapViewFiltersToFilters.ts index 773815c7ca58..2551637a0223 100644 --- a/packages/twenty-front/src/modules/views/utils/mapViewFiltersToFilters.ts +++ b/packages/twenty-front/src/modules/views/utils/mapViewFiltersToFilters.ts @@ -23,6 +23,8 @@ export const mapViewFiltersToFilters = ( value: viewFilter.value, displayValue: viewFilter.displayValue, operand: viewFilter.operand, + viewFilterGroupId: viewFilter.viewFilterGroupId, + positionInViewFilterGroup: viewFilter.positionInViewFilterGroup, definition: viewFilter.definition ?? availableFilterDefinition, }; }) diff --git a/packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts b/packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts new file mode 100644 index 000000000000..3767abe6a65d --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/mapViewGroupsToRecordGroupDefinitions.ts @@ -0,0 +1,79 @@ +import { isDefined } from '~/utils/isDefined'; + +import { ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; +import { + RecordGroupDefinition, + RecordGroupDefinitionType, +} from '@/object-record/record-group/types/RecordGroupDefinition'; +import { ViewGroup } from '@/views/types/ViewGroup'; +import { FieldMetadataType } from '~/generated-metadata/graphql'; + +export const mapViewGroupsToRecordGroupDefinitions = ({ + objectMetadataItem, + viewGroups, +}: { + objectMetadataItem: ObjectMetadataItem; + viewGroups: ViewGroup[]; +}): RecordGroupDefinition[] => { + if (viewGroups?.length === 0) { + return []; + } + + const fieldMetadataId = viewGroups?.[0]?.fieldMetadataId; + const selectFieldMetadataItem = objectMetadataItem.fields.find( + (field) => + field.id === fieldMetadataId && field.type === FieldMetadataType.Select, + ); + + if (!selectFieldMetadataItem) { + return []; + } + + if (!selectFieldMetadataItem.options) { + throw new Error( + `Select Field ${objectMetadataItem.nameSingular} has no options`, + ); + } + + const recordGroupDefinitionsFromViewGroups = viewGroups + .map((viewGroup) => { + const selectedOption = selectFieldMetadataItem.options?.find( + (option) => option.value === viewGroup.fieldValue, + ); + + if (!selectedOption) return null; + + return { + id: viewGroup.id, + fieldMetadataId: viewGroup.fieldMetadataId, + type: RecordGroupDefinitionType.Value, + title: selectedOption.label, + value: selectedOption.value, + color: selectedOption.color, + position: viewGroup.position, + isVisible: viewGroup.isVisible, + } as RecordGroupDefinition; + }) + .filter(isDefined) + .sort((a, b) => a.position - b.position); + + if (selectFieldMetadataItem.isNullable === true) { + const noValueColumn = { + id: 'no-value', + title: 'No Value', + type: RecordGroupDefinitionType.NoValue, + value: null, + position: + recordGroupDefinitionsFromViewGroups + .map((option) => option.position) + .reduce((a, b) => Math.max(a, b), 0) + 1, + isVisible: true, + fieldMetadataId: selectFieldMetadataItem.id, + color: 'transparent', + } satisfies RecordGroupDefinition; + + return [...recordGroupDefinitionsFromViewGroups, noValueColumn]; + } + + return recordGroupDefinitionsFromViewGroups; +}; diff --git a/packages/twenty-front/src/modules/views/utils/shouldReplaceFilter.ts b/packages/twenty-front/src/modules/views/utils/shouldReplaceFilter.ts new file mode 100644 index 000000000000..ac183045153e --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/shouldReplaceFilter.ts @@ -0,0 +1,18 @@ +import { Filter } from '@/object-record/object-filter-dropdown/types/Filter'; +import { isDefined } from 'twenty-ui'; + +export const shouldReplaceFilter = ( + oldFilter: Pick, + newFilter: Pick, +) => { + const isNewFilterAdvancedFilter = isDefined(newFilter.viewFilterGroupId); + + if (isNewFilterAdvancedFilter) { + return newFilter.id === oldFilter.id; + } else { + return ( + newFilter.fieldMetadataId === oldFilter.fieldMetadataId && + !oldFilter.viewFilterGroupId + ); + } +}; diff --git a/packages/twenty-front/src/modules/views/utils/sortViewFilterGroupsOutermostFirst.ts b/packages/twenty-front/src/modules/views/utils/sortViewFilterGroupsOutermostFirst.ts new file mode 100644 index 000000000000..7df13e95b8b5 --- /dev/null +++ b/packages/twenty-front/src/modules/views/utils/sortViewFilterGroupsOutermostFirst.ts @@ -0,0 +1,15 @@ +import { ViewFilterGroup } from '@/views/types/ViewFilterGroup'; + +export const sortViewFilterGroupsOutermostFirst = ( + viewFilterGroups: ViewFilterGroup[], + parentViewFilterGroupId?: string, +): ViewFilterGroup[] => { + const childGroups = viewFilterGroups.filter( + (group) => group.parentViewFilterGroupId === parentViewFilterGroupId, + ); + + return childGroups.flatMap((group) => [ + group, + ...sortViewFilterGroupsOutermostFirst(viewFilterGroups, group.id), + ]); +}; diff --git a/packages/twenty-front/src/modules/views/utils/view-filter-value/computeVariableDateViewFilterValue.ts b/packages/twenty-front/src/modules/views/view-filter-value/utils/computeVariableDateViewFilterValue.ts similarity index 83% rename from packages/twenty-front/src/modules/views/utils/view-filter-value/computeVariableDateViewFilterValue.ts rename to packages/twenty-front/src/modules/views/view-filter-value/utils/computeVariableDateViewFilterValue.ts index 1b09bc91348b..d34e4b7f1b35 100644 --- a/packages/twenty-front/src/modules/views/utils/view-filter-value/computeVariableDateViewFilterValue.ts +++ b/packages/twenty-front/src/modules/views/view-filter-value/utils/computeVariableDateViewFilterValue.ts @@ -1,7 +1,7 @@ import { VariableDateViewFilterValueDirection, VariableDateViewFilterValueUnit, -} from '@/views/utils/view-filter-value/resolveDateViewFilterValue'; +} from '@/views/view-filter-value/utils/resolveDateViewFilterValue'; export const computeVariableDateViewFilterValue = ( direction: VariableDateViewFilterValueDirection, diff --git a/packages/twenty-front/src/modules/views/utils/view-filter-value/resolveDateViewFilterValue.ts b/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveDateViewFilterValue.ts similarity index 100% rename from packages/twenty-front/src/modules/views/utils/view-filter-value/resolveDateViewFilterValue.ts rename to packages/twenty-front/src/modules/views/view-filter-value/utils/resolveDateViewFilterValue.ts diff --git a/packages/twenty-front/src/modules/views/utils/view-filter-value/resolveFilterValue.ts b/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveFilterValue.ts similarity index 91% rename from packages/twenty-front/src/modules/views/utils/view-filter-value/resolveFilterValue.ts rename to packages/twenty-front/src/modules/views/view-filter-value/utils/resolveFilterValue.ts index 34afbb46ad1a..c5c4e064507b 100644 --- a/packages/twenty-front/src/modules/views/utils/view-filter-value/resolveFilterValue.ts +++ b/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveFilterValue.ts @@ -1,7 +1,7 @@ import { Filter } from '@/object-record/object-filter-dropdown/types/Filter'; import { FilterableFieldType } from '@/object-record/object-filter-dropdown/types/FilterableFieldType'; import { ViewFilterOperand } from '@/views/types/ViewFilterOperand'; -import { resolveNumberViewFilterValue } from '@/views/utils/view-filter-value/resolveNumberViewFilterValue'; +import { resolveNumberViewFilterValue } from '@/views/view-filter-value/utils/resolveNumberViewFilterValue'; import { resolveDateViewFilterValue, ResolvedDateViewFilterValue, diff --git a/packages/twenty-front/src/modules/views/utils/view-filter-value/resolveNumberViewFilterValue.ts b/packages/twenty-front/src/modules/views/view-filter-value/utils/resolveNumberViewFilterValue.ts similarity index 100% rename from packages/twenty-front/src/modules/views/utils/view-filter-value/resolveNumberViewFilterValue.ts rename to packages/twenty-front/src/modules/views/view-filter-value/utils/resolveNumberViewFilterValue.ts diff --git a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx index c3ce24018b9e..84d32dc19365 100644 --- a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx +++ b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentCreateMode.tsx @@ -4,8 +4,8 @@ import { IconLayoutKanban, IconTable, IconX } from 'twenty-ui'; import { IconPicker } from '@/ui/input/components/IconPicker'; import { Select } from '@/ui/input/components/Select'; +import { TextInputV2 } from '@/ui/input/components/TextInputV2'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; -import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; @@ -120,11 +120,11 @@ export const ViewPickerContentCreateMode = () => { disableBlur onClose={() => setHotkeyScope(ViewsHotkeyScope.ListDropdown)} /> - { + onChange={(value) => { setViewPickerIsDirty(true); - setViewPickerInputName(event.target.value); + setViewPickerInputName(value); }} autoFocus /> diff --git a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentEditMode.tsx b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentEditMode.tsx index 3246651fb1fe..c478dbfb36b9 100644 --- a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentEditMode.tsx +++ b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerContentEditMode.tsx @@ -2,8 +2,8 @@ import { Key } from 'ts-key-enum'; import { IconChevronLeft } from 'twenty-ui'; import { IconPicker } from '@/ui/input/components/IconPicker'; +import { TextInputV2 } from '@/ui/input/components/TextInputV2'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader'; -import { DropdownMenuInput } from '@/ui/layout/dropdown/components/DropdownMenuInput'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { useScopedHotkeys } from '@/ui/utilities/hotkey/hooks/useScopedHotkeys'; @@ -79,11 +79,11 @@ export const ViewPickerContentEditMode = () => { disableBlur onClose={() => setHotkeyScope(ViewsHotkeyScope.ListDropdown)} /> - { + onChange={(value) => { setViewPickerIsDirty(true); - setViewPickerInputName(event.target.value); + setViewPickerInputName(value); }} autoFocus /> diff --git a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerCreateButton.tsx b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerCreateButton.tsx index 2fb0cb4f42c4..b2bb66204f20 100644 --- a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerCreateButton.tsx +++ b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerCreateButton.tsx @@ -1,4 +1,3 @@ -import { Button } from '@/ui/input/button/components/Button'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { ViewType } from '@/views/types/ViewType'; import { useCreateViewFromCurrentState } from '@/views/view-picker/hooks/useCreateViewFromCurrentState'; @@ -8,6 +7,7 @@ import { useViewPickerMode } from '@/views/view-picker/hooks/useViewPickerMode'; import { viewPickerIsPersistingComponentState } from '@/views/view-picker/states/viewPickerIsPersistingComponentState'; import { viewPickerKanbanFieldMetadataIdComponentState } from '@/views/view-picker/states/viewPickerKanbanFieldMetadataIdComponentState'; import { viewPickerTypeComponentState } from '@/views/view-picker/states/viewPickerTypeComponentState'; +import { Button } from 'twenty-ui'; export const ViewPickerCreateButton = () => { const { availableFieldsForKanban, navigateToSelectSettings } = diff --git a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerEditButton.tsx b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerEditButton.tsx index a2f67dcabb16..16df5ea4a3c7 100644 --- a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerEditButton.tsx +++ b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerEditButton.tsx @@ -1,4 +1,3 @@ -import { Button } from '@/ui/input/button/components/Button'; import { useRecoilComponentValueV2 } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValueV2'; import { ViewType } from '@/views/types/ViewType'; import { useCreateViewFromCurrentState } from '@/views/view-picker/hooks/useCreateViewFromCurrentState'; @@ -8,6 +7,7 @@ import { useViewPickerMode } from '@/views/view-picker/hooks/useViewPickerMode'; import { viewPickerIsPersistingComponentState } from '@/views/view-picker/states/viewPickerIsPersistingComponentState'; import { viewPickerKanbanFieldMetadataIdComponentState } from '@/views/view-picker/states/viewPickerKanbanFieldMetadataIdComponentState'; import { viewPickerTypeComponentState } from '@/views/view-picker/states/viewPickerTypeComponentState'; +import { Button } from 'twenty-ui'; export const ViewPickerEditButton = () => { const { availableFieldsForKanban, navigateToSelectSettings } = diff --git a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerListContent.tsx b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerListContent.tsx index 80d3f329a83a..00200448860d 100644 --- a/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerListContent.tsx +++ b/packages/twenty-front/src/modules/views/view-picker/components/ViewPickerListContent.tsx @@ -1,15 +1,21 @@ import styled from '@emotion/styled'; import { DropResult } from '@hello-pangea/dnd'; import { MouseEvent, useCallback } from 'react'; -import { IconLock, IconPencil, IconPlus, useIcons } from 'twenty-ui'; +import { + IconLock, + IconPencil, + IconPlus, + LightIconButtonAccent, + MenuItem, + MenuItemDraggable, + useIcons, +} from 'twenty-ui'; import { DraggableItem } from '@/ui/layout/draggable-list/components/DraggableItem'; import { DraggableList } from '@/ui/layout/draggable-list/components/DraggableList'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuSeparator } from '@/ui/layout/dropdown/components/DropdownMenuSeparator'; import { useDropdown } from '@/ui/layout/dropdown/hooks/useDropdown'; -import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; -import { MenuItemDraggable } from '@/ui/navigation/menu-item/components/MenuItemDraggable'; import { useSetRecoilComponentStateV2 } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentStateV2'; import { useChangeView } from '@/views/hooks/useChangeView'; import { useGetCurrentView } from '@/views/hooks/useGetCurrentView'; @@ -60,8 +66,6 @@ export const ViewPickerListContent = () => { const { getIcon } = useIcons(); - const indexView = viewsOnCurrentObject.find((view) => view.key === 'INDEX'); - const handleDragEnd = useCallback( (result: DropResult) => { if (!result.destination) return; @@ -81,33 +85,29 @@ export const ViewPickerListContent = () => { return ( <> - {indexView && ( - handleViewSelect(indexView.id)} - LeftIcon={getIcon(indexView.icon)} - text={indexView.name} - accent="placeholder" - isDragDisabled - /> - )} indexView?.id !== view.id) - .map((view, index) => ( - ( + handleViewSelect(view.id)} + LeftIcon={getIcon(view.icon)} + text={view.name} + /> + ) : ( { Icon: IconPencil, onClick: (event: MouseEvent) => handleEditViewButtonClick(event, view.id), + accent: 'tertiary' as LightIconButtonAccent, }, ].filter(isDefined)} - isIconDisplayedOnHoverOnly={ - indexView?.id === view.id ? false : true - } + isIconDisplayedOnHoverOnly={true} onClick={() => handleViewSelect(view.id)} LeftIcon={getIcon(view.icon)} text={view.name} /> - } - /> - ))} + ) + } + /> + ))} /> diff --git a/packages/twenty-front/src/modules/workflow/components/OverrideWorkflowDraftConfirmationModal.tsx b/packages/twenty-front/src/modules/workflow/components/OverrideWorkflowDraftConfirmationModal.tsx new file mode 100644 index 000000000000..d66b4629a732 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/components/OverrideWorkflowDraftConfirmationModal.tsx @@ -0,0 +1,62 @@ +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; +import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; +import { buildShowPageURL } from '@/object-record/record-show/utils/buildShowPageURL'; +import { + ConfirmationModal, + StyledCenteredButton, +} from '@/ui/layout/modal/components/ConfirmationModal'; +import { openOverrideWorkflowDraftConfirmationModalState } from '@/workflow/states/openOverrideWorkflowDraftConfirmationModalState'; +import { WorkflowVersion } from '@/workflow/types/Workflow'; +import { useRecoilState } from 'recoil'; + +export const OverrideWorkflowDraftConfirmationModal = ({ + draftWorkflowVersionId, + workflowVersionUpdateInput, +}: { + draftWorkflowVersionId: string; + workflowVersionUpdateInput: Pick; +}) => { + const [ + openOverrideWorkflowDraftConfirmationModal, + setOpenOverrideWorkflowDraftConfirmationModal, + ] = useRecoilState(openOverrideWorkflowDraftConfirmationModalState); + + const { updateOneRecord: updateOneWorkflowVersion } = + useUpdateOneRecord({ + objectNameSingular: CoreObjectNameSingular.WorkflowVersion, + }); + + const handleOverrideDraft = async () => { + await updateOneWorkflowVersion({ + idToUpdate: draftWorkflowVersionId, + updateOneRecordInput: workflowVersionUpdateInput, + }); + }; + + return ( + <> + { + setOpenOverrideWorkflowDraftConfirmationModal(false); + }} + variant="secondary" + title="Go to Draft" + fullWidth + /> + } + /> + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/components/RecordShowPageWorkflowHeader.tsx b/packages/twenty-front/src/modules/workflow/components/RecordShowPageWorkflowHeader.tsx index c2000974e5f9..2e741bcf37cc 100644 --- a/packages/twenty-front/src/modules/workflow/components/RecordShowPageWorkflowHeader.tsx +++ b/packages/twenty-front/src/modules/workflow/components/RecordShowPageWorkflowHeader.tsx @@ -1,9 +1,9 @@ -import { Button } from '@/ui/input/button/components/Button'; import { useActivateWorkflowVersion } from '@/workflow/hooks/useActivateWorkflowVersion'; import { useDeactivateWorkflowVersion } from '@/workflow/hooks/useDeactivateWorkflowVersion'; import { useDeleteOneWorkflowVersion } from '@/workflow/hooks/useDeleteOneWorkflowVersion'; import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; import { + Button, IconPlayerPlay, IconPlayerStop, IconPower, diff --git a/packages/twenty-front/src/modules/workflow/components/RecordShowPageWorkflowVersionHeader.tsx b/packages/twenty-front/src/modules/workflow/components/RecordShowPageWorkflowVersionHeader.tsx index 79cbe7c27f77..03c9c5a520c2 100644 --- a/packages/twenty-front/src/modules/workflow/components/RecordShowPageWorkflowVersionHeader.tsx +++ b/packages/twenty-front/src/modules/workflow/components/RecordShowPageWorkflowVersionHeader.tsx @@ -1,14 +1,21 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; -import { useUpdateOneRecord } from '@/object-record/hooks/useUpdateOneRecord'; -import { Button } from '@/ui/input/button/components/Button'; +import { OverrideWorkflowDraftConfirmationModal } from '@/workflow/components/OverrideWorkflowDraftConfirmationModal'; import { useActivateWorkflowVersion } from '@/workflow/hooks/useActivateWorkflowVersion'; import { useCreateNewWorkflowVersion } from '@/workflow/hooks/useCreateNewWorkflowVersion'; import { useDeactivateWorkflowVersion } from '@/workflow/hooks/useDeactivateWorkflowVersion'; import { useWorkflowVersion } from '@/workflow/hooks/useWorkflowVersion'; +import { openOverrideWorkflowDraftConfirmationModalState } from '@/workflow/states/openOverrideWorkflowDraftConfirmationModalState'; import { Workflow, WorkflowVersion } from '@/workflow/types/Workflow'; -import { IconPencil, IconPlayerStop, IconPower, isDefined } from 'twenty-ui'; +import { useSetRecoilState } from 'recoil'; +import { + Button, + IconPencil, + IconPlayerStop, + IconPower, + isDefined, +} from 'twenty-ui'; export const RecordShowPageWorkflowVersionHeader = ({ workflowVersionId, @@ -46,6 +53,8 @@ export const RecordShowPageWorkflowVersionHeader = ({ skip: !isDefined(workflowVersion), limit: 1, }); + const draftWorkflowVersion: WorkflowVersion | undefined = + draftWorkflowVersions[0]; const showUseAsDraftButton = !loadingDraftWorkflowVersions && @@ -57,7 +66,7 @@ export const RecordShowPageWorkflowVersionHeader = ({ workflowVersionRelatedWorkflowQuery.record.lastPublishedVersionId; const hasAlreadyDraftVersion = - !loadingDraftWorkflowVersions && draftWorkflowVersions.length > 0; + !loadingDraftWorkflowVersions && isDefined(draftWorkflowVersion); const isWaitingForWorkflowVersion = !isDefined(workflowVersion); @@ -65,10 +74,9 @@ export const RecordShowPageWorkflowVersionHeader = ({ const { deactivateWorkflowVersion } = useDeactivateWorkflowVersion(); const { createNewWorkflowVersion } = useCreateNewWorkflowVersion(); - const { updateOneRecord: updateOneWorkflowVersion } = - useUpdateOneRecord({ - objectNameSingular: CoreObjectNameSingular.WorkflowVersion, - }); + const setOpenOverrideWorkflowDraftConfirmationModal = useSetRecoilState( + openOverrideWorkflowDraftConfirmationModalState, + ); return ( <> @@ -80,13 +88,7 @@ export const RecordShowPageWorkflowVersionHeader = ({ disabled={isWaitingForWorkflowVersion} onClick={async () => { if (hasAlreadyDraftVersion) { - await updateOneWorkflowVersion({ - idToUpdate: draftWorkflowVersions[0].id, - updateOneRecordInput: { - trigger: workflowVersion.trigger, - steps: workflowVersion.steps, - }, - }); + setOpenOverrideWorkflowDraftConfirmationModal(true); } else { await createNewWorkflowVersion({ workflowId: workflowVersion.workflow.id, @@ -125,6 +127,16 @@ export const RecordShowPageWorkflowVersionHeader = ({ }} /> ) : null} + + {isDefined(workflowVersion) && isDefined(draftWorkflowVersion) ? ( + + ) : null} ); }; diff --git a/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowSelectActionContent.tsx b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowSelectActionContent.tsx index 094cc99e0996..0d13a0b394f7 100644 --- a/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowSelectActionContent.tsx +++ b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowSelectActionContent.tsx @@ -1,8 +1,8 @@ -import { MenuItem } from '@/ui/navigation/menu-item/components/MenuItem'; import { ACTIONS } from '@/workflow/constants/Actions'; import { useCreateStep } from '@/workflow/hooks/useCreateStep'; import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow'; import styled from '@emotion/styled'; +import { MenuItem } from 'twenty-ui'; const StyledActionListContainer = styled.div` display: flex; @@ -24,18 +24,16 @@ export const RightDrawerWorkflowSelectActionContent = ({ }); return ( - <> - - {ACTIONS.map((action) => ( - { - return createStep(action.type); - }} - /> - ))} - - + + {ACTIONS.map((action) => ( + { + return createStep(action.type); + }} + /> + ))} + ); }; diff --git a/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowSelectTriggerType.tsx b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowSelectTriggerType.tsx new file mode 100644 index 000000000000..7eb10fc6ecb2 --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowSelectTriggerType.tsx @@ -0,0 +1,16 @@ +import { RightDrawerWorkflowSelectTriggerTypeContent } from '@/workflow/components/RightDrawerWorkflowSelectTriggerTypeContent'; +import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; +import { workflowIdState } from '@/workflow/states/workflowIdState'; +import { useRecoilValue } from 'recoil'; +import { isDefined } from 'twenty-ui'; + +export const RightDrawerWorkflowSelectTriggerType = () => { + const workflowId = useRecoilValue(workflowIdState); + const workflow = useWorkflowWithCurrentVersion(workflowId); + + if (!isDefined(workflow)) { + return null; + } + + return ; +}; diff --git a/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowSelectTriggerTypeContent.tsx b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowSelectTriggerTypeContent.tsx new file mode 100644 index 000000000000..abe47dd1ebea --- /dev/null +++ b/packages/twenty-front/src/modules/workflow/components/RightDrawerWorkflowSelectTriggerTypeContent.tsx @@ -0,0 +1,58 @@ +import { useFilteredObjectMetadataItems } from '@/object-metadata/hooks/useFilteredObjectMetadataItems'; +import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; +import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; +import { TRIGGER_STEP_ID } from '@/workflow/constants/TriggerStepId'; +import { TRIGGER_TYPES } from '@/workflow/constants/TriggerTypes'; +import { useUpdateWorkflowVersionTrigger } from '@/workflow/hooks/useUpdateWorkflowVersionTrigger'; +import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState'; +import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow'; +import { getTriggerDefaultDefinition } from '@/workflow/utils/getTriggerDefaultDefinition'; +import styled from '@emotion/styled'; +import { useSetRecoilState } from 'recoil'; +import { MenuItem } from 'twenty-ui'; + +const StyledActionListContainer = styled.div` + display: flex; + flex-direction: column; + height: 100%; + overflow-y: auto; + + padding-block: ${({ theme }) => theme.spacing(1)}; + padding-inline: ${({ theme }) => theme.spacing(2)}; +`; + +export const RightDrawerWorkflowSelectTriggerTypeContent = ({ + workflow, +}: { + workflow: WorkflowWithCurrentVersion; +}) => { + const { updateTrigger } = useUpdateWorkflowVersionTrigger({ workflow }); + + const { activeObjectMetadataItems } = useFilteredObjectMetadataItems(); + + const { openRightDrawer } = useRightDrawer(); + const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState); + + return ( + + {TRIGGER_TYPES.map((action) => ( + { + await updateTrigger( + getTriggerDefaultDefinition({ + type: action.type, + activeObjectMetadataItems, + }), + ); + + setWorkflowSelectedNode(TRIGGER_STEP_ID); + + openRightDrawer(RightDrawerPages.WorkflowStepEdit); + }} + /> + ))} + + ); +}; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramBaseStepNode.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramBaseStepNode.tsx index 8484a29e7eaf..0318f61a6bf4 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramBaseStepNode.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramBaseStepNode.tsx @@ -21,13 +21,12 @@ const StyledStepNodeType = styled.div` ${({ theme }) => theme.border.radius.sm} 0 0; color: ${({ theme }) => theme.color.gray50}; - font-size: ${({ theme }) => theme.font.size.xs}; + font-size: ${({ theme }) => theme.font.size.md}; font-weight: ${({ theme }) => theme.font.weight.semiBold}; + margin-left: ${({ theme }) => theme.spacing(2)}; padding: ${({ theme }) => theme.spacing(1)} ${({ theme }) => theme.spacing(2)}; - position: absolute; - top: 0; - transform: translateY(-100%); + align-self: flex-start; .selectable.selected &, .selectable:focus &, @@ -62,11 +61,13 @@ const StyledStepNodeInnerContainer = styled.div<{ variant?: Variant }>` const StyledStepNodeLabel = styled.div<{ variant?: Variant }>` align-items: center; display: flex; - font-size: ${({ theme }) => theme.font.size.md}; + font-size: ${({ theme }) => theme.font.size.lg}; font-weight: ${({ theme }) => theme.font.weight.medium}; - column-gap: ${({ theme }) => theme.spacing(2)}; + column-gap: ${({ theme }) => theme.spacing(3)}; color: ${({ variant, theme }) => - variant === 'placeholder' ? theme.font.color.extraLight : null}; + variant === 'placeholder' + ? theme.font.color.extraLight + : theme.font.color.primary}; `; const StyledSourceHandle = styled(Handle)` @@ -78,9 +79,13 @@ export const StyledTargetHandle = styled(Handle)` `; const StyledRightFloatingElementContainer = styled.div` + display: flex; + align-items: center; position: absolute; + right: ${({ theme }) => theme.spacing(-3)}; + bottom: 0; + top: 0; transform: translateX(100%); - right: ${({ theme }) => theme.spacing(-2)}; `; export const WorkflowDiagramBaseStepNode = ({ @@ -102,9 +107,9 @@ export const WorkflowDiagramBaseStepNode = ({ ) : null} - - {capitalize(nodeType)} + {capitalize(nodeType)} + {Icon} diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasBase.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasBase.tsx index 79273d062aa6..55aab89ae27d 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasBase.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasBase.tsx @@ -1,3 +1,6 @@ +import { isRightDrawerMinimizedState } from '@/ui/layout/right-drawer/states/isRightDrawerMinimizedState'; +import { isRightDrawerOpenState } from '@/ui/layout/right-drawer/states/isRightDrawerOpenState'; +import { useIsMobile } from '@/ui/utilities/responsive/hooks/useIsMobile'; import { WorkflowVersionStatusTag } from '@/workflow/components/WorkflowVersionStatusTag'; import { workflowDiagramState } from '@/workflow/states/workflowDiagramState'; import { WorkflowVersionStatus } from '@/workflow/types/Workflow'; @@ -15,14 +18,16 @@ import { Background, EdgeChange, FitViewOptions, + getNodesBounds, NodeChange, NodeProps, ReactFlow, + useReactFlow, } from '@xyflow/react'; import '@xyflow/react/dist/style.css'; -import React, { useMemo } from 'react'; -import { useSetRecoilState } from 'recoil'; -import { GRAY_SCALE, isDefined } from 'twenty-ui'; +import React, { useEffect, useMemo, useRef } from 'react'; +import { useRecoilValue, useSetRecoilState } from 'recoil'; +import { GRAY_SCALE, isDefined, THEME_COMMON } from 'twenty-ui'; const StyledResetReactflowStyles = styled.div` height: 100%; @@ -35,6 +40,9 @@ const StyledResetReactflowStyles = styled.div` .react-flow__node-output, .react-flow__node-group { padding: 0; + width: auto; + text-align: start; + white-space: nowrap; } --xy-node-border-radius: none; @@ -51,10 +59,10 @@ const StyledStatusTagContainer = styled.div` padding: ${({ theme }) => theme.spacing(2)}; `; -const defaultFitViewOptions: FitViewOptions = { - minZoom: 1.3, - maxZoom: 1.3, -}; +const defaultFitViewOptions = { + minZoom: 1, + maxZoom: 1, +} satisfies FitViewOptions; export const WorkflowDiagramCanvasBase = ({ diagram, @@ -77,11 +85,29 @@ export const WorkflowDiagramCanvasBase = ({ >; children?: React.ReactNode; }) => { + const reactflow = useReactFlow(); + const { nodes, edges } = useMemo( () => getOrganizedDiagram(diagram), [diagram], ); + const isRightDrawerOpen = useRecoilValue(isRightDrawerOpenState); + const isRightDrawerMinimized = useRecoilValue(isRightDrawerMinimizedState); + const isMobile = useIsMobile(); + + const rightDrawerState = !isRightDrawerOpen + ? 'closed' + : isRightDrawerMinimized + ? 'minimized' + : isMobile + ? 'fullScreen' + : 'normal'; + + const rightDrawerWidth = Number( + THEME_COMMON.rightDrawerWidth.replace('px', ''), + ); + const setWorkflowDiagram = useSetRecoilState(workflowDiagramState); const handleNodesChange = ( @@ -118,27 +144,68 @@ export const WorkflowDiagramCanvasBase = ({ }); }; + const containerRef = useRef(null); + + useEffect(() => { + if (!isDefined(containerRef.current) || !reactflow.viewportInitialized) { + return; + } + + const currentViewport = reactflow.getViewport(); + + const flowBounds = getNodesBounds(reactflow.getNodes()); + + let visibleRightDrawerWidth = 0; + if (rightDrawerState === 'normal') { + visibleRightDrawerWidth = rightDrawerWidth; + } + + const viewportX = + (containerRef.current.offsetWidth + visibleRightDrawerWidth) / 2 - + flowBounds.width / 2; + + reactflow.setViewport( + { + ...currentViewport, + x: viewportX - visibleRightDrawerWidth, + }, + { duration: 300 }, + ); + }, [reactflow, rightDrawerState, rightDrawerWidth]); + return ( - + { - fitView(defaultFitViewOptions); + onInit={() => { + if (!isDefined(containerRef.current)) { + throw new Error('Expect the container ref to be defined'); + } + + const flowBounds = getNodesBounds(reactflow.getNodes()); + + reactflow.setViewport({ + x: containerRef.current.offsetWidth / 2 - flowBounds.width / 2, + y: 150, + zoom: defaultFitViewOptions.maxZoom, + }); }} + minZoom={defaultFitViewOptions.minZoom} + maxZoom={defaultFitViewOptions.maxZoom} nodeTypes={nodeTypes} - fitView nodes={nodes.map((node) => ({ ...node, draggable: false }))} edges={edges} onNodesChange={handleNodesChange} onEdgesChange={handleEdgesChange} + proOptions={{ hideAttribution: true }} > {children} - - - - + + + + ); }; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasEditable.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasEditable.tsx index ed953f511fa3..fe866fa72697 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasEditable.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasEditable.tsx @@ -5,6 +5,7 @@ import { WorkflowDiagramEmptyTrigger } from '@/workflow/components/WorkflowDiagr import { WorkflowDiagramStepNodeEditable } from '@/workflow/components/WorkflowDiagramStepNodeEditable'; import { WorkflowWithCurrentVersion } from '@/workflow/types/Workflow'; import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram'; +import { ReactFlowProvider } from '@xyflow/react'; export const WorkflowDiagramCanvasEditable = ({ diagram, @@ -14,17 +15,19 @@ export const WorkflowDiagramCanvasEditable = ({ workflowWithCurrentVersion: WorkflowWithCurrentVersion; }) => { return ( - - - + + + + + ); }; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasEditableEffect.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasEditableEffect.tsx index ad383a527b8c..ae68427e7922 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasEditableEffect.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasEditableEffect.tsx @@ -1,5 +1,9 @@ import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; +import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope'; import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; +import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; +import { CREATE_STEP_STEP_ID } from '@/workflow/constants/CreateStepStepId'; +import { EMPTY_TRIGGER_STEP_ID } from '@/workflow/constants/EmptyTriggerStepId'; import { useStartNodeCreation } from '@/workflow/hooks/useStartNodeCreation'; import { useTriggerNodeSelection } from '@/workflow/hooks/useTriggerNodeSelection'; import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState'; @@ -13,6 +17,8 @@ export const WorkflowDiagramCanvasEditableEffect = () => { const { startNodeCreation } = useStartNodeCreation(); const { openRightDrawer, closeRightDrawer } = useRightDrawer(); + const setHotkeyScope = useSetHotkeyScope(); + const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState); const handleSelectionChange = useCallback( @@ -26,7 +32,14 @@ export const WorkflowDiagramCanvasEditableEffect = () => { return; } - const isCreateStepNode = selectedNode.type === 'create-step'; + const isEmptyTriggerNode = selectedNode.type === EMPTY_TRIGGER_STEP_ID; + if (isEmptyTriggerNode) { + openRightDrawer(RightDrawerPages.WorkflowStepSelectTriggerType); + + return; + } + + const isCreateStepNode = selectedNode.type === CREATE_STEP_STEP_ID; if (isCreateStepNode) { if (selectedNode.data.nodeType !== 'create-step') { throw new Error('Expected selected node to be a create step node.'); @@ -38,9 +51,11 @@ export const WorkflowDiagramCanvasEditableEffect = () => { } setWorkflowSelectedNode(selectedNode.id); + setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false }); openRightDrawer(RightDrawerPages.WorkflowStepEdit); }, [ + setHotkeyScope, closeRightDrawer, openRightDrawer, setWorkflowSelectedNode, diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasReadonly.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasReadonly.tsx index d6c50fa9034e..5302046f22f9 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasReadonly.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasReadonly.tsx @@ -4,6 +4,7 @@ import { WorkflowDiagramEmptyTrigger } from '@/workflow/components/WorkflowDiagr import { WorkflowDiagramStepNodeReadonly } from '@/workflow/components/WorkflowDiagramStepNodeReadonly'; import { WorkflowVersion } from '@/workflow/types/Workflow'; import { WorkflowDiagram } from '@/workflow/types/WorkflowDiagram'; +import { ReactFlowProvider } from '@xyflow/react'; export const WorkflowDiagramCanvasReadonly = ({ diagram, @@ -13,16 +14,18 @@ export const WorkflowDiagramCanvasReadonly = ({ workflowVersion: WorkflowVersion; }) => { return ( - - - + + + + + ); }; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasReadonlyEffect.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasReadonlyEffect.tsx index 17cf9ae1df2e..e823ce2c6d53 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasReadonlyEffect.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCanvasReadonlyEffect.tsx @@ -1,5 +1,7 @@ import { useRightDrawer } from '@/ui/layout/right-drawer/hooks/useRightDrawer'; +import { RightDrawerHotkeyScope } from '@/ui/layout/right-drawer/types/RightDrawerHotkeyScope'; import { RightDrawerPages } from '@/ui/layout/right-drawer/types/RightDrawerPages'; +import { useSetHotkeyScope } from '@/ui/utilities/hotkey/hooks/useSetHotkeyScope'; import { useTriggerNodeSelection } from '@/workflow/hooks/useTriggerNodeSelection'; import { workflowSelectedNodeState } from '@/workflow/states/workflowSelectedNodeState'; import { WorkflowDiagramNode } from '@/workflow/types/WorkflowDiagram'; @@ -11,6 +13,7 @@ import { isDefined } from 'twenty-ui'; export const WorkflowDiagramCanvasReadonlyEffect = () => { const { openRightDrawer, closeRightDrawer } = useRightDrawer(); const setWorkflowSelectedNode = useSetRecoilState(workflowSelectedNodeState); + const setHotkeyScope = useSetHotkeyScope(); const handleSelectionChange = useCallback( ({ nodes }: OnSelectionChangeParams) => { @@ -24,9 +27,15 @@ export const WorkflowDiagramCanvasReadonlyEffect = () => { } setWorkflowSelectedNode(selectedNode.id); + setHotkeyScope(RightDrawerHotkeyScope.RightDrawer, { goto: false }); openRightDrawer(RightDrawerPages.WorkflowStepView); }, - [closeRightDrawer, openRightDrawer, setWorkflowSelectedNode], + [ + closeRightDrawer, + openRightDrawer, + setWorkflowSelectedNode, + setHotkeyScope, + ], ); useOnSelectionChange({ diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCreateStepNode.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCreateStepNode.tsx index 2e1b1328a0b1..98b93c689ada 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCreateStepNode.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramCreateStepNode.tsx @@ -1,7 +1,6 @@ -import { IconButton } from '@/ui/input/button/components/IconButton'; import styled from '@emotion/styled'; import { Handle, Position } from '@xyflow/react'; -import { IconPlus } from 'twenty-ui'; +import { IconButton, IconPlus } from 'twenty-ui'; export const StyledTargetHandle = styled(Handle)` visibility: hidden; @@ -12,7 +11,7 @@ export const WorkflowDiagramCreateStepNode = () => { <> - + ); }; diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramEffect.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramEffect.tsx index 8b714cc7613c..a95b709e160e 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramEffect.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramEffect.tsx @@ -27,7 +27,9 @@ export const WorkflowDiagramEffect = ({ workflowDiagramState, ); - const nextWorkflowDiagram = getWorkflowVersionDiagram(currentVersion); + const nextWorkflowDiagram = addCreateStepNodes( + getWorkflowVersionDiagram(currentVersion), + ); let mergedWorkflowDiagram = nextWorkflowDiagram; if (isDefined(previousWorkflowDiagram)) { @@ -37,11 +39,7 @@ export const WorkflowDiagramEffect = ({ ); } - const workflowDiagramWithCreateStepNodes = addCreateStepNodes( - mergedWorkflowDiagram, - ); - - set(workflowDiagramState, workflowDiagramWithCreateStepNodes); + set(workflowDiagramState, mergedWorkflowDiagram); }; }, [], diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramStepNodeBase.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramStepNodeBase.tsx index 0fc4d8591051..04c6a62be931 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramStepNodeBase.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramStepNodeBase.tsx @@ -3,7 +3,7 @@ import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram'; import { assertUnreachable } from '@/workflow/utils/assertUnreachable'; import { useTheme } from '@emotion/react'; import styled from '@emotion/styled'; -import { IconCode, IconMail, IconPlaylistAdd } from 'twenty-ui'; +import { IconCode, IconHandMove, IconMail, IconPlaylistAdd } from 'twenty-ui'; const StyledStepNodeLabelIconContainer = styled.div` align-items: center; @@ -26,17 +26,30 @@ export const WorkflowDiagramStepNodeBase = ({ const renderStepIcon = () => { switch (data.nodeType) { case 'trigger': { - return ( - - - - ); - } - case 'condition': { - return null; + switch (data.triggerType) { + case 'DATABASE_EVENT': { + return ( + + + + ); + } + case 'MANUAL': { + return ( + + + + ); + } + } + + return assertUnreachable(data.triggerType); } case 'action': { switch (data.actionType) { @@ -44,7 +57,7 @@ export const WorkflowDiagramStepNodeBase = ({ return ( @@ -53,7 +66,7 @@ export const WorkflowDiagramStepNodeBase = ({ case 'SEND_EMAIL': { return ( - + ); } diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramStepNodeEditable.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramStepNodeEditable.tsx index cb8290fd732f..9df7f0bbc42f 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramStepNodeEditable.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowDiagramStepNodeEditable.tsx @@ -1,4 +1,3 @@ -import { FloatingIconButton } from '@/ui/input/button/components/FloatingIconButton'; import { WorkflowDiagramStepNodeBase } from '@/workflow/components/WorkflowDiagramStepNodeBase'; import { useDeleteOneStep } from '@/workflow/hooks/useDeleteOneStep'; import { useWorkflowWithCurrentVersion } from '@/workflow/hooks/useWorkflowWithCurrentVersion'; @@ -6,7 +5,7 @@ import { workflowIdState } from '@/workflow/states/workflowIdState'; import { WorkflowDiagramStepNodeData } from '@/workflow/types/WorkflowDiagram'; import { assertWorkflowWithCurrentVersionIsDefined } from '@/workflow/utils/assertWorkflowWithCurrentVersionIsDefined'; import { useRecoilValue } from 'recoil'; -import { IconTrash } from 'twenty-ui'; +import { FloatingIconButton, IconTrash } from 'twenty-ui'; export const WorkflowDiagramStepNodeEditable = ({ id, @@ -33,6 +32,7 @@ export const WorkflowDiagramStepNodeEditable = ({ RightFloatingElement={ selected ? ( { return deleteOneStep(); diff --git a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx index be2a495202ac..4dafbc3b4aa2 100644 --- a/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx +++ b/packages/twenty-front/src/modules/workflow/components/WorkflowEditActionFormSendEmail.tsx @@ -1,28 +1,19 @@ -import { TextArea } from '@/ui/input/components/TextArea'; -import { TextInput } from '@/ui/input/components/TextInput'; -import { WorkflowEditActionFormBase } from '@/workflow/components/WorkflowEditActionFormBase'; +import { GMAIL_SEND_SCOPE } from '@/accounts/constants/GmailSendScope'; +import { ConnectedAccount } from '@/accounts/types/ConnectedAccount'; +import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; +import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; +import { useTriggerApisOAuth } from '@/settings/accounts/hooks/useTriggerApiOAuth'; +import { Select, SelectOption } from '@/ui/input/components/Select'; +import { WorkflowEditGenericFormBase } from '@/workflow/components/WorkflowEditGenericFormBase'; +import { VariableTagInput } from '@/workflow/search-variables/components/VariableTagInput'; +import { workflowIdState } from '@/workflow/states/workflowIdState'; import { WorkflowSendEmailStep } from '@/workflow/types/Workflow'; import { useTheme } from '@emotion/react'; -import styled from '@emotion/styled'; -import React, { useEffect } from 'react'; +import { useEffect } from 'react'; import { Controller, useForm } from 'react-hook-form'; +import { useRecoilValue } from 'recoil'; import { IconMail, IconPlus, isDefined } from 'twenty-ui'; import { useDebouncedCallback } from 'use-debounce'; -import { Select, SelectOption } from '@/ui/input/components/Select'; -import { useFindManyRecords } from '@/object-record/hooks/useFindManyRecords'; -import { ConnectedAccount } from '@/accounts/types/ConnectedAccount'; -import { useRecoilValue } from 'recoil'; -import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState'; -import { useTriggerGoogleApisOAuth } from '@/settings/accounts/hooks/useTriggerGoogleApisOAuth'; -import { workflowIdState } from '@/workflow/states/workflowIdState'; -import { GMAIL_SEND_SCOPE } from '@/accounts/constants/GmailSendScope'; - -const StyledTriggerSettings = styled.div` - padding: ${({ theme }) => theme.spacing(6)}; - display: flex; - flex-direction: column; - row-gap: ${({ theme }) => theme.spacing(4)}; -`; type WorkflowEditActionFormSendEmailProps = | { @@ -37,6 +28,7 @@ type WorkflowEditActionFormSendEmailProps = type SendEmailFormData = { connectedAccountId: string; + email: string; subject: string; body: string; }; @@ -46,13 +38,15 @@ export const WorkflowEditActionFormSendEmail = ( ) => { const theme = useTheme(); const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState); - const { triggerGoogleApisOAuth } = useTriggerGoogleApisOAuth(); + const { triggerApisOAuth } = useTriggerApisOAuth(); + const workflowId = useRecoilValue(workflowIdState); const redirectUrl = `/object/workflow/${workflowId}`; const form = useForm({ defaultValues: { connectedAccountId: '', + email: '', subject: '', body: '', }, @@ -73,7 +67,7 @@ export const WorkflowEditActionFormSendEmail = ( !isDefined(scopes) || !isDefined(scopes.find((scope) => scope === GMAIL_SEND_SCOPE)) ) { - await triggerGoogleApisOAuth({ + await triggerApisOAuth('google', { redirectLocation: redirectUrl, loginHint: connectedAccount.handle, }); @@ -83,10 +77,11 @@ export const WorkflowEditActionFormSendEmail = ( useEffect(() => { form.setValue( 'connectedAccountId', - props.action.settings.connectedAccountId ?? '', + props.action.settings.input.connectedAccountId ?? '', ); - form.setValue('subject', props.action.settings.subject ?? ''); - form.setValue('body', props.action.settings.body ?? ''); + form.setValue('email', props.action.settings.input.email ?? ''); + form.setValue('subject', props.action.settings.input.subject ?? ''); + form.setValue('body', props.action.settings.input.body ?? ''); }, [props.action.settings, form]); const saveAction = useDebouncedCallback( @@ -99,9 +94,12 @@ export const WorkflowEditActionFormSendEmail = ( ...props.action, settings: { ...props.action.settings, - connectedAccountId: formData.connectedAccountId, - subject: formData.subject, - body: formData.body, + input: { + connectedAccountId: formData.connectedAccountId, + email: formData.email, + subject: formData.subject, + body: formData.body, + }, }, }); @@ -134,12 +132,12 @@ export const WorkflowEditActionFormSendEmail = ( }; if ( - isDefined(props.action.settings.connectedAccountId) && - props.action.settings.connectedAccountId !== '' + isDefined(props.action.settings.input.connectedAccountId) && + props.action.settings.input.connectedAccountId !== '' ) { filter.or.push({ id: { - eq: props.action.settings.connectedAccountId, + eq: props.action.settings.input.connectedAccountId, }, }); } @@ -168,70 +166,85 @@ export const WorkflowEditActionFormSendEmail = ( return ( !loading && ( - } - actionTitle="Send Email" - actionType="Email" + } + headerTitle="Send Email" + headerType="Email" > - - ( -